[
  {
    "path": ".config/nextest.toml",
    "content": "[profile.ci-core]\n# Exclude language-specific integration tests from the main CI runs (except unimplemented/unsupported/script/fail/pygrep).\ndefault-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::)))\"\nstatus-level = \"skip\"\nfinal-status-level = \"slow\"\nfailure-output = \"immediate\"\nfail-fast = false\ntest-threads = 8\n\n[profile.lang-bun]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(bun::)\"\n\n[profile.lang-deno]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(deno::)\"\n\n[profile.lang-docker]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and (test(docker::) or test(docker_image::))\"\n\n[profile.lang-golang]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(golang::)\"\n\n[profile.lang-haskell]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(haskell::)\"\n\n[profile.lang-julia]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(julia::)\"\n\n[profile.lang-lua]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(lua::)\"\n# LuaRocks can hit a race condition when multiple processes are installing the same package; run Lua tests serially.\nthreads-required = \"num-cpus\"\n\n[profile.lang-node]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(node::)\"\n\n[profile.lang-python]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(python::)\"\n\n[profile.lang-ruby]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(ruby::)\"\n\n[profile.lang-rust]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(rust::)\"\n\n[profile.lang-swift]\ninherits = \"ci-core\"\ndefault-filter = \"binary_id(prek::languages) and test(swift::)\"\n"
  },
  {
    "path": ".config/taplo.toml",
    "content": "[formatting]\nalign_comments = false\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/rust\n{\n  \"name\": \"prek\",\n  // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n  \"image\": \"mcr.microsoft.com/devcontainers/rust:1-1-bookworm\",\n  \"hostRequirements\": {\n   \"cpus\": 4,\n   \"memory\": \"16gb\",\n   \"storage\": \"32gb\"\n  },\n  \"features\": {\n    \"ghcr.io/devcontainers/features/docker-outside-of-docker:1\": {},\n    \"ghcr.io/devcontainers/features/github-cli:1\": {},\n    \"ghcr.io/devcontainers-extra/features/mise:1\": {},\n    \"ghcr.io/devcontainers-extra/features/uv:1\": {},\n  }\n  // Use 'mounts' to make the cargo cache persistent in a Docker Volume.\n  // \"mounts\": [\n  // \t{\n  // \t\t\"source\": \"devcontainer-cargo-cache-${devcontainerId}\",\n  // \t\t\"target\": \"/usr/local/cargo\",\n  // \t\t\"type\": \"volume\"\n  // \t}\n  // ]\n  // Features to add to the dev container. More info: https://containers.dev/features.\n  // \"features\": {},\n  // Use 'forwardPorts' to make a list of ports inside the container available locally.\n  // \"forwardPorts\": [],\n  // Use 'postCreateCommand' to run commands after the container is created.\n  // \"postCreateCommand\": \"rustc --version\",\n  // Configure tool-specific properties.\n  // \"customizations\": {},\n  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.\n  // \"remoteUser\": \"root\"\n}\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Ensure consistent line endings across platforms (avoid LF -> CRLF on Windows)\n* text=auto eol=lf\n\nprek.schema.json linguist-generated=true\nscripts/macports/Portfile linguist-generated=true\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @j178\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: Bug report\ndescription: Create a report to help us improve prek\nbody:\n  - type: textarea\n    attributes:\n      label: Summary\n      description: |\n        A clear and concise description of the bug, including a minimal reproducible example.\n        If we cannot reproduce the bug, it is unlikely that we will be able to help you.\n    validations:\n      required: true\n\n  - type: checkboxes\n    attributes:\n      label: Willing to submit a PR?\n      description: |\n        If you have time, we welcome pull requests.\n      options:\n        - label: Yes — I’m willing to open a PR to fix this.\n\n  - type: input\n    attributes:\n      label: Platform\n      description: What operating system and architecture are you using? (see `uname -orsm`)\n      placeholder: e.g., macOS 14 arm64, Windows 11 x86_64, Ubuntu 20.04 amd64\n    validations:\n      required: true\n\n  - type: input\n    attributes:\n      label: Version\n      description: What version of prek are you using? (see `prek -V`)\n      placeholder: e.g., prek 0.2.3 (7fe75a86d 2025-09-29)\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: .pre-commit-config.yaml\n      description: |\n        Please attach or paste the contents of your `.pre-commit-config.yaml` file if relevant.\n      value: |\n        ```yaml\n\n        ```\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Log file\n      description: |\n        Please attach or paste the contents of the trace log file located at:\n        - Linux/macOS: `~/.cache/prek/prek.log`\n        - Windows: `%LOCALAPPDATA%\\prek\\prek.log`\n        If the log file doesn't exist or is empty, please run your command with increased verbosity:\n        ```bash\n        prek -vvv [your command]\n        ```\n      value: |\n        ```\n\n        ```\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/codecov.yml",
    "content": "coverage:\n  status:\n    project:\n      default:\n        target: auto\n        threshold: 1%\n        informational: true\n    patch:\n      default:\n        target: auto\n        threshold: 1%\n        informational: true\n\nignore:\n  - \"tests/**/*\"\n  - \"examples/**/*\"\n  - \"target/**/*\"\n  - \"crates/prek-pty/**/*\"\n  - \"crates/prek-yaml/**/*\"\n"
  },
  {
    "path": ".github/copilot-instructions.md",
    "content": "# Copilot instructions for `prek`\n\n## Code requirements\n\n- Concise, idiomatic Rust (2024 edition).\n- Proper error handling (no unwraps, panics, etc. in app code).\n- Clear separation of concerns (e.g., config parsing vs. execution).\n- Thorough test coverage (unit + integration tests, snapshot testing where appropriate).\n\n## Big picture\n\n- Rust workspace under `crates/*`. The main CLI binary is `crates/prek` (`src/main.rs`).\n- `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/`.\n- User-facing output is centralized:\n    - Warnings go through `warn_user!` / `warn_user_once!` in `crates/prek/src/warnings.rs` (can be disabled via `-q` / `-qq`).\n    - Progress/output selection is via `Printer` in `crates/prek/src/printer.rs`.\n- Cross-process coordination uses a store under `$PREK_HOME` (see `Store::from_settings` / `Store::lock_async` in `crates/prek/src/store.rs`).\n\n## Developer workflows (preferred)\n\n- Lint/format like CI: `mise run lint` (runs `cargo fmt` + `cargo clippy --all-targets --all-features --workspace -- -D warnings`).\n- Run all tests: `mise run test` (workspace, all targets/features).\n- Snapshot-first test workflow (insta):\n    - Unit/bin tests with review UI: `mise run test-unit -- <filter>` or `mise run test-all-unit`.\n    - Integration tests with review UI: `mise run test-integration <test> [filter]` or `mise run test-all-integration`.\n    - DO NOT run `cargo test -p prek` while testing, they are slow. Use `cargo test -p prek --lib <unit-test> -- --exact` (or `cargo test -p prek --bin prek <unit-test> -- --exact`) for unit tests and `cargo test -p prek --test <test> -- <filter>` for integration tests.\n    - Use `cargo insta review --accept` to accept snapshot changes after running tests locally.\n\n## Project-specific conventions\n\n- Prefer `fs-err` / `fs-err::tokio` over `std::fs` / `tokio::fs` for filesystem operations (see many modules, e.g. `crates/prek/src/store.rs`).\n- Prefer `anyhow::Result` for app-level flows and `thiserror` for typed errors when there’s a clear domain (see `crates/prek/src/store.rs`).\n- Logging uses `tracing`; default behavior is configured in `crates/prek/src/main.rs` and can be overridden via `RUST_LOG`.\n- 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.\n\n## Tests and fixtures\n\n- Integration tests use `TestContext` helpers in `crates/prek/tests/common/mod.rs` and snapshot macros like `cmd_snapshot!`.\n- Tests often normalize paths with regex filters; prefer using `context.filters()` for stable snapshots.\n\n## Docs + generated artifacts\n\n- Docs are built with Zensical (see `mkdocs.yml`); run locally via `mise run build-docs`.\n- CLI reference + JSON schema are generated via `mise run generate` (see tasks in `mise.toml`).\n\n## When changing behavior\n\n- If a change affects user-visible output, update the relevant snapshot(s) under `crates/prek/tests/` (use the `cargo insta` review flow).\n- Keep output stable and routed through existing printer/warning macros rather than printing directly.\n"
  },
  {
    "path": ".github/renovate.json5",
    "content": "{\n  $schema: 'https://docs.renovatebot.com/renovate-schema.json',\n  extends: [\n    'config:recommended',\n    // https://docs.renovatebot.com/presets-default/#configmigration\n    ':configMigration',\n    // https://docs.renovatebot.com/presets-customManagers/#custommanagersgithubactionsversions\n    // Update _VERSION environment variables in GitHub Action files.\n    'customManagers:githubActionsVersions',\n  ],\n  schedule: [\n    '* 1-3 * * 1'\n  ],\n  // release.yml is generated by cargo-dist and should not be updated.\n  ignorePaths: [\n    '.github/workflows/release.yml'\n  ],\n  prHourlyLimit: 10,\n  labels: [\n    'internal'\n  ],\n  semanticCommits: 'disabled',\n  // pre-commit is currently in beta testing, must opt in\n  'pre-commit': {\n    enabled: true\n  },\n  enabledManagers: [\n    'github-actions',\n    'pre-commit',\n    'cargo',\n    'custom.regex',\n  ],\n  customManagers: [\n    // Update `uv` version in `crates/prek/src/languages/python/uv.rs` and CI workflows.\n    {\n      customType: 'regex',\n      managerFilePatterns: [\n        '/src/languages/python/uv.rs$/',\n        '/.github/workflows/.*\\\\.yml$/',\n      ],\n      datasourceTemplate: 'pypi',\n      packageNameTemplate: 'uv',\n      matchStrings: [\n        'const\\\\s+CUR_UV_VERSION\\\\s*:\\\\s*&str\\\\s*=\\\\s*\"(?<currentValue>\\\\d+\\\\.\\\\d+\\\\.\\\\d+)\"',\n        'UV_VERSION:\\\\s*\"(?<currentValue>\\\\d+\\\\.\\\\d+\\\\.\\\\d+)\"',\n      ]\n    },\n    // Update major GitHub actions references in documentation.\n    {\n      customType: 'regex',\n      managerFilePatterns: [\n        '/README\\\\.md$/',\n        '/^docs/.*\\\\.md$/'\n      ],\n      matchStrings: [\n        '\\\\suses: (?<depName>[\\\\w-]+/[\\\\w-]+)(?<path>/.*)?@(?<currentValue>.+?)\\\\s',\n      ],\n      datasourceTemplate: 'github-tags',\n      versioningTemplate: 'regex:^v(?<major>\\\\d+)$',\n    },\n    // Minimum supported Rust toolchain version\n    {\n      customType: \"regex\",\n      managerFilePatterns: [\"/(^|/)Cargo\\\\.toml$/\"],\n      matchStrings: [\n        'rust-version\\\\s*=\\\\s*\"(?<currentValue>\\\\d+\\\\.\\\\d+(\\\\.\\\\d+)?)\"',\n      ],\n      depNameTemplate: \"msrv\",\n      packageNameTemplate: \"rust-lang/rust\",\n      datasourceTemplate: \"github-releases\",\n    },\n    // Rust toolchain version\n    {\n      customType: \"regex\",\n      managerFilePatterns: [\"/(^|/)rust-toolchain\\\\.toml$/\"],\n      matchStrings: [\n        'channel\\\\s*=\\\\s*\"(?<currentValue>\\\\d+\\\\.\\\\d+(\\\\.\\\\d+)?)\"',\n      ],\n      depNameTemplate: \"rust\",\n      packageNameTemplate: \"rust-lang/rust\",\n      datasourceTemplate: \"github-releases\",\n    }\n  ],\n  packageRules: [\n    // Group all GitHub Actions updates together\n    {\n      groupName: 'GitHub Actions',\n      matchManagers: ['github-actions'],\n      matchDepTypes: ['action'],\n      // Pin GitHub Actions to immutable SHAs\n      pinDigests: true\n    },\n    {\n      groupName: 'pre-commit',\n      matchManagers: ['pre-commit'],\n      matchFileNames: ['.pre-commit-config.yaml']\n    },\n    // Annotate GitHub Actions SHAs with a SemVer version.\n    {\n      extends: [\n        'helpers:pinGitHubActionDigests'\n      ],\n      extractVersion: '^(?<version>v?\\\\d+\\\\.\\\\d+\\\\.\\\\d+)$',\n      versioning: 'regex:^v?(?<major>\\\\d+)(\\\\.(?<minor>\\\\d+)\\\\.(?<patch>\\\\d+))?$'\n    },\n    // Disable updates for GitHub runners: we'd only pin them to a specific version\n    // if there was a deliberate reason to do so\n    {\n      groupName: 'GitHub runners',\n      matchManagers: [\n        'github-actions'\n      ],\n      matchDatasources: [\n        'github-runners'\n      ],\n      description: \"Disable PRs updating GitHub runners (e.g. 'runs-on: macos-14')\",\n      enabled: false\n    },\n    // Disable updates for the msvc-dev-cmd action, the latest version does not work.\n    {\n      matchManagers: [\n        \"github-actions\"\n      ],\n      matchDepTypes: [\n        \"action\"\n      ],\n      matchDepNames: [\n        \"ilammy/msvc-dev-cmd\"\n      ],\n      enabled: false\n    },\n    {\n      matchManagers: [\"custom.regex\"],\n      matchDepNames: [\"rust\"],\n      commitMessageTopic: \"Rust\",\n    },\n    {\n      matchManagers: [ 'cargo', 'pre-commit', 'github-actions', 'custom.regex' ],\n      minimumReleaseAge: '7 days'\n    },\n    {\n      commitMessageTopic: \"MSRV\",\n      matchManagers: [\"custom.regex\"],\n      matchDepNames: [\"msrv\"],\n      // We have a rolling support policy for the MSRV\n      // 2 releases back * 6 weeks per release * 7 days per week + 1\n      minimumReleaseAge: \"85 days\",\n      internalChecksFilter: \"strict\",\n      groupName: \"MSRV\",\n    }\n  ]\n}\n"
  },
  {
    "path": ".github/workflows/build-binaries.yml",
    "content": "# Build prek on all platforms.\n#\n# Generates both wheels (for PyPI) and archived binaries (for GitHub releases).\n# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local\n# artifacts job within `cargo-dist`.\n#\n# Adapted from https://github.com/astral-sh/uv/blob/main/.github/workflows/build-binaries.yml\n\nname: \"Build binaries\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n  pull_request:\n    paths:\n      # When we change pyproject.toml, we want to ensure that the maturin builds still work.\n      - pyproject.toml\n      # And when we change this workflow itself...\n      - .github/workflows/build-binaries.yml\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CARGO_INCREMENTAL: 0\n  CARGO_NET_RETRY: 10\n  CARGO_TERM_COLOR: always\n  RUSTUP_MAX_RETRIES: 10\n  # renovate: datasource=github-releases depName=PyO3/maturin versioning=semver\n  MATURIN_VERSION: \"v1.12.6\"\n\njobs:\n  sdist:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: Build sdist\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          command: sdist\n          args: --out dist\n      - name: \"Upload sdist\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-sdist\n          path: dist\n\n  windows:\n    runs-on: windows-latest\n    strategy:\n      matrix:\n        platform:\n          - target: x86_64-pc-windows-msvc\n            arch: x64\n          - target: i686-pc-windows-msvc\n            arch: x86\n          - target: aarch64-pc-windows-msvc\n            arch: x64 # not relevant here\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Install NASM\"\n        # NASM is required for x86/x86-64 Windows targets by aws-lc-sys.\n        # On aarch64-pc-windows-msvc, it uses clang-cl instead.\n        # See: https://aws.github.io/aws-lc-rs/requirements/windows.html#build-requirements\n        if: contains(matrix.platform.target, 'x86') || contains(matrix.platform.target, 'i686')\n        run: |\n          winget install NASM.NASM --accept-source-agreements --accept-package-agreements\n          echo \"C:\\Program Files\\NASM\" | Out-File -FilePath $env:GITHUB_PATH -Append\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.platform.target }}\n          args: --profile dist --locked --out dist --features self-update\n          sccache: 'true'\n        env:\n          # Disable prebuilt NASM objects so we always compile assembly from source.\n          AWS_LC_SYS_PREBUILT_NASM: \"0\"\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-windows-${{ matrix.platform.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          ARCHIVE_FILE=prek-${{ matrix.platform.target }}.zip\n          7z a $ARCHIVE_FILE ./target/${{ matrix.platform.target }}/dist/prek.exe\n          sha256sum $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.platform.target }}\n          path: |\n            *.zip\n            *.sha256\n\n  macos:\n    runs-on: ${{ matrix.platform.runner }}\n    strategy:\n      matrix:\n        platform:\n          - runner: macos-15\n            target: x86_64-apple-darwin\n          - runner: macos-15\n            target: aarch64-apple-darwin\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.platform.target }}\n          args: --profile dist --locked --out dist --features self-update\n          sccache: 'true'\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-macos-${{ matrix.platform.target }}\n          path: dist\n      - name: \"Archive binary\"\n        run: |\n          TARGET=${{ matrix.platform.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.platform.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n\n\n  linux:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        include:\n          - { target: \"i686-unknown-linux-gnu\", cc: \"gcc -m32\" }\n          - { target: \"x86_64-unknown-linux-gnu\", cc: \"gcc\" }\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.target }}\n          # Generally, we try to build in a target docker container. In this case however, a\n          # 32-bit compiler runs out of memory (4GB memory limit for 32-bit), so we cross compile\n          # from 64-bit version of the container, breaking the pattern from other builds.\n          container: quay.io/pypa/manylinux2014\n          args: --profile dist --locked --out dist --features self-update\n          # See: https://github.com/sfackler/rust-openssl/issues/2036#issuecomment-1724324145\n          before-script-linux: |\n            # Install the 32-bit cross target on 64-bit (noop if we're already on 64-bit)\n            rustup target add ${{ matrix.target }}\n            # If we're running on rhel centos, install needed packages.\n            if command -v yum &> /dev/null; then\n                yum update -y && yum install -y perl-core openssl openssl-devel pkgconfig libatomic\n\n                # If we're running on i686 we need to symlink libatomic\n                # in order to build openssl with -latomic flag.\n                if [[ ! -d \"/usr/lib64\" ]]; then\n                    ln -s /usr/lib/libatomic.so.1 /usr/lib/libatomic.so\n                else\n                    # Support cross-compiling from 64-bit to 32-bit\n                    yum install -y glibc-devel.i686 libstdc++-devel.i686\n                fi\n            else\n                # If we're running on debian-based system.\n                apt update -y && apt-get install -y libssl-dev openssl pkg-config\n            fi\n          sccache: 'true'\n          manylinux: auto\n        env:\n          CC: ${{ matrix.cc }}\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-linux-${{ matrix.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          TARGET=${{ matrix.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n\n  linux-arm:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    strategy:\n      matrix:\n        platform:\n          - target: aarch64-unknown-linux-gnu\n            arch: aarch64\n            # see https://github.com/astral-sh/ruff/issues/3791\n            # and https://github.com/gnzlbg/jemallocator/issues/170#issuecomment-1503228963\n            maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16\n          - target: armv7-unknown-linux-gnueabihf\n            arch: armv7\n          - target: arm-unknown-linux-musleabihf\n            arch: arm\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.platform.target }}\n          # On `aarch64`, use `manylinux: 2_28`; otherwise, use `manylinux: auto`.\n          manylinux: ${{ matrix.platform.arch == 'aarch64' && '2_28' || 'auto' }}\n          docker-options: ${{ matrix.platform.maturin_docker_options }}\n          args: --profile dist --locked --out dist --features self-update\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-linux-${{ matrix.platform.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          TARGET=${{ matrix.platform.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.platform.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n\n  linux-s390x:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    strategy:\n      matrix:\n        platform:\n          - target: s390x-unknown-linux-gnu\n            arch: s390x\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.platform.target }}\n          manylinux: auto\n          args: --profile dist --locked --out dist --features self-update\n          rust-toolchain: ${{ matrix.platform.toolchain || null }}\n        env:\n          CFLAGS_s390x_unknown_linux_gnu: -march=z10\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-linux-${{ matrix.platform.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          TARGET=${{ matrix.platform.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.platform.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n\n  linux-riscv64:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    strategy:\n      matrix:\n        platform:\n          - target: riscv64gc-unknown-linux-gnu\n            arch: riscv64\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.platform.target }}\n          manylinux: auto\n          args: --profile dist --locked --out dist --features self-update\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-linux-${{ matrix.platform.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          TARGET=${{ matrix.platform.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.platform.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n\n\n  musllinux:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        target:\n          - x86_64-unknown-linux-musl\n          - i686-unknown-linux-musl\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.target }}\n          manylinux: musllinux_1_1\n          args: --profile dist --locked --out dist --features self-update\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-linux-${{ matrix.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          TARGET=${{ matrix.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n\n  musllinux-cross:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        platform:\n          - target: aarch64-unknown-linux-musl\n            arch: aarch64\n            maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16\n          - target: armv7-unknown-linux-musleabihf\n            arch: armv7\n      fail-fast: false\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Build wheels\"\n        uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1\n        with:\n          maturin-version: ${{ env.MATURIN_VERSION }}\n          target: ${{ matrix.platform.target }}\n          manylinux: musllinux_1_1\n          args: --profile dist --locked --out dist --features self-update ${{ matrix.platform.arch == 'aarch64' && '--compatibility 2_17' || ''}}\n          docker-options: ${{ matrix.platform.maturin_docker_options }}\n          rust-toolchain: ${{ matrix.platform.toolchain || null }}\n      - name: \"Upload wheels\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: wheels-linux-${{ matrix.platform.target }}\n          path: dist\n      - name: \"Archive binary\"\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          TARGET=${{ matrix.platform.target }}\n          ARCHIVE_NAME=prek-$TARGET\n          ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz\n\n          mkdir -p $ARCHIVE_NAME\n          cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek\n          tar czvf $ARCHIVE_FILE $ARCHIVE_NAME\n          shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: artifacts-${{ matrix.platform.target }}\n          path: |\n            *.tar.gz\n            *.sha256\n"
  },
  {
    "path": ".github/workflows/build-docker.yml",
    "content": "# Build and publish a Docker image.\n#\n# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local\n# artifacts job within `cargo-dist`.\n#\n# Adapted from https://github.com/astral-sh/ty/blob/main/.github/workflows/build-docker.yml\nname: \"Build Docker image\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n  pull_request:\n    paths:\n      - .github/workflows/build-docker.yml\n\nenv:\n  PREK_BASE_IMG: ghcr.io/${{ github.repository_owner }}/prek\n\npermissions:\n  contents: read\n  packages: write # zizmor: ignore[excessive-permissions]\n\njobs:\n  docker-build:\n    name: Build Docker image ghcr.io/j178/prek for ${{ matrix.platform }}\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    strategy:\n      fail-fast: false\n      matrix:\n        platform:\n          - linux/amd64\n          - linux/arm64\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n\n      - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Check tag consistency\n        if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}\n        env:\n          TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }}\n        run: |\n          version=$(grep -m 1 \"^version = \" pyproject.toml | sed -e 's/version = \"\\(.*\\)\"/\\1/g')\n          if [ \"${TAG}\" != \"v${version}\" ]; then\n            echo \"The input tag does not match the version from pyproject.toml:\" >&2\n            echo \"${TAG}\" >&2\n            echo \"${version}\" >&2\n            exit 1\n          else\n            echo \"Releasing ${version}\"\n          fi\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        env:\n          DOCKER_METADATA_ANNOTATIONS_LEVELS: index\n        with:\n          images: ${{ env.PREK_BASE_IMG }}\n          # Defining this makes sure the org.opencontainers.image.version OCI label becomes the actual release version and not the branch name\n          tags: |\n            type=raw,value=dry-run,enable=${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }}\n            type=semver,pattern={{ raw }},value=${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }},enable=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}\n\n      - name: Normalize Platform Pair (replace / with -)\n        run: |\n          platform=${{ matrix.platform }}\n          echo \"PLATFORM_TUPLE=${platform//\\//-}\" >> \"$GITHUB_ENV\"\n\n      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/\n      - name: Build and push by digest\n        id: build\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0\n        with:\n          context: .\n          platforms: ${{ matrix.platform }}\n          cache-from: type=gha,scope=prek-${{ env.PLATFORM_TUPLE }}\n          cache-to: type=gha,mode=min,scope=prek-${{ env.PLATFORM_TUPLE }}\n          labels: ${{ steps.meta.outputs.labels }}\n          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 }}\n\n      - name: Export digests\n        env:\n          digest: ${{ steps.build.outputs.digest }}\n        run: |\n          mkdir -p /tmp/digests\n          touch \"/tmp/digests/${digest#sha256:}\"\n\n      - name: Upload digests\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: digests-${{ env.PLATFORM_TUPLE }}\n          path: /tmp/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  docker-publish:\n    name: Publish Docker image (ghcr.io/j178/prek)\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    needs:\n      - docker-build\n    permissions:\n      contents: read\n      packages: write\n      id-token: write\n      attestations: write\n    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0\n        with:\n          path: /tmp/digests\n          pattern: digests-*\n          merge-multiple: true\n\n      - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        env:\n          DOCKER_METADATA_ANNOTATIONS_LEVELS: index\n        with:\n          images: ${{ env.PREK_BASE_IMG }}\n          # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version\n          tags: |\n            type=raw,value=${{ fromJson(inputs.plan).announcement_tag }}\n            type=semver,pattern=v{{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }}\n\n      - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/\n      - name: Create manifest list and push\n        working-directory: /tmp/digests\n        # The jq command expands the docker/metadata json \"tags\" array entry to `-t tag1 -t tag2 ...` for each tag in the array\n        # The printf will expand the base image with the `<PREK_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory\n        # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <PREK_BASE_IMG>@sha256:<sha256_1> <PREK_BASE_IMG>@sha256:<sha256_2> ...`\n        run: |\n          # shellcheck disable=SC2046\n          readarray -t lines <<< \"$DOCKER_METADATA_OUTPUT_ANNOTATIONS\"; annotations=(); for line in \"${lines[@]}\"; do annotations+=(--annotation \"$line\"); done\n\n          docker buildx imagetools create \\\n            \"${annotations[@]}\" \\\n            $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf \"${PREK_BASE_IMG}@sha256:%s \" *)\n\n      - name: Export manifest digest\n        id: manifest-digest\n        env:\n          IMAGE: ${{ env.PREK_BASE_IMG }}\n          VERSION: ${{ steps.meta.outputs.version }}\n        run: |\n          digest=\"$(\n            docker buildx imagetools inspect \\\n              \"${IMAGE}:${VERSION}\" \\\n              --format '{{json .Manifest}}' \\\n            | jq -r '.digest'\n          )\"\n          echo \"digest=${digest}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Generate artifact attestation\n        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0\n        with:\n          subject-name: ${{ env.PREK_BASE_IMG }}\n          subject-digest: ${{ steps.manifest-digest.outputs.digest }}\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [master]\n  pull_request:\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }}\n  cancel-in-progress: true\n\npermissions: {}\n\nenv:\n  # UV_VERSION should not greater than MAX_UV_VERSION in `languages/python/uv`.\n  # Otherwise, tests jobs will install their own uv, and it will encounter concurrency issue.\n  UV_VERSION: \"0.10.9\"\n  NODE_VERSION: \"20\"\n  BUN_VERSION: \"1.3\"\n  GO_VERSION: \"1.24\"\n  PYTHON_VERSION: \"3.12\"\n  RUBY_VERSION: \"3.4\"\n  LUA_VERSION: \"5.4\"\n  LUAROCKS_VERSION: \"3.12.2\"\n  GHC_VERSION: \"9.14.1\" # Preinstalled in ubuntu-24.04 runner image\n  CABAL_VERSION: \"3.16.1.0\"\n  JULIA_VERSION: \"1.12.4\"\n  DENO_VERSION: \"2\"\n\n  # Cargo env vars\n  CARGO_INCREMENTAL: 0\n  CARGO_NET_RETRY: 10\n  CARGO_TERM_COLOR: always\n  RUSTUP_MAX_RETRIES: 10\n\njobs:\n  plan:\n    runs-on: ubuntu-latest\n    outputs:\n      test-code: ${{ !contains(github.event.pull_request.labels.*.name, 'test:skip') && (steps.changed.outputs.any_code_changed == 'true' || github.ref == 'refs/heads/master') }}\n      save-rust-cache: ${{ github.ref == 'refs/heads/master' || steps.changed.outputs.cache_changed == 'true' }}\n      # Run benchmarks if Rust code or benchmark-related files changed\n      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') }}\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: \"Determine changed files\"\n        id: changed\n        shell: bash\n        run: |\n          CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha || 'origin/master' }}...HEAD)\n\n          ANY_CODE_CHANGED=false\n          CACHE_CHANGED=false\n          RUST_CODE_CHANGED=false\n          BENCH_RELATED_CHANGED=false\n\n          while IFS= read -r file; do\n            # Check if cache-relevant files changed (Cargo files, toolchain, workflows)\n            if [[ \"${file}\" == \"Cargo.lock\" || \"${file}\" == \"Cargo.toml\" || \"${file}\" == \"rust-toolchain.toml\" || \"${file}\" == \".cargo/config.toml\" || \"${file}\" =~ ^crates/.*/Cargo\\.toml$ || \"${file}\" =~ ^\\.github/workflows/.*\\.yml$ ]]; then\n              echo \"Detected cache-relevant change: ${file}\"\n              CACHE_CHANGED=true\n            fi\n\n            # Check if Rust code changed (for benchmarks)\n            if [[ \"${file}\" =~ \\.rs$ ]] || [[ \"${file}\" =~ Cargo\\.toml$ ]] || [[ \"${file}\" == \"Cargo.lock\" ]] || [[ \"${file}\" == \"rust-toolchain.toml\" ]] || [[ \"${file}\" =~ ^\\.cargo/ ]]; then\n              echo \"Detected Rust code change: ${file}\"\n              RUST_CODE_CHANGED=true\n            fi\n\n            if [[ \"${file}\" == \".github/workflows/performance.yml\" ]] || [[ \"${file}\" =~ ^scripts/hyperfine-.*\\.sh$ ]]; then\n              echo \"Detected benchmark-related change: ${file}\"\n              BENCH_RELATED_CHANGED=true\n            fi\n\n            if [[ \"${file}\" =~ ^docs/ ]]; then\n              echo \"Skipping ${file} (matches docs/ pattern)\"\n              continue\n            fi\n            if [[ \"${file}\" =~ ^mkdocs.*\\.yml$ ]]; then\n              echo \"Skipping ${file} (matches mkdocs*.yml pattern)\"\n              continue\n            fi\n            if [[ \"${file}\" =~ \\.md$ ]]; then\n              echo \"Skipping ${file} (matches *.md pattern)\"\n              continue\n            fi\n\n            echo \"Detected code change in: ${file}\"\n            ANY_CODE_CHANGED=true\n\n          done <<< \"${CHANGED_FILES}\"\n          echo \"any_code_changed=${ANY_CODE_CHANGED}\" >> \"${GITHUB_OUTPUT}\"\n          echo \"cache_changed=${CACHE_CHANGED}\" >> \"${GITHUB_OUTPUT}\"\n          echo \"rust_code_changed=${RUST_CODE_CHANGED}\" >> \"${GITHUB_OUTPUT}\"\n          echo \"bench_related_changed=${BENCH_RELATED_CHANGED}\" >> \"${GITHUB_OUTPUT}\"\n\n  lint:\n    name: \"lint\"\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Install Rustfmt\"\n        run: rustup component add rustfmt\n      - name: \"rustfmt\"\n        run: cargo fmt --all --check\n      - name: Run prek checks\n        uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0\n        env:\n          PREK_SKIP: cargo-fmt,cargo-clippy\n\n  check-release:\n    name: \"check release\"\n    needs: plan\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Install dist\n        shell: bash\n        run: \"curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh\"\n\n      - name: Run dist plan\n        run: |\n          dist plan --output-format=json > plan-dist-manifest.json\n          echo \"dist plan completed successfully\"\n          cat plan-dist-manifest.json\n\n  cargo-clippy-linux:\n    name: \"cargo clippy | ubuntu\"\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Install Rust toolchain\"\n        run: rustup component add clippy\n      - name: \"Clippy\"\n        run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings\n\n  cargo-clippy-windows:\n    name: \"cargo clippy | windows\"\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 15\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Create Dev Drive\n        run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1\n\n      # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...\n      - name: Copy Git Repo to Dev Drive\n        run: |\n          Copy-Item -Path \"${{ github.workspace }}\" -Destination \"$Env:PREK_WORKSPACE\" -Recurse\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          workspaces: ${{ env.PREK_WORKSPACE }}\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Install Rust toolchain\"\n        run: rustup component add clippy\n\n      - name: \"Clippy\"\n        working-directory: ${{ env.PREK_WORKSPACE }}\n        run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings\n\n  cargo-shear:\n    name: \"cargo shear\"\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Install cargo shear\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-shear\n      - run: cargo shear\n\n  cargo-test-without-uv:\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 5\n    runs-on: ubuntu-latest\n    name: \"cargo test | without uv\"\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Install Rust toolchain\"\n        run: rustup component add llvm-tools-preview\n\n      - name: \"Install cargo nextest\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-nextest\n\n      - name: \"Install cargo-llvm-cov\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-llvm-cov\n\n      - name: \"Cargo test without uv\"\n        run: |\n          echo \"::group::Test install uv with auto select\"\n          cargo llvm-cov nextest \\\n            --profile ci-core \\\n            --cargo-profile fast-build \\\n            --no-report \\\n            -E 'binary_id(prek::run) and test(run_basic)'\n          echo \"::endgroup::\"\n\n          for source in github pypi aliyun pip invalid; do\n            echo \"::group::Test install uv from $source\"\n            export PREK_UV_SOURCE=$source\n            cargo llvm-cov nextest \\\n              --profile ci-core \\\n              --cargo-profile fast-build \\\n              --no-report \\\n              -E 'binary_id(prek::run) and test(run_basic)'\n            echo \"::endgroup::\"\n          done\n\n          cargo llvm-cov report --profile fast-build --lcov --output-path lcov.info\n\n      - name: \"Upload coverage reports to Codecov\"\n        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          files: lcov.info\n\n  cargo-test:\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: ${{ matrix.os }}\n    name: \"cargo test | ${{ matrix.os }}\"\n    strategy:\n      fail-fast: false\n      matrix:\n        os:\n          - ubuntu-latest\n          - macos-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n        if: ${{ matrix.os == 'ubuntu-latest' }}\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Install Rust toolchain\"\n        run: rustup component add llvm-tools-preview\n\n      - name: \"Install cargo nextest\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-nextest\n\n      - name: \"Install cargo-llvm-cov\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-llvm-cov\n\n      - name: \"Install uv\"\n        uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0\n        with:\n          version: ${{ env.UV_VERSION }}\n\n      - name: \"Install Python\"\n        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: \"Cargo test\"\n        run: |\n          cargo llvm-cov nextest \\\n            --lcov \\\n            --output-path lcov.info \\\n            --workspace \\\n            --cargo-profile fast-build \\\n            --profile ci-core \\\n            --features schemars\n\n      - name: \"Upload coverage reports to Codecov\"\n        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          files: lcov.info\n\n  cargo-test-windows:\n    name: \"cargo test | windows\"\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    runs-on: windows-latest\n    timeout-minutes: 15\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Create Dev Drive\n        run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1\n\n      # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...\n      - name: Copy Git Repo to Dev Drive\n        run: |\n          Copy-Item -Path \"${{ github.workspace }}\" -Destination \"$Env:PREK_WORKSPACE\" -Recurse\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          workspaces: ${{ env.PREK_WORKSPACE }}\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Install Rust toolchain\"\n        run: rustup component add llvm-tools-preview\n\n      - name: \"Install cargo nextest\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-nextest\n\n      - name: \"Install cargo-llvm-cov\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-llvm-cov\n\n      - name: \"Install uv\"\n        uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0\n        with:\n          version: ${{ env.UV_VERSION }}\n          cache-local-path: ${{ env.DEV_DRIVE }}/uv-cache\n\n      - name: \"Install Python\"\n        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # windows only\n\n      - name: \"Cargo test\"\n        working-directory: ${{ env.PREK_WORKSPACE }}\n        shell: pwsh\n        run: |\n          # Remove msys64 from PATH for Rust compilation\n          $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -notmatch '\\\\msys64\\\\' }) -join ';'\n\n          cargo llvm-cov nextest `\n            --lcov `\n            --output-path lcov.info `\n            --workspace `\n            --cargo-profile fast-build `\n            --features schemars `\n            --profile ci-core\n\n          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n\n      - name: \"Upload coverage reports to Codecov\"\n        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          files: ${{ env.PREK_WORKSPACE }}/lcov.info\n\n  language-tests:\n    name: \"language tests | ${{ matrix.language }} | ${{ matrix.os }}\"\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 15\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os:\n          - ubuntu-latest\n          - macos-latest\n          - windows-latest\n        language:\n          - bun\n          - deno\n          - docker\n          - golang\n          - haskell\n          - julia\n          - lua\n          - node\n          - python\n          - ruby\n          - rust\n          - swift\n        exclude:\n          # Docker is only available on ubuntu-latest\n          - os: macos-latest\n            language: docker\n          - os: windows-latest\n            language: docker\n          # Swift is preinstalled on ubuntu and macOS; Windows requires setup which is slow\n          - os: windows-latest\n            language: swift\n          # GHC is not preinstalled on macOS, and installing it takes a long time\n          - os: macos-latest\n            language: haskell\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n        if: ${{ matrix.os == 'ubuntu-latest' }}\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Install Rust toolchain\"\n        run: rustup component add llvm-tools-preview\n\n      - name: \"Install cargo nextest\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-nextest\n\n      - name: \"Install cargo-llvm-cov\"\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-llvm-cov\n\n      - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # windows only\n        if: ${{ matrix.os == 'windows-latest' }}\n\n      - name: \"Install uv\"\n        if: ${{ matrix.language == 'python' }}\n        uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0\n        with:\n          version: ${{ env.UV_VERSION }}\n\n      - name: \"Install Python\"\n        if: ${{ matrix.language == 'python' }}\n        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: \"Install Node.js\"\n        if: ${{ matrix.language == 'node' }}\n        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: npm\n          # Dummy dependency path to satisfy required input while enabling caching\n          cache-dependency-path: LICENSE\n\n      - name: \"Install Go\"\n        if: ${{ matrix.language == 'golang' }}\n        uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: ${{ env.GO_VERSION }}\n          # Dummy dependency path to satisfy required input while enabling caching\n          cache-dependency-path: LICENSE\n\n      - name: \"Install Lua\"\n        if: ${{ matrix.language == 'lua' }}\n        uses: leafo/gh-actions-lua@8c9e175e7a3d77e21f809eefbee34a19b858641b # v12\n        with:\n          luaVersion: ${{ env.LUA_VERSION }}\n\n      - name: \"Install LuaRocks\"\n        if: ${{ matrix.language == 'lua' }}\n        uses: luarocks/gh-actions-luarocks@7c85eeff60655651b444126f2a78be784e836a0a #v6\n        with:\n          luaRocksVersion: ${{ env.LUAROCKS_VERSION }}\n\n      - name: \"Install Ruby\"\n        if: ${{ matrix.language == 'ruby' }}\n        uses: ruby/setup-ruby@6ca151fd1bfcfd6fe0c4eb6837eb0584d0134a0c # v1.290.0\n        with:\n          ruby-version: ${{ env.RUBY_VERSION }}\n\n      - name: \"Install Bun\"\n        if: ${{ matrix.language == 'bun' }}\n        uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2.1.3\n        with:\n          bun-version: ${{ env.BUN_VERSION }}\n\n      - name: \"Install Deno\"\n        if: ${{ matrix.language == 'deno' }}\n        uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3\n        with:\n          deno-version: ${{ env.DENO_VERSION }}\n\n      - name: \"Install GHC and Cabal\"\n        if: ${{ matrix.language == 'haskell' }}\n        uses: haskell-actions/setup@f9150cb1d140e9a9271700670baa38991e6fa25c # v2.10.3\n        with:\n          ghc-version: ${{ env.GHC_VERSION }}\n          cabal-version: ${{ env.CABAL_VERSION }}\n\n      - name: \"Install Julia\"\n        if: ${{ matrix.language == 'julia' }}\n        uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0\n        with:\n          version: ${{ env.JULIA_VERSION }}\n\n      - name: \"Run language tests\"\n        if: ${{ matrix.os != 'windows-latest' }}\n        env:\n          # Ruby auto_download test queries the GitHub Releases API; without a\n          # token, shared runners quickly hit the 60 req/hour rate limit.\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          cargo llvm-cov nextest \\\n            --lcov \\\n            --output-path lcov.info \\\n            --workspace \\\n            --cargo-profile fast-build \\\n            --features schemars \\\n            --profile lang-${{ matrix.language }}\n\n      - name: \"Run language tests (windows)\"\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: pwsh\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          # Remove msys64 from PATH for Rust compilation\n          $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -notmatch '\\\\msys64\\\\' }) -join ';'\n\n          cargo llvm-cov nextest `\n            --lcov `\n            --output-path lcov.info `\n            --workspace `\n            --cargo-profile fast-build `\n            --features schemars `\n            --profile lang-${{ matrix.language }}\n\n          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n\n      - name: \"Upload coverage reports to Codecov\"\n        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          files: lcov.info\n\n  performance:\n    needs: plan\n    if: ${{ needs.plan.outputs.run-bench == 'true' }}\n    uses: ./.github/workflows/performance.yml\n    with:\n      save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }}\n\n  ecosystem-cpython:\n    name: \"ecosystem | cpython\"\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Checkout python/cpython\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          repository: python/cpython\n          ref: f3759d21dd5e6510361d7409a1df53f35ebd9a58\n          path: cpython\n          fetch-depth: 1\n          persist-credentials: false\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: Run prek on cpython\n        working-directory: cpython\n        run: cargo run -p prek -- --all-files\n\n  build-binary-msrv:\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    name: \"build binary | msrv\"\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - name: \"Read MSRV from Cargo.toml\"\n        id: msrv\n        run: |\n          MSRV=$(grep -m1 'rust-version' Cargo.toml | sed 's/.*\"\\([^\"]*\\)\".*/\\1/')\n          echo \"value=$MSRV\" >> \"$GITHUB_OUTPUT\"\n      - name: \"Install Rust toolchain\"\n        run: rustup default ${MSRV}\n        env:\n          MSRV: ${{ steps.msrv.outputs.value }}\n      - name: \"Install mold\"\n        uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n      - run: cargo +${MSRV} build --profile no-debug --bin prek\n        env:\n          MSRV: ${{ steps.msrv.outputs.value }}\n      - run: ./target/no-debug/prek --version\n\n  build-binary-linux-libc:\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    name: \"build binary | linux libc\"\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Build\"\n        run: cargo build --profile no-debug --bin prek\n\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: prek-linux-libc-${{ github.sha }}\n          path: |\n            ./target/no-debug/prek\n          retention-days: 1\n\n  build-binary-macos-aarch64:\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: macos-latest\n    name: \"build binary | macos aarch64\"\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Build\"\n        run: cargo build --profile no-debug --bin prek\n\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: prek-macos-aarch64-${{ github.sha }}\n          path: |\n            ./target/no-debug/prek\n          retention-days: 1\n\n  build-binary-windows-x86_64:\n    needs: plan\n    if: ${{ needs.plan.outputs.test-code == 'true' }}\n    timeout-minutes: 10\n    runs-on: windows-latest\n    name: \"build binary | windows x86_64\"\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Setup Dev Drive\n        run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1\n\n      # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...\n      - name: Copy Git Repo to Dev Drive\n        run: |\n          Copy-Item -Path \"${{ github.workspace }}\" -Destination \"$Env:PREK_WORKSPACE\" -Recurse\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          workspaces: ${{ env.PREK_WORKSPACE }}\n          save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }}\n\n      - name: \"Build\"\n        working-directory: ${{ env.PREK_WORKSPACE }}\n        run: cargo build --profile no-debug --bin prek\n\n      - name: \"Upload binary\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: prek-windows-x86_64-${{ github.sha }}\n          path: |\n            ${{ env.PREK_WORKSPACE }}/target/no-debug/prek.exe\n          retention-days: 1\n"
  },
  {
    "path": ".github/workflows/performance.yml",
    "content": "name: Performance\n\non:\n  workflow_call:\n    inputs:\n      save-rust-cache:\n        required: false\n        type: string\n        default: \"true\"\n\npermissions: {}\n\nenv:\n  # Cargo env vars\n  CARGO_INCREMENTAL: 0\n  CARGO_NET_RETRY: 10\n  CARGO_TERM_COLOR: always\n  RUSTUP_MAX_RETRIES: 10\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  bloat-check:\n    runs-on: ubuntu-latest\n    name: \"bloat check\"\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n          fetch-depth: 0\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ inputs.save-rust-cache == 'true' }}\n      - uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: cargo-bloat\n\n      - name: Build head branch\n        id: bloat_head\n        run: |\n          # Build head branch\n          cargo bloat --release | tee bloat_head.txt >&1\n\n      - name: Checkout base\n        run: |\n          git checkout ${{ github.event.pull_request.base.sha }}\n\n      - name: Build base branch\n        id: bloat_base\n        run: |\n          # Build base branch\n          cargo bloat --release | tee bloat_base.txt >&1\n\n      - name: Compare bloat results\n        shell: python\n        run: |\n          import re\n          from pathlib import Path\n\n          def parse_size(text):\n              match = re.search(r'\\.text section size.*?([\\d.]+)\\s*([KMGT]i?B)', text)\n              if not match:\n                  raise ValueError(\"Could not find .text section size\")\n              value, unit = float(match.group(1)), match.group(2)\n              multipliers = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4}\n              size = value * multipliers.get(unit, 1)\n              return size, f\"{value} {unit}\"\n\n          head_text = Path('bloat_head.txt').read_text()\n          base_text = Path('bloat_base.txt').read_text()\n\n          head_bytes, head_size = parse_size(head_text)\n          base_bytes, base_size = parse_size(base_text)\n\n          pct_change = ((head_bytes - base_bytes) / base_bytes) * 100\n          pct_display = f\"{pct_change:+.2f}%\"\n          comparison = f\"\"\"\\\n          ### 📦 Cargo Bloat Comparison\n          **Binary size change:** {pct_display} ({base_size} → {head_size})\n\n          <details>\n            <summary>Expand for cargo-bloat output</summary>\n\n          #### Head Branch Results\n          ```\n          {head_text}\n          ```\n\n          #### Base Branch Results\n          ```\n          {base_text}\n          ```\n          </details>\n          \"\"\"\n\n          Path(\"bloat-comparison.txt\").write_text(comparison)\n\n      - name: Upload bloat results\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          # NOTE: https://github.com/j178/prek-ci-bot uses this artifact name to post comments on PRs.\n          # Make sure to update the bot if you rename the artifact.\n          name: bloat-check-results\n          path: bloat-comparison.txt\n\n  hyperfine-benchmark:\n    runs-on: ubuntu-latest\n    name: \"hyperfine benchmark\"\n    timeout-minutes: 10\n    env:\n      HYPERFINE_BENCHMARK_WORKSPACE: /tmp/prek-bench\n      HYPERFINE_BIN_DIR: ${{ github.workspace }}/.hyperfine-bin\n      HYPERFINE_RESULTS_FILE: ${{ github.workspace }}/hyperfine-benchmark.md\n      HYPERFINE_HEAD_BINARY: prek-head\n      HYPERFINE_BASE_BINARY: prek-base\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: Add hyperfine bin dir to PATH\n        run: |\n          mkdir -p \"$HYPERFINE_BIN_DIR\"\n          echo \"$HYPERFINE_BIN_DIR\" >> \"$GITHUB_PATH\"\n\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ inputs.save-rust-cache == 'true' }}\n\n      - id: base-binary-cache\n        uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3\n        with:\n          path: ${{ env.HYPERFINE_BIN_DIR }}/${{ env.HYPERFINE_BASE_BINARY }}\n          key: prek-hyperfine-base-${{ github.event.pull_request.base.sha }}-${{ hashFiles('Cargo.lock') }}-${{ runner.os }}-${{ runner.arch }}\n\n      - name: Build base version\n        if: ${{ steps.base-binary-cache.outputs.cache-hit != 'true' }}\n        env:\n          BASE_VERSION: ${{ github.event.pull_request.base.sha }}\n        run: |\n          git checkout ${{ github.event.pull_request.base.sha }}\n          cargo build --profile profiling && mv target/profiling/prek \"$HYPERFINE_BIN_DIR/$HYPERFINE_BASE_BINARY\"\n          git checkout ${{ github.sha }}\n\n      - name: Build head version\n        run: |\n          cargo build --profile profiling && mv target/profiling/prek \"$HYPERFINE_BIN_DIR/$HYPERFINE_HEAD_BINARY\"\n\n      - name: Install hyperfine\n        uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26\n        with:\n          tool: hyperfine\n\n      - name: Setup test environment for builtin hooks\n        run: scripts/hyperfine-setup-test-env.sh\n\n      - name: Run benchmarks\n        run: scripts/hyperfine-run-benchmarks.sh\n\n      - name: Upload benchmark results\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          # NOTE: https://github.com/j178/prek-ci-bot uses this artifact name to post comments on PRs.\n          # Make sure to update the bot if you rename the artifact.\n          name: hyperfine-benchmark-results\n          path: ${{ env.HYPERFINE_RESULTS_FILE }}\n"
  },
  {
    "path": ".github/workflows/publish-crates.yml",
    "content": "name: \"Publish to crates.io\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n\njobs:\n  publish:\n    name: Upload to crates.io\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    permissions:\n      # For crates.io's trusted publishing.\n      id-token: write\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1\n      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2\n        with:\n          save-if: ${{ github.ref == 'refs/heads/master' }}\n      - name: \"Install Rust toolchain\"\n        run: rustup show\n      - uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec # v1.0.3\n        id: auth\n      - name: \"Publish to crates.io\"\n        run: cargo publish --workspace\n        env:\n          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}\n"
  },
  {
    "path": ".github/workflows/publish-docs.yml",
    "content": "name: Deploy Documentation\n\non:\n  workflow_dispatch:\n    inputs:\n      ref:\n        description: \"The commit SHA, tag, or branch to publish. Uses the default branch if not specified.\"\n        default: \"\"\n        type: string\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n\nconcurrency:\n  group: \"pages\"\n  cancel-in-progress: false\n\npermissions: {}\n\nenv:\n  UV_VERSION: \"0.10.9\"\n  # Match version used to compile `docs/requirements.txt`\n  PYTHON_VERSION: \"3.14\"\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n\n      - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0\n        with:\n          version: ${{ env.UV_VERSION }}\n          python-version: ${{ env.PYTHON_VERSION }}\n          cache-dependency-glob: |\n            **/docs/requirements.txt\n\n      - name: Build documentation\n        run: |\n          uvx --with-requirements docs/requirements.txt zensical build\n          uvx --with-requirements docs/requirements.txt llmstxt-standalone build\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4\n        with:\n          path: ./site\n\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    permissions:\n      contents: read\n      pages: write\n      id-token: write\n    runs-on: ubuntu-latest\n    needs: build\n    if: github.ref == 'refs/heads/master'\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5\n"
  },
  {
    "path": ".github/workflows/publish-homebrew.yml",
    "content": "name: \"Publish Homebrew formula\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n    secrets:\n      HOMEBREW_TAP_TOKEN:\n        required: true\n\njobs:\n  publish:\n    name: Publish Homebrew formula\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      PLAN: ${{ inputs.plan }}\n      GITHUB_USER: \"axo bot\"\n      GITHUB_EMAIL: \"admin+bot@axo.dev\"\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: true\n          repository: \"j178/homebrew-tap\"\n          token: ${{ secrets.HOMEBREW_TAP_TOKEN }}\n      - name: Fetch homebrew formulae\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0\n        with:\n          pattern: artifacts-*\n          path: Formula/\n          merge-multiple: true\n      - name: Commit formula files\n        run: |\n          git config --global user.name \"${GITHUB_USER}\"\n          git config --global user.email \"${GITHUB_EMAIL}\"\n\n          for release in $(echo \"$PLAN\" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(\".rb\")] | any)'); do\n            filename=$(echo \"$release\" | jq '.artifacts[] | select(endswith(\".rb\"))' --raw-output)\n            name=$(echo \"$filename\" | sed \"s/\\.rb$//\")\n            version=$(echo \"$release\" | jq .app_version --raw-output)\n\n            export PATH=\"/home/linuxbrew/.linuxbrew/bin:$PATH\"\n            brew update\n            # Avoid reformatting user-provided metadata such as homepage and description.\n            brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix \"Formula/${filename}\" || true\n\n            git add \"Formula/${filename}\"\n            git commit -m \"${name} ${version}\"\n          done\n          git push\n"
  },
  {
    "path": ".github/workflows/publish-npm.yml",
    "content": "name: \"Publish to npmjs registry\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n\njobs:\n  publish:\n    name: Upload to npmjs registry\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    permissions:\n      # For npm's trusted publishing.\n      id-token: write\n      packages: write\n    env:\n      PLAN: ${{ inputs.plan }}\n    steps:\n      - name: Fetch npm packages\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0\n        with:\n          pattern: artifacts-build-global\n          path: npm/\n          merge-multiple: true\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.x\"\n          registry-url: \"https://registry.npmjs.org\"\n      - name: Update npm\n        run: npm install -g npm@latest\n      - run: |\n          for release in $(echo \"$PLAN\" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(\"-npm-package.tar.gz\")] | any)'); do\n            pkg=$(echo \"$release\" | jq '.artifacts[] | select(endswith(\"-npm-package.tar.gz\"))' --raw-output)\n            prerelease=$(echo \"$PLAN\" | jq \".announcement_is_prerelease\")\n            if [ \"$prerelease\" = \"true\" ]; then\n              npm publish --tag beta --provenance --access public \"./npm/${pkg}\"\n            else\n              npm publish --provenance --access public \"./npm/${pkg}\"\n            fi\n          done\n"
  },
  {
    "path": ".github/workflows/publish-prek-action.yml",
    "content": "name: \"Publish prek-action known versions\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n    secrets:\n      PREK_ACTION_TOKEN:\n        required: true\n\npermissions: {}\n\njobs:\n  publish:\n    name: Publish prek-action known versions\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    env:\n      PLAN: ${{ inputs.plan }}\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          repository: j178/prek-action\n          ref: main\n          token: ${{ secrets.PREK_ACTION_TOKEN }}\n          persist-credentials: false\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: 24\n          cache: npm\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Update known versions\n        id: refresh\n        env:\n          GITHUB_TOKEN: ${{ secrets.PREK_ACTION_TOKEN }}\n        run: npm run update-known-versions\n\n      - name: Check whether known version files changed\n        id: changes\n        run: |\n          if git diff --quiet -- version-manifest.json src/known-checksums.ts; then\n            echo \"known_versions_changed=false\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"known_versions_changed=true\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Run tests\n        if: steps.changes.outputs.known_versions_changed == 'true'\n        run: npm test\n\n      - name: Rebuild dist\n        if: steps.changes.outputs.known_versions_changed == 'true'\n        run: npm run bundle\n\n      - name: Create pull request\n        id: cpr\n        if: steps.changes.outputs.known_versions_changed == 'true'\n        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0\n        with:\n          token: ${{ secrets.PREK_ACTION_TOKEN }}\n          add-paths: |\n            version-manifest.json\n            src/known-checksums.ts\n            dist/index.cjs\n            dist/post/index.cjs\n          branch: automation/update-known-versions\n          base: main\n          commit-message: ${{ steps.refresh.outputs.pr_title }}\n          title: ${{ steps.refresh.outputs.pr_title }}\n          body: |\n            Automated update from the prek release workflow.\n\n            Added releases:\n            ${{ steps.refresh.outputs.added_versions_markdown }}\n\n      - name: Merge pull request\n        if: steps.cpr.outputs.pull-request-number != ''\n        env:\n          GH_TOKEN: ${{ secrets.PREK_ACTION_TOKEN }}\n          PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}\n        shell: bash\n        run: |\n          sleep 10\n          gh pr merge --squash --repo j178/prek-action \"$PR_NUMBER\"\n"
  },
  {
    "path": ".github/workflows/publish-pypi.yml",
    "content": "name: \"Publish to PyPI\"\n\non:\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n\njobs:\n  publish:\n    name: Upload to PyPI\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    permissions:\n      # For PyPI's trusted publishing.\n      id-token: write\n    steps:\n      - name: \"Install uv\"\n        uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0\n      - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0\n        with:\n          pattern: wheels-*\n          path: wheels\n          merge-multiple: true\n      - name: Publish to PyPi\n        run: uv publish -v wheels/*\n"
  },
  {
    "path": ".github/workflows/publish-winget.yml",
    "content": "name: \"Publish to winget\"\n\non:\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: \"The release tag to publish (e.g., vX.Y.Z).\"\n        required: true\n        type: string\n  workflow_call:\n    inputs:\n      plan:\n        required: true\n        type: string\n    secrets:\n      WINGET_TOKEN:\n        required: true\n\npermissions: {}\n\njobs:\n  winget:\n    name: Publish to winget\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n    if: >-\n      ${{\n        inputs.plan == ''\n        || !fromJson(inputs.plan).announcement_is_prerelease\n      }}\n    steps:\n      - name: Determine release tag\n        id: tag\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          PLAN_TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || '' }}\n        run: |\n          if [ -n \"$INPUT_TAG\" ]; then\n            echo \"value=$INPUT_TAG\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"value=$PLAN_TAG\" >> \"$GITHUB_OUTPUT\"\n          fi\n        shell: bash\n\n      - name: Publish to winget\n        uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2\n        with:\n          identifier: j178.Prek\n          release-tag: ${{ steps.tag.outputs.value }}\n          installers-regex: 'prek-.*windows.*\\.zip$'\n          token: ${{ secrets.WINGET_TOKEN }}\n          fork-user: prek-bot\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist\n#\n# Copyright 2022-2024, axodotdev\n# SPDX-License-Identifier: MIT or Apache-2.0\n#\n# CI that:\n#\n# * checks for a Git Tag that looks like a release\n# * builds artifacts with dist (archives, installers, hashes)\n# * uploads those artifacts to temporary workflow zip\n# * on success, uploads the artifacts to a GitHub Release\n#\n# Note that the GitHub Release will be created with a generated\n# title/body based on your changelogs.\n\nname: Release\npermissions:\n  \"contents\": \"write\"\n\n# This task will run whenever you workflow_dispatch with a tag that looks like a version\n# like \"1.0.0\", \"v0.1.0-prerelease.1\", \"my-app/0.1.0\", \"releases/v1.0.0\", etc.\n# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where\n# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION\n# must be a Cargo-style SemVer Version (must have at least major.minor.patch).\n#\n# If PACKAGE_NAME is specified, then the announcement will be for that\n# package (erroring out if it doesn't have the given version or isn't dist-able).\n#\n# If PACKAGE_NAME isn't specified, then the announcement will be for all\n# (dist-able) packages in the workspace with that version (this mode is\n# intended for workspaces with only one dist-able package, or with all dist-able\n# packages versioned/released in lockstep).\n#\n# If you push multiple tags at once, separate instances of this workflow will\n# spin up, creating an independent announcement for each one. However, GitHub\n# will hard limit this to 3 tags per commit, as it will assume more tags is a\n# mistake.\n#\n# If there's a prerelease-style suffix to the version, then the release(s)\n# will be marked as a prerelease.\non:\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: Release Tag\n        required: true\n        default: dry-run\n        type: string\n\njobs:\n  # Run 'dist plan' (or host) to determine what tasks we need to do\n  plan:\n    runs-on: \"ubuntu-latest\"\n    outputs:\n      val: ${{ steps.plan.outputs.manifest }}\n      tag: ${{ (inputs.tag != 'dry-run' && inputs.tag) || '' }}\n      tag-flag: ${{ inputs.tag && inputs.tag != 'dry-run' && format('--tag={0}', inputs.tag) || '' }}\n      publishing: ${{ inputs.tag && inputs.tag != 'dry-run' }}\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install dist\n        # we specify bash to get pipefail; it guards against the `curl` command\n        # failing. otherwise `sh` won't catch that `curl` returned non-0\n        shell: bash\n        run: \"curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh\"\n      - name: Cache dist\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f\n        with:\n          name: cargo-dist-cache\n          path: ~/.cargo/bin/dist\n      # sure would be cool if github gave us proper conditionals...\n      # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible\n      # functionality based on whether this is a pull_request, and whether it's from a fork.\n      # (PRs run on the *source* but secrets are usually on the *target* -- that's *good*\n      # but also really annoying to build CI around when it needs secrets to work right.)\n      - id: plan\n        run: |\n          dist ${{ (inputs.tag && inputs.tag != 'dry-run' && format('host --steps=create --tag={0}', inputs.tag)) || 'plan' }} --output-format=json > plan-dist-manifest.json\n          echo \"dist ran successfully\"\n          cat plan-dist-manifest.json\n          echo \"manifest=$(jq -c \".\" plan-dist-manifest.json)\" >> \"$GITHUB_OUTPUT\"\n      - name: \"Upload dist-manifest.json\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f\n        with:\n          name: artifacts-plan-dist-manifest\n          path: plan-dist-manifest.json\n\n  custom-build-binaries:\n    needs:\n      - plan\n    if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }}\n    uses: ./.github/workflows/build-binaries.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n\n  custom-build-docker:\n    needs:\n      - plan\n    if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }}\n    uses: ./.github/workflows/build-docker.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n    permissions:\n      \"attestations\": \"write\"\n      \"contents\": \"read\"\n      \"id-token\": \"write\"\n      \"packages\": \"write\"\n\n  # Build and package all the platform-agnostic(ish) things\n  build-global-artifacts:\n    needs:\n      - plan\n      - custom-build-binaries\n      - custom-build-docker\n    runs-on: \"ubuntu-latest\"\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install cached dist\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\n        with:\n          name: cargo-dist-cache\n          path: ~/.cargo/bin/\n      - run: chmod +x ~/.cargo/bin/dist\n      # Get all the local artifacts for the global tasks to use (for e.g. checksums)\n      - name: Fetch local artifacts\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\n        with:\n          pattern: artifacts-*\n          path: target/distrib/\n          merge-multiple: true\n      - id: cargo-dist\n        shell: bash\n        run: |\n          dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json \"--artifacts=global\" > dist-manifest.json\n          echo \"dist ran successfully\"\n\n          # Parse out what we just built and upload it to scratch storage\n          echo \"paths<<EOF\" >> \"$GITHUB_OUTPUT\"\n          jq --raw-output \".upload_files[]\" dist-manifest.json >> \"$GITHUB_OUTPUT\"\n          echo \"EOF\" >> \"$GITHUB_OUTPUT\"\n\n          cp dist-manifest.json \"$BUILD_MANIFEST_NAME\"\n      - name: \"Upload artifacts\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f\n        with:\n          name: artifacts-build-global\n          path: |\n            ${{ steps.cargo-dist.outputs.paths }}\n            ${{ env.BUILD_MANIFEST_NAME }}\n  # Determines if we should publish/announce\n  host:\n    needs:\n      - plan\n      - custom-build-binaries\n      - custom-build-docker\n      - build-global-artifacts\n    # Only run if we're \"publishing\", and only if plan, local and global didn't fail (skipped is fine)\n    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') }}\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    runs-on: \"ubuntu-latest\"\n    outputs:\n      val: ${{ steps.host.outputs.manifest }}\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install cached dist\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\n        with:\n          name: cargo-dist-cache\n          path: ~/.cargo/bin/\n      - run: chmod +x ~/.cargo/bin/dist\n      # Fetch artifacts from scratch-storage\n      - name: Fetch artifacts\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\n        with:\n          pattern: artifacts-*\n          path: target/distrib/\n          merge-multiple: true\n      # This is a harmless no-op for GitHub Releases, hosting for that happens in \"announce\"\n      - id: host\n        shell: bash\n        run: |\n          dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json\n          echo \"artifacts uploaded and released successfully\"\n          cat dist-manifest.json\n          echo \"manifest=$(jq -c \".\" dist-manifest.json)\" >> \"$GITHUB_OUTPUT\"\n      - name: \"Upload dist-manifest.json\"\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f\n        with:\n          # Overwrite the previous copy\n          name: artifacts-dist-manifest\n          path: dist-manifest.json\n\n  custom-publish-crates:\n    needs:\n      - plan\n      - host\n    if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}\n    uses: ./.github/workflows/publish-crates.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n    # publish jobs get escalated permissions\n    permissions:\n      \"id-token\": \"write\"\n\n  custom-publish-pypi:\n    needs:\n      - plan\n      - host\n    if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}\n    uses: ./.github/workflows/publish-pypi.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n    # publish jobs get escalated permissions\n    permissions:\n      \"id-token\": \"write\"\n\n  custom-publish-npm:\n    needs:\n      - plan\n      - host\n    if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}\n    uses: ./.github/workflows/publish-npm.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n    # publish jobs get escalated permissions\n    permissions:\n      \"id-token\": \"write\"\n\n  # Create a GitHub Release while uploading all files to it\n  announce:\n    needs:\n      - plan\n      - host\n      - custom-publish-crates\n      - custom-publish-pypi\n      - custom-publish-npm\n    # use \"always() && ...\" to allow us to wait for all publish jobs while\n    # still allowing individual publish jobs to skip themselves (for prereleases).\n    # \"host\" however must run to completion, no skipping allowed!\n    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') }}\n    runs-on: \"ubuntu-latest\"\n    permissions:\n      \"attestations\": \"write\"\n      \"contents\": \"write\"\n      \"id-token\": \"write\"\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          persist-credentials: false\n          submodules: recursive\n      # Create a GitHub Release while uploading all files to it\n      - name: \"Download GitHub Artifacts\"\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\n        with:\n          pattern: artifacts-*\n          path: artifacts\n          merge-multiple: true\n      - name: Cleanup\n        run: |\n          # Remove the granular manifests\n          rm -f artifacts/*-dist-manifest.json\n      - name: Attest\n        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32\n        with:\n          subject-path: |\n            artifacts/*\n      - name: Create GitHub Release\n        env:\n          PRERELEASE_FLAG: \"${{ fromJson(needs.host.outputs.val).announcement_is_prerelease && '--prerelease' || '' }}\"\n          ANNOUNCEMENT_TITLE: \"${{ fromJson(needs.host.outputs.val).announcement_title }}\"\n          ANNOUNCEMENT_BODY: \"${{ fromJson(needs.host.outputs.val).announcement_github_body }}\"\n          RELEASE_COMMIT: \"${{ github.sha }}\"\n        run: |\n          # Write and read notes from a file to avoid quoting breaking things\n          echo \"$ANNOUNCEMENT_BODY\" > $RUNNER_TEMP/notes.txt\n\n          gh release create \"${{ needs.plan.outputs.tag }}\" --target \"$RELEASE_COMMIT\" $PRERELEASE_FLAG --title \"$ANNOUNCEMENT_TITLE\" --notes-file \"$RUNNER_TEMP/notes.txt\" artifacts/*\n\n  custom-publish-docs:\n    needs:\n      - plan\n      - announce\n    uses: ./.github/workflows/publish-docs.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n    permissions:\n      \"contents\": \"read\"\n      \"id-token\": \"write\"\n      \"pages\": \"write\"\n\n  custom-publish-homebrew:\n    needs:\n      - plan\n      - announce\n    uses: ./.github/workflows/publish-homebrew.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n\n  custom-publish-prek-action:\n    needs:\n      - plan\n      - announce\n    uses: ./.github/workflows/publish-prek-action.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n\n  custom-publish-winget:\n    needs:\n      - plan\n      - announce\n    uses: ./.github/workflows/publish-winget.yml\n    with:\n      plan: ${{ needs.plan.outputs.val }}\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/setup-dev-drive.ps1",
    "content": "# This creates a 10GB dev drive, and exports all required environment\n# variables so that rustup, prek and others all use the dev drive as much\n# as possible.\n# $Volume = New-VHD -Path C:/prek_dev_drive.vhdx -SizeBytes 10GB |\n# \t\t\t\t\tMount-VHD -Passthru |\n# \t\t\t\t\tInitialize-Disk -Passthru |\n# \t\t\t\t\tNew-Partition -AssignDriveLetter -UseMaximumSize |\n# \t\t\t\t\tFormat-Volume -FileSystem ReFS -Confirm:$false -Force\n#\n# Write-Output $Volume\n\n$Drive = \"D:\"\n$Tmp = \"$($Drive)\\prek-tmp\"\n\n# Create the directory ahead of time in an attempt to avoid race-conditions\nNew-Item $Tmp -ItemType Directory\n\n# Move Cargo to the dev drive\nNew-Item -Path \"$($Drive)/.cargo/bin\" -ItemType Directory -Force\nif (Test-Path \"C:/Users/runneradmin/.cargo\") {\n    Copy-Item -Path \"C:/Users/runneradmin/.cargo/*\" -Destination \"$($Drive)/.cargo/\" -Recurse -Force\n}\n\nWrite-Output `\n\t\"DEV_DRIVE=$($Drive)\" `\n\t\"TMP=$($Tmp)\" `\n\t\"TEMP=$($Tmp)\" `\n\t\"PREK_INTERNAL__TEST_DIR=$($Tmp)\" `\n\t\"RUSTUP_HOME=$($Drive)/.rustup\" `\n\t\"CARGO_HOME=$($Drive)/.cargo\" `\n\t\"PREK_WORKSPACE=$($Drive)/prek\" `\n    \"PATH=$($Drive)/.cargo/bin;$env:PATH\" `\n\t>> $env:GITHUB_ENV\n"
  },
  {
    "path": ".github/workflows/sync-identify.yml",
    "content": "name: \"Sync pre-commit identify tags\"\non:\n  workflow_dispatch:\n  schedule:\n    - cron: \"0 0 * * *\"\n\npermissions: {}\n\njobs:\n  sync:\n    if: github.repository == 'j178/prek'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n      - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0\n        with:\n          version: \"latest\"\n          enable-cache: true\n      - name: \"Sync identify tags\"\n        run: uv run --upgrade gen.py\n        working-directory: ./crates/prek-identify\n\n      - name: \"Create Pull Request\"\n        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0\n        with:\n          commit-message: \"Sync latest identify tags\"\n          add-paths: |\n            crates/prek-identify/src/tags.rs\n            crates/prek-identify/gen.py.lock\n          branch: \"sync-identify-tags\"\n          title: \"Sync latest identify tags\"\n          body: \"Automated update for identify tags.\"\n          base: \"master\"\n          draft: true\n"
  },
  {
    "path": ".github/workflows/zizmor.yml",
    "content": "name: Run zizmor\n\non:\n  push:\n    branches: [\"master\"]\n  pull_request:\n    branches: [\"**\"]\n\npermissions: {}\n\njobs:\n  zizmor:\n    name: Run zizmor\n    runs-on: ubuntu-latest\n    permissions:\n      security-events: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Run zizmor\n        uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2\n"
  },
  {
    "path": ".github/zizmor.yml",
    "content": "# Configuration for the zizmor static analysis tool, run via pre-commit in CI\n# https://woodruffw.github.io/zizmor/configuration/\n\n# release.yml is generated by cargo-dist, ignore its findings there.\nrules:\n  template-injection:\n    ignore:\n      - release.yml\n  excessive-permissions:\n    ignore:\n      - release.yml\n  secrets-inherit:\n    ignore:\n      - release.yml\n  secrets-outside-env:\n    ignore:\n      - ci.yml # CODECOV_TOKEN is not important\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n\n.cache\n__pycache__/\nsite/\n\n# Insta snapshots.\n*.pending-snap\n\n# JetBrains IDE\n.idea\n\n# Vscode IDE\n.vscode\n\n# macOS\n**/.DS_Store\n\n# profiling flamegraphs\n*.flamegraph.svg\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "fail_fast: true\ndefault_install_hook_types: [pre-push]\nexclude:\n  glob: '**/snapshots/**'\n\nrepos:\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n        exclude:\n          glob: CHANGELOG.md\n      - id: mixed-line-ending\n      - id: check-yaml\n      - id: check-toml\n      - id: end-of-file-fixer\n\n  - repo: https://github.com/crate-ci/typos\n    rev: v1.44.0\n    hooks:\n      - id: typos\n\n  - repo: https://github.com/executablebooks/mdformat\n    rev: '1.0.0'\n    hooks:\n      - id: mdformat\n        language: python  # ensures that Renovate can update additional_dependencies\n        args: [--number, --compact-tables, --align-semantic-breaks-in-lists]\n        additional_dependencies:\n          - mdformat-mkdocs==5.1.4\n          - mdformat-simple-breaks==0.1.0\n\n  - repo: https://github.com/python-jsonschema/check-jsonschema\n    rev: 0.37.0\n    hooks:\n      - id: check-metaschema\n        files:\n          glob: prek.schema.json\n      - id: check-github-workflows\n      - id: check-renovate\n        additional_dependencies: ['json5']\n\n  - repo: local\n    hooks:\n      - id: taplo-fmt\n        name: taplo fmt\n        entry: taplo fmt --config .config/taplo.toml\n        language: python\n        additional_dependencies: [\"taplo==0.9.3\"]\n        types: [toml]\n\n  - repo: local\n    hooks:\n      - id: cargo-fmt\n        name: cargo fmt\n        entry: cargo fmt --\n        language: system\n        types: [rust]\n        pass_filenames: false # This makes it a lot faster\n\n      - id: cargo-clippy\n        name: cargo clippy\n        language: system\n        types: [rust]\n        pass_filenames: false\n        entry: cargo clippy --all-targets --all-features -- -D warnings\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## 0.3.6\n\nReleased on 2026-03-16.\n\n### Enhancements\n\n- Allow selectors for hook ids containing colons ([#1782](https://github.com/j178/prek/pull/1782))\n- 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))\n- Retry auth-failed repo clones with terminal prompts enabled ([#1761](https://github.com/j178/prek/pull/1761))\n\n### Performance\n\n- Optimize `detect_private_key` by chunked reading and using aho-corasick ([#1791](https://github.com/j178/prek/pull/1791))\n- Optimize `fix_byte_order_marker` by shifting file contents in place ([#1790](https://github.com/j178/prek/pull/1790))\n\n### Bug fixes\n\n- Align stage defaulting behavior with pre-commit ([#1788](https://github.com/j178/prek/pull/1788))\n- Make sure child output is drained in the PTY subprocess ([#1768](https://github.com/j178/prek/pull/1768))\n- fix(golang): use `GOTOOLCHAIN=local` when probing system go ([#1797](https://github.com/j178/prek/pull/1797))\n\n### Documentation\n\n- Disambiguate “hook” terminology by renaming \"Git hooks\" to \"Git shims\" ([#1776](https://github.com/j178/prek/pull/1776))\n- Document compatibility with pre-commit ([#1767](https://github.com/j178/prek/pull/1767))\n- Update configuration.md with TOML 1.1 notes ([#1764](https://github.com/j178/prek/pull/1764))\n\n### Other changes\n\n- Sync latest identify tags ([#1798](https://github.com/j178/prek/pull/1798))\n\n### Contributors\n\n- @github-actions\n- @j178\n- @pcastellazzi\n- @deadnews\n- @copilot-swe-agent\n\n## 0.3.5\n\nReleased on 2026-03-09.\n\n### Enhancements\n\n- Add automatic Ruby download support using rv binaries ([#1668](https://github.com/j178/prek/pull/1668))\n- Adjust open file limit on process startup ([#1705](https://github.com/j178/prek/pull/1705))\n- Allow parallel gem retry ([#1732](https://github.com/j178/prek/pull/1732))\n- Enable system-proxy feature on reqwest ([#1738](https://github.com/j178/prek/pull/1738))\n- Expose `--git-dir` to force hook installation target ([#1723](https://github.com/j178/prek/pull/1723))\n- Pass `--quiet`, `--verbose`, and `--no-progress` through `prek install` into generated hook scripts ([#1753](https://github.com/j178/prek/pull/1753))\n- Respect `core.sharedRepository` for hook permissions ([#1755](https://github.com/j178/prek/pull/1755))\n- Support legacy mode hook script ([#1706](https://github.com/j178/prek/pull/1706))\n- rust: support `cli:` git dependency 4th segment package disambiguation ([#1747](https://github.com/j178/prek/pull/1747))\n\n### Bug fixes\n\n- Fix Python `__main__.py` entry ([#1741](https://github.com/j178/prek/pull/1741))\n- python: strip `UV_SYSTEM_PYTHON` from `uv venv` and `pip install` commands ([#1756](https://github.com/j178/prek/pull/1756))\n\n### Other changes\n\n- Sync latest identify tags ([#1733](https://github.com/j178/prek/pull/1733))\n\n### Contributors\n\n- @Dev-iL\n- @tennox\n- @shaanmajid\n- @is-alnilam\n- @github-actions\n- @j178\n\n## 0.3.4\n\nReleased on 2026-02-28.\n\n### Enhancements\n\n- Allow `pass_filenames` to accept a positive integer ([#1698](https://github.com/j178/prek/pull/1698))\n- Install and compile gems in parallel ([#1674](https://github.com/j178/prek/pull/1674))\n- Sync identify file-type mappings with pre-commit identify ([#1660](https://github.com/j178/prek/pull/1660))\n- Use `--locked` for Rust `cargo install` commands ([#1661](https://github.com/j178/prek/pull/1661))\n- Add `PREK_MAX_CONCURRENCY` environment variable for configuring maximum concurrency ([#1697](https://github.com/j178/prek/pull/1697))\n- Add `PREK_LOG_TRUNCATE_LIMIT` environment variable for configuring log truncation ([#1679](https://github.com/j178/prek/pull/1679))\n- Add support for `python -m prek` ([#1686](https://github.com/j178/prek/pull/1686))\n\n### Bug fixes\n\n- Skip invalid Rust toolchains instead of failing ([#1699](https://github.com/j178/prek/pull/1699))\n\n### Performance\n\n- Bitset-based TagSet refactor: precompute tag masks and speed up hook type filtering ([#1665](https://github.com/j178/prek/pull/1665))\n\n### Documentation\n\n- Document `winget install j178.Prek` ([#1670](https://github.com/j178/prek/pull/1670))\n\n### Contributors\n\n- @uplsh580\n- @Svecco\n- @dbast\n- @drichardson\n- @JP-Ellis\n- @j178\n- @is-alnilam\n- @copilot-swe-agent\n\n## 0.3.3\n\nReleased on 2026-02-15.\n\n### Enhancements\n\n- Read Python version specifier from hook repo `pyproject.toml` ([#1596](https://github.com/j178/prek/pull/1596))\n- Add `#:schema` directives to generated prek.toml ([#1597](https://github.com/j178/prek/pull/1597))\n- Add `prek util list-builtins` command ([#1600](https://github.com/j178/prek/pull/1600))\n- 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))\n- Add progress bar to `cache clean` and show removal summary ([#1616](https://github.com/j178/prek/pull/1616))\n- Make `yaml-to-toml` CONFIG argument optional ([#1593](https://github.com/j178/prek/pull/1593))\n- `prek uninstall` removes legacy scripts too ([#1622](https://github.com/j178/prek/pull/1622))\n\n### Bug fixes\n\n- Fix underflow when formatting summary output ([#1626](https://github.com/j178/prek/pull/1626))\n- Match `files/exclude` filter against relative path of nested project ([#1624](https://github.com/j178/prek/pull/1624))\n- Select `musllinux` wheel tag for uv on musl-based distros ([#1628](https://github.com/j178/prek/pull/1628))\n\n### Documentation\n\n- Clarify `prek list` description ([#1604](https://github.com/j178/prek/pull/1604))\n\n### Contributors\n\n- @ichoosetoaccept\n- @shaanmajid\n- @soraxas\n- @9999years\n- @j178\n\n## 0.3.2\n\nReleased on 2026-02-06.\n\n### Highlights\n\n- **`prek.toml` is here!**\n\n    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.\n\n    For example, this config:\n\n    ```yaml\n    repos:\n      - repo: https://github.com/pre-commit/pre-commit-hooks\n        rev: v6.0.0\n        hooks:\n          - id: check-yaml\n    ```\n\n    Can be written as `prek.toml` like this:\n\n    ```toml\n    [[repos]]\n    repo = \"https://github.com/pre-commit/pre-commit-hooks\"\n    rev = \"v6.0.0\"\n    hooks = [ { id = \"check-yaml\" } ]\n    ```\n\n- **`serde-yaml` has been replaced with `serde-saphyr`**\n\n    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.\n\n    For example, this invalid config:\n\n    ```yaml\n    repos:\n      - repo: https://github.com/crate-ci/typos\n        hooks:\n          - id: typos\n    ```\n\n    Before:\n\n    ```console\n    $ prek run\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: Invalid remote repo: missing field `rev`\n    ```\n\n    Now:\n\n    ```console\n    $ prek run\n    error: Failed to parse `.pre-commit-config.yaml`\n    caused by: error: line 2 column 5: missing field `rev` at line 2, column 5\n    --> <input>:2:5\n      |\n    1 | repos:\n    2 |   - repo: https://github.com/crate-ci/typos\n      |     ^ missing field `rev` at line 2, column 5\n    3 |     hooks:\n    4 |       - id: typos\n      |\n    ```\n\n- **`prek util` subcommands**\n\n    We added a new `prek util` top-level command for miscellaneous utilities that don't fit into other categories. The first two utilities are:\n\n    - `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.\n    - `prek util yaml-to-toml`: converts `.pre-commit-config.yaml` to `prek.toml`.\n\n    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.\n\n### Enhancements\n\n- Add `prek util identify` subcommand ([#1554](https://github.com/j178/prek/pull/1554))\n- Add `prek util yaml-to-toml` to convert `.pre-commit-config.yaml` to `prek.toml` ([#1584](https://github.com/j178/prek/pull/1584))\n- Detect install source for actionable upgrade hints ([#1540](https://github.com/j178/prek/pull/1540))\n- Detect prek installed by the standalone installer ([#1545](https://github.com/j178/prek/pull/1545))\n- Implement `serialize_yaml_scalar` using `serde-saphyr` ([#1534](https://github.com/j178/prek/pull/1534))\n- Improve max cli arguments length calculation ([#1518](https://github.com/j178/prek/pull/1518))\n- Move `identify` and `init-template-dir` under the `prek util` top-level command ([#1574](https://github.com/j178/prek/pull/1574))\n- Replace serde-yaml with serde-saphyr (again) ([#1520](https://github.com/j178/prek/pull/1520))\n- Show precise location for config parsing error ([#1530](https://github.com/j178/prek/pull/1530))\n- Support `Julia` language ([#1519](https://github.com/j178/prek/pull/1519))\n- Support `prek.toml` ([#1271](https://github.com/j178/prek/pull/1271))\n- Added `PREK_QUIET` environment variable support ([#1513](https://github.com/j178/prek/pull/1513))\n- Remove upper bound constraint of uv version ([#1588](https://github.com/j178/prek/pull/1588))\n\n### Bug fixes\n\n- Do not make the child a session leader ([#1586](https://github.com/j178/prek/pull/1586))\n- Fix FilePattern schema to accept plain strings ([#1564](https://github.com/j178/prek/pull/1564))\n- Use semver fallback sort when tag timestamps are equal ([#1579](https://github.com/j178/prek/pull/1579))\n\n### Documentation\n\n- Add `OpenClaw` to the list of users ([#1517](https://github.com/j178/prek/pull/1517))\n- 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))\n- Add document about authoring remote hooks ([#1571](https://github.com/j178/prek/pull/1571))\n- Add `llms.txt` generation for LLM-friendly documentation ([#1553](https://github.com/j178/prek/pull/1553))\n- Document using `--refresh` to pick up `.prekignore` changes ([#1575](https://github.com/j178/prek/pull/1575))\n- Fix PowerShell completion instruction syntax ([#1568](https://github.com/j178/prek/pull/1568))\n- Update quick start to use `prek.toml` ([#1576](https://github.com/j178/prek/pull/1576))\n\n### Other changes\n\n- Include `prek.toml` in run hint for config filename ([#1578](https://github.com/j178/prek/pull/1578))\n\n### Contributors\n\n- @fatelei\n- @domenkozar\n- @makeecat\n- @fllesser\n- @j178\n- @copilot-swe-agent\n- @oopscompiled\n- @rmuir\n- @shaanmajid\n\n## 0.3.1\n\nReleased on 2026-01-31.\n\n### Enhancements\n\n- Add `language: swift` support ([#1463](https://github.com/j178/prek/pull/1463))\n- Add `language: haskell` support ([#1484](https://github.com/j178/prek/pull/1484))\n- Extract go version constraint from `go.mod` ([#1457](https://github.com/j178/prek/pull/1457))\n- Warn when config file exists but fails to parse ([#1487](https://github.com/j178/prek/pull/1487))\n- 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))\n- Allow `GIT_CONFIG_PARAMETERS` for private repository authentication ([#1472](https://github.com/j178/prek/pull/1472))\n- Show progress bar when running builtin hooks ([#1504](https://github.com/j178/prek/pull/1504))\n\n### Bug fixes\n\n- Cap ARG_MAX at `1<<19` for safety ([#1506](https://github.com/j178/prek/pull/1506))\n- Don't check Python executable path in health check ([#1496](https://github.com/j178/prek/pull/1496))\n\n### Documentation\n\n- Include `CocoIndex` as a project using prek ([#1477](https://github.com/j178/prek/pull/1477))\n- Add commands for artifact verification using GitHub Attestations ([#1500](https://github.com/j178/prek/pull/1500))\n\n### Contributors\n\n- @halms\n- @Haleshot\n- @simono\n- @tisonkun\n- @fllesser\n- @j178\n- @shaanmajid\n\n## 0.3.0\n\nReleased on 2026-01-22.\n\n### Highlights\n\n- `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.\n- `language: bun` is now supported, making it possible to write and run hooks with [Bun](https://bun.sh/).\n\n### Enhancements\n\n- Implement `prek cache gc` ([#1410](https://github.com/j178/prek/pull/1410))\n\n    - Bootstrap tracking configs from workspace cache ([#1417](https://github.com/j178/prek/pull/1417))\n    - Show total size `prek cache gc` removed ([#1418](https://github.com/j178/prek/pull/1418))\n    - Show accurate repo and hook details in `prek cache gc -v` ([#1420](https://github.com/j178/prek/pull/1420))\n    - `prek cache gc` remove specific unused tool versions ([#1422](https://github.com/j178/prek/pull/1422))\n    - Fix unused tool versions not removed in `prek cache gc` ([#1436](https://github.com/j178/prek/pull/1436))\n\n- Add `language: bun` support ([#1411](https://github.com/j178/prek/pull/1411))\n\n    - Use `git ls-remote --tags` to list bun versions ([#1439](https://github.com/j178/prek/pull/1439))\n\n- Accept `--stage` as an alias for `--hook-stage` in `prek run` ([#1398](https://github.com/j178/prek/pull/1398))\n\n- Expand `~` tilde in `PREK_HOME` ([#1431](https://github.com/j178/prek/pull/1431))\n\n- Support refs to trees ([#1449](https://github.com/j178/prek/pull/1449))\n\n### Bug fixes\n\n- Avoid file lock warning for in-process contention ([#1406](https://github.com/j178/prek/pull/1406))\n- Resolve relative repo paths from config file directory ([#1443](https://github.com/j178/prek/pull/1443))\n- fix: use `split()` instead of `resolve(None)` for builtin hook argument parsing ([#1415](https://github.com/j178/prek/pull/1415))\n\n### Documentation\n\n- Add `simple-icons` and `ast-grep` to the users of prek ([#1403](https://github.com/j178/prek/pull/1403))\n- Improve JSON schema for `repo` field ([#1432](https://github.com/j178/prek/pull/1432))\n- Improve JSON schema for builtin and meta hooks ([#1427](https://github.com/j178/prek/pull/1427))\n- Add pronunciation entry to FAQ ([#1442](https://github.com/j178/prek/pull/1442))\n- Add commitizen to the list of projects using prek ([#1413](https://github.com/j178/prek/pull/1413))\n- Move docs to zensical ([#1421](https://github.com/j178/prek/pull/1421))\n\n### Other Changes\n\n- Refactor config layout ([#1407](https://github.com/j178/prek/pull/1407))\n\n### Contributors\n\n- @shaanmajid\n- @KevinGimbel\n- @jtamagnan\n- @jmeickle-theaiinstitute\n- @YazdanRa\n- @j178\n- @mschoettle\n- @tisonkun\n\n## 0.2.30\n\nReleased on 2026-01-18.\n\n### Enhancements\n\n- Build binaries using minimal-size profile ([#1376](https://github.com/j178/prek/pull/1376))\n- Check for duplicate keys in `check-json5` builtin hook ([#1387](https://github.com/j178/prek/pull/1387))\n- Preserve quoting style in `auto-update` ([#1379](https://github.com/j178/prek/pull/1379))\n- Show warning if file lock acquiring blocks for long time ([#1353](https://github.com/j178/prek/pull/1353))\n- Singleflight Python health checks with cached interpreter info ([#1381](https://github.com/j178/prek/pull/1381))\n\n### Bug fixes\n\n- Do not resolve entry for docker_image ([#1386](https://github.com/j178/prek/pull/1386))\n- Fix command lookup on Windows ([#1383](https://github.com/j178/prek/pull/1383))\n\n### Documentation\n\n- Document language support details ([#1380](https://github.com/j178/prek/pull/1380))\n- Document that `check-json5` now rejects duplicate keys ([#1391](https://github.com/j178/prek/pull/1391))\n\n### Contributors\n\n- @j178\n\n## 0.2.29\n\nReleased on 2026-01-16.\n\n### Highlights\n\n`files` / `exclude` now support globs (including glob lists), making config filters much easier to read and maintain than heavily-escaped regex.\n\nBefore (regex):\n\n```yaml\nfiles: \"^(src/.*\\\\.rs$|crates/[^/]+/src/.*\\\\.rs$)\"\n```\n\nAfter (glob list):\n\n```yaml\nfiles:\n  glob:\n    - src/**/*.rs\n    - crates/**/src/**/*.rs\n```\n\n### Enhancements\n\n- Add `check-json5` as builtin hooks ([#1367](https://github.com/j178/prek/pull/1367))\n- Add glob list support for file patterns (`files` and `exclude`) ([#1197](https://github.com/j178/prek/pull/1197))\n\n### Bug fixes\n\n- Fix missing commit hash from version info ([#1352](https://github.com/j178/prek/pull/1352))\n- Remove git env vars from `uv pip install` subprocess ([#1355](https://github.com/j178/prek/pull/1355))\n- Set `TERM=dumb` under PTY to prevent capability-probe hangs ([#1363](https://github.com/j178/prek/pull/1363))\n\n### Documentation\n\n- Add `home-assistant/core` to the users of prek ([#1350](https://github.com/j178/prek/pull/1350))\n- Document builtin hooks ([#1370](https://github.com/j178/prek/pull/1370))\n- Explain project configuration scope ([#1373](https://github.com/j178/prek/pull/1373))\n\n### Contributors\n\n- @Goldziher\n- @yihong0618\n- @j178\n- @shaanmajid\n- @ulgens\n\n## 0.2.28\n\nReleased on 2026-01-13.\n\n### Enhancements\n\n- Avoid running `git diff` for skipped hooks ([#1335](https://github.com/j178/prek/pull/1335))\n- More accurate command line length limit calculation ([#1348](https://github.com/j178/prek/pull/1348))\n- Raise platform command line length upper limit ([#1347](https://github.com/j178/prek/pull/1347))\n- Use `/bin/sh` in generated git hook scripts ([#1333](https://github.com/j178/prek/pull/1333))\n\n### Bug fixes\n\n- Avoid rewriting if config is up-to-date ([#1346](https://github.com/j178/prek/pull/1346))\n\n### Documentation\n\n- Add `ty` to the users of prek ([#1342](https://github.com/j178/prek/pull/1342))\n- Add `ruff` to the users of prek ([#1334](https://github.com/j178/prek/pull/1334))\n- Complete configuration document ([#1338](https://github.com/j178/prek/pull/1338))\n- Document UV environment variable inheritance in prek ([#1339](https://github.com/j178/prek/pull/1339))\n\n### Contributors\n\n- @copilot-swe-agent\n- @MatthewMckee4\n- @yihong0618\n- @j178\n\n## 0.2.27\n\nReleased on 2026-01-07.\n\n### Highlights\n\n`python/cpython` is now [using](https://github.com/j178/prek/pull/1308) prek. That’s the highlight of this release!\n\n### Enhancements\n\n- Add hook-level `env` option to set environment variables for hooks (#1279) ([#1285](https://github.com/j178/prek/pull/1285))\n- Support apple's `container` for docker language ([#1306](https://github.com/j178/prek/pull/1306))\n- Skip cookiecutter template directories like `{{cookiecutter.project_slug}}` during project discovery ([#1316](https://github.com/j178/prek/pull/1316))\n- Use global `CONCURRENCY` for repo clone ([#1292](https://github.com/j178/prek/pull/1292))\n- untar: disallow external symlinks ([#1314](https://github.com/j178/prek/pull/1314))\n\n### Bug fixes\n\n- Exit with success if no hooks match the hook stage ([#1317](https://github.com/j178/prek/pull/1317))\n- Fix Go template string to detect rootless podman ([#1302](https://github.com/j178/prek/pull/1302))\n- Panic on overly long filenames instead of silently dropping files ([#1287](https://github.com/j178/prek/pull/1287))\n\n### Other changes\n\n- Add `python/cpython` to users ([#1308](https://github.com/j178/prek/pull/1308))\n- Add `MoonshotAI/kimi-cli` to users ([#1286](https://github.com/j178/prek/pull/1286))\n- Drop powerpc64 wheels ([#1319](https://github.com/j178/prek/pull/1319))\n\n### Contributors\n\n- @ulgens\n- @loganaden\n- @danielparks\n- @branchv\n- @j178\n- @yihong0618\n- @mocknen\n- @copilot-swe-agent\n- @ZhuoZhuoCrayon\n\n## 0.2.25\n\nReleased on 2025-12-27.\n\n### Performance\n\n- Use `git cat-file -e` in check if a rev exists ([#1277](https://github.com/j178/prek/pull/1277))\n\n### Bug fixes\n\n- Fix `priority` not applied for remote hooks ([#1281](https://github.com/j178/prek/pull/1281))\n- Report config file parsing error in `auto-update` ([#1274](https://github.com/j178/prek/pull/1274))\n- Unset `GIT_DIR` for auto-update ([#1269](https://github.com/j178/prek/pull/1269))\n\n### Contributors\n\n- @j178\n- @branchv\n\n## 0.2.24\n\nReleased on 2025-12-23.\n\n### Enhancements\n\n- Build and publish docker image to `ghcr.io/j178/prek` ([#1253](https://github.com/j178/prek/pull/1253))\n- Support git urls for rust dependencies ([#1256](https://github.com/j178/prek/pull/1256))\n\n### Bug fixes\n\n- Ensure running `uv pip install` inside the remote repo path ([#1262](https://github.com/j178/prek/pull/1262))\n- Fix `check-added-large-files` for traced files ([#1260](https://github.com/j178/prek/pull/1260))\n- Respect `GIT_DIR` set by git ([#1258](https://github.com/j178/prek/pull/1258))\n\n### Documentation\n\n- Add docker integration docs ([#1254](https://github.com/j178/prek/pull/1254))\n- Clarify `priority` scope across repos ([#1251](https://github.com/j178/prek/pull/1251))\n- Improve documentation for configurations ([#1247](https://github.com/j178/prek/pull/1247))\n- Render changelog in document site ([#1248](https://github.com/j178/prek/pull/1248))\n\n### Contributors\n\n- @j178\n- @branchv\n\n## 0.2.23\n\nReleased on 2025-12-20.\n\n### Highlights\n\n🚀 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).\n\n### Enhancements\n\n- Allow uv reading user-level or system-level configuration files ([#1227](https://github.com/j178/prek/pull/1227))\n- Implement `check-case-conflict` as builtin hook ([#888](https://github.com/j178/prek/pull/888))\n- Implement `priority` based parallel execution ([#1232](https://github.com/j178/prek/pull/1232))\n\n### Bug fixes\n\n- Fix `check-executable-have-shebangs` \"command line too long\" error on Windows ([#1236](https://github.com/j178/prek/pull/1236))\n\n### Documentation\n\n- Add FastAPI to the list of projects using prek ([#1241](https://github.com/j178/prek/pull/1241))\n- Document hook_types flag and default_install_hook_types behavior ([#1225](https://github.com/j178/prek/pull/1225))\n- Improve documentation for `priority` ([#1245](https://github.com/j178/prek/pull/1245))\n- Mention prek can be installed via`taiki-e/install-action@prek` ([#1234](https://github.com/j178/prek/pull/1234))\n\n### Contributors\n\n- @j178\n- @copilot-swe-agent\n- @lmmx\n\n## 0.2.22\n\nReleased on 2025-12-13.\n\n### Highlights\n\nIn this release, prek adds support for the `--cooldown-days` option in the `prek auto-update` command.\nThis option allows users to skip releases that are newer than a specified number of days.\nIt is useful to mitigate open source supply chain risks by avoiding very recent releases that may not have been widely adopted or vetted yet.\nBig thanks to @lmmx for driving this feature!\n\n### Enhancements\n\n- Support`--cooldown-days` in `prek auto-update` ([#1172](https://github.com/j178/prek/pull/1172))\n    - Prefer tag creation timestamp in `--cooldown-days` ([#1221](https://github.com/j178/prek/pull/1221))\n- Use `cargo install` for packages in workspace ([#1207](https://github.com/j178/prek/pull/1207))\n\n### Bug fixes\n\n- Set `CARGO_HOME` for `cargo metadata` ([#1209](https://github.com/j178/prek/pull/1209))\n\n### Contributors\n\n- @j178\n- @lmmx\n\n## 0.2.21\n\nReleased on 2025-12-09.\n\n### Bug fixes\n\n- Fallback to use remote repo package root instead of erroring ([#1203](https://github.com/j178/prek/pull/1203))\n- Prepend toolchain bin directory to PATH when calling cargo ([#1204](https://github.com/j178/prek/pull/1204))\n- Use `cargo` from installed toolchain ([#1202](https://github.com/j178/prek/pull/1202))\n\n### Contributors\n\n- @j178\n\n## 0.2.20\n\nReleased on 2025-12-08.\n\n### Highlights\n\nIn this release:\n\n- Rust hooks are now fully supported with automatic toolchain management, including package discovery in virtual workspaces. Big thanks to @lmmx for driving this.\n- Added a `prek cache size` subcommand so you can quickly see how much cache space prek is using. Thanks @MatthewMckee4!\n- 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.\n\nWant 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)\n\n### Enhancements\n\n- Support Rust language ([#989](https://github.com/j178/prek/pull/989))\n    - Refactor Rust toolchain management ([#1198](https://github.com/j178/prek/pull/1198))\n    - Add support for finding packages in virtual workspaces ([#1180](https://github.com/j178/prek/pull/1180))\n- Add `prek cache size` command ([#1183](https://github.com/j178/prek/pull/1183))\n- Support orphan projects ([#1129](https://github.com/j178/prek/pull/1129))\n- Fallback to `manual` stage for hooks specified directly in command line ([#1185](https://github.com/j178/prek/pull/1185))\n- Make go module cache read-writeable (thus deletable) ([#1164](https://github.com/j178/prek/pull/1164))\n- Provide more information when validating configs and manifests ([#1182](https://github.com/j178/prek/pull/1182))\n- Improve error message for invalid number of arguments to hook-impl ([#1196](https://github.com/j178/prek/pull/1196))\n\n### Bug fixes\n\n- Disable git terminal prompts ([#1193](https://github.com/j178/prek/pull/1193))\n- Prevent `post-checkout` deadlock when cloning repos ([#1192](https://github.com/j178/prek/pull/1192))\n- Prevent color output when redirecting stdout to a file ([#1159](https://github.com/j178/prek/pull/1159))\n\n### Documentation\n\n- Add MacPorts to installation methods ([#1157](https://github.com/j178/prek/pull/1157))\n- Add a FAQ page explaining `prek install --install--hooks` ([#1162](https://github.com/j178/prek/pull/1162))\n\n### Other changes\n\n- Add `prek: enabled` repo badge ([#1171](https://github.com/j178/prek/pull/1171))\n- Add favicon for docs website ([#1187](https://github.com/j178/prek/pull/1187))\n\n### Contributors\n\n- @MatthewMckee4\n- @lmmx\n- @j178\n- @joshmarkovic\n- @frazar\n- @jmelahman\n- @drainpixie\n\n## 0.2.19\n\nReleased on 2025-11-26.\n\n### Performance\n\n- Simplify `fix_byte_order_marker` hook ([#1136](https://github.com/j178/prek/pull/1136))\n- Simplify `trailing-whitespace` hook to improve performance ([#1135](https://github.com/j178/prek/pull/1135))\n\n### Bug fixes\n\n- Close stdin for hook subcommands ([#1155](https://github.com/j178/prek/pull/1155))\n- Fix parsing Python interpreter info containing non-UTF8 chars ([#1141](https://github.com/j178/prek/pull/1141))\n\n### Contributors\n\n- @chilin0525\n- @nblock\n- @j178\n\n## 0.2.18\n\nReleased on 2025-11-21.\n\n### Highlights\n\nIn this release, prek adds a new special repo type `repo: builtin` that lets you use built‑in hooks.\nIt 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.\nSince prek doesn’t have to clone anything or set up a virtual environment, `repo: builtin` hooks work even in air‑gapped environments.\n\nFor more details, see: https://prek.j178.dev/builtin/\n\n### Enhancements\n\n- Add support `repo: builtin` ([#1118](https://github.com/j178/prek/pull/1118))\n- Enable virtual terminal processing on Windows ([#1123](https://github.com/j178/prek/pull/1123))\n\n### Bug fixes\n\n- Do not recurse into submodules during workspace discovery ([#1121](https://github.com/j178/prek/pull/1121))\n- Do not dim the hook output ([#1126](https://github.com/j178/prek/pull/1126))\n- Further reduce max cli length for cmd.exe on Windows ([#1131](https://github.com/j178/prek/pull/1131))\n- Revert \"Disallow hook-level `minimum_prek_version` (#1101)\" ([#1120](https://github.com/j178/prek/pull/1120))\n\n### Other changes\n\n- docs: refer airflow as Apache Airflow ([#1116](https://github.com/j178/prek/pull/1116))\n\n### Contributors\n\n- @j178\n- @Lee-W\n\n## 0.2.17\n\nReleased on 2025-11-18.\n\n### Bug fixes\n\n- Revert back to use `serde_yaml` again ([#1112](https://github.com/j178/prek/pull/1112))\n\n### Contributors\n\n- @j178\n\n## 0.2.16\n\nReleased on 2025-11-18.\n\n### Bug fixes\n\n- Disallow hook-level `minimum_prek_version` ([#1101](https://github.com/j178/prek/pull/1101))\n- Do not require a project in `prek init-template-dir` ([#1109](https://github.com/j178/prek/pull/1109))\n- Make sure `uv pip install` uses the Python from virtualenv ([#1108](https://github.com/j178/prek/pull/1108))\n- Restore using `serde_yaml` in `check-yaml` hook ([#1106](https://github.com/j178/prek/pull/1106))\n\n### Contributors\n\n- @j178\n\n## 0.2.15\n\nReleased on 2025-11-17.\n\n### Highlights\n\nprek 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).\n\n### Enhancements\n\n- Clean up hook environments when install fails ([#1085](https://github.com/j178/prek/pull/1085))\n- Prepare for publishing prek to crates.io ([#1088](https://github.com/j178/prek/pull/1088))\n- Replace `serde-yaml` with `serde_saphyr` ([#1087](https://github.com/j178/prek/pull/1087))\n- Warn unexpected keys in repo and hook level ([#1096](https://github.com/j178/prek/pull/1096))\n\n### Bug fixes\n\n- Fix `prek init-template-dir` fails in non-git repo ([#1093](https://github.com/j178/prek/pull/1093))\n\n### Contributors\n\n- @j178\n\n## 0.2.14\n\nReleased on 2025-11-14.\n\n### Enhancements\n\n- Support `PREK_CONTAINER_RUNTIME=podman` to override container runtime ([#1033](https://github.com/j178/prek/pull/1033))\n- Support rootless container runtime ([#1018](https://github.com/j178/prek/issues/1018))\n- Support `language: unsupported` and `language: unsupported_script` introduced in pre-commit v4.4 ([#1073](https://github.com/j178/prek/pull/1073))\n- Tweak to regex used for mountinfo ([#1037](https://github.com/j178/prek/pull/1037))\n\n### Bug fixes\n\n- Fix `--files` argument - files referencing other projects aren’t being filtered ([#1064](https://github.com/j178/prek/pull/1064))\n- Unset `objectFormat` in `git init` ([#1048](https://github.com/j178/prek/pull/1048))\n\n### Documentation\n\n- Add scoop to installation ([#1067](https://github.com/j178/prek/pull/1067))\n- Document workspace file visibility constraints ([#1071](https://github.com/j178/prek/pull/1071))\n- 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))\n\n### Other changes\n\n- Add a hint to install when running inside a sub-project ([#1045](https://github.com/j178/prek/pull/1045))\n- Add a hint to use `--refresh` when no configuration found ([#1046](https://github.com/j178/prek/pull/1046))\n- Run uv pip install from the current directory ([#1069](https://github.com/j178/prek/pull/1069))\n\n### Contributors\n\n- @zzstoatzz\n- @st1971\n- @yihong0618\n- @j178\n- @copilot-swe-agent\n- @idlsoft\n\n## 0.2.13\n\nReleased on 2025-11-04.\n\n### Enhancements\n\n- Add Ruby support (no download support yet) ([#993](https://github.com/j178/prek/pull/993))\n- Implement `check-executables-have-shebangs` as builtin-hook ([#924](https://github.com/j178/prek/pull/924))\n- Improve container id detection ([#1031](https://github.com/j178/prek/pull/1031))\n\n### Performance\n\n- Optimize hot paths: reduce allocations ([#997](https://github.com/j178/prek/pull/997))\n- Refactor `identify` using smallvec ([#982](https://github.com/j178/prek/pull/982))\n\n### Bug fixes\n\n- Fix YAML with nested merge keys ([#1020](https://github.com/j178/prek/pull/1020))\n- Treat every file as executable on Windows to keep compatibility with pre-commit ([#980](https://github.com/j178/prek/pull/980))\n\n### Documentation\n\n- Document that .gitignore is respected by default during workspace discovery ([#983](https://github.com/j178/prek/pull/983))\n- Update project stability status ([#1005](https://github.com/j178/prek/pull/1005))\n- Add FastMCP to \"who is using prek\" ([#1034](https://github.com/j178/prek/pull/1034))\n- Add attrs to \"who is using prek\" ([#981](https://github.com/j178/prek/pull/981))\n\n### Contributors\n\n- @my1e5\n- @j178\n- @zzstoatzz\n- @lmmx\n- @feliblo\n- @yihong0618\n- @st1971\n- @is-alnilam\n\n## 0.2.12\n\nReleased on 2025-10-27.\n\n### Enhancements\n\n- Add a warning for unimplemented hooks ([#976](https://github.com/j178/prek/pull/976))\n- Allow using system trusted store by `PREK_NATIVE_TLS` ([#959](https://github.com/j178/prek/pull/959))\n\n### Bug fixes\n\n- Do not check for `script` subprocess status ([#964](https://github.com/j178/prek/pull/964))\n- Fix compatibility with older luarocks ([#967](https://github.com/j178/prek/pull/967))\n- Fix local relative path in `try-repo` ([#975](https://github.com/j178/prek/pull/975))\n\n### Documentation\n\n- Update language support status ([#970](https://github.com/j178/prek/pull/970))\n\n### Contributors\n\n- @yihong0618\n- @st1971\n- @j178\n\n## 0.2.11\n\nReleased on 2025-10-24.\n\n### Enhancements\n\n- Support `language: lua` hooks ([#954](https://github.com/j178/prek/pull/954))\n- Support `language_version: system` ([#949](https://github.com/j178/prek/pull/949))\n- Implement `no-commit-to-branch` as builtin hook ([#930](https://github.com/j178/prek/pull/930))\n- Improve styling for stashing error message ([#953](https://github.com/j178/prek/pull/953))\n- Support nix-shell style shebang ([#929](https://github.com/j178/prek/pull/929))\n\n### Documentation\n\n- Add a page about \"Quick start\" ([#934](https://github.com/j178/prek/pull/934))\n- Add kreuzberg to \"who is using prek\" ([#936](https://github.com/j178/prek/pull/936))\n- Clarify minimum mise version required to use `mise use prek` ([#931](https://github.com/j178/prek/pull/931))\n\n### Contributors\n\n- @fllesser\n- @j178\n\n## 0.2.10\n\nReleased on 2025-10-18.\n\n### Enhancements\n\n- Add `--fail-fast` CLI flag to stop after first hook failure ([#908](https://github.com/j178/prek/pull/908))\n- Add collision detection for hook env directories ([#914](https://github.com/j178/prek/pull/914))\n- Error out if not projects found ([#913](https://github.com/j178/prek/pull/913))\n- Implement `check-xml` as builtin hook ([#894](https://github.com/j178/prek/pull/894))\n- Implement `check-merge-conflict` as builtin hook ([#885](https://github.com/j178/prek/pull/885))\n- Use line-by-line reading in `check-merge-conflict` ([#910](https://github.com/j178/prek/pull/910))\n\n### Bug fixes\n\n- Fix pygrep hook env health check ([#921](https://github.com/j178/prek/pull/921))\n- Group `pygrep` with `python` when installing pygrep hooks ([#920](https://github.com/j178/prek/pull/920))\n- Ignore `.` prefixed directory when searching managed Python for pygrep ([#919](https://github.com/j178/prek/pull/919))\n\n### Documentation\n\n- Add contribution guide ([#912](https://github.com/j178/prek/pull/912))\n\n### Other changes\n\n### Contributors\n\n- @AdityasWorks\n- @j178\n- @kenwoodjw\n- @lmmx\n\n## 0.2.9\n\nReleased on 2025-10-16.\n\n### Enhancements\n\n- Lazily check hook env health ([#897](https://github.com/j178/prek/pull/897))\n- Implement `check-symlinks` as builtin hook ([#895](https://github.com/j178/prek/pull/895))\n- Implement `detect-private-key` as builtin hook ([#893](https://github.com/j178/prek/pull/893))\n\n### Bug fixes\n\n- Download files to scratch directory to avoid cross-filesystem rename ([#889](https://github.com/j178/prek/pull/889))\n- Fix golang hook install local dependencies ([#902](https://github.com/j178/prek/pull/902))\n- Ignore the user-set `UV_MANAGED_PYTHON` ([#900](https://github.com/j178/prek/pull/900))\n\n### Other changes\n\n- Add package metadata for cargo-binstall ([#882](https://github.com/j178/prek/pull/882))\n\n### Contributors\n\n- @j178\n- @lmmx\n\n## 0.2.8\n\nReleased on 2025-10-14.\n\n*This is a re-release of 0.2.6 that fixes an issue where publishing to npmjs.com failed.*\n\n### Enhancements\n\n- Publish prek to npmjs.com ([#819](https://github.com/j178/prek/pull/819))\n- Support YAML merge keys in `.pre-commit-config.yaml` ([#871](https://github.com/j178/prek/pull/871))\n\n### Bug fixes\n\n- Use relative path with `--cd` in the generated hook script ([#868](https://github.com/j178/prek/pull/868))\n- Fix autoupdate `rev` rendering for \"float-like\" version numbers ([#867](https://github.com/j178/prek/pull/867))\n\n### Documentation\n\n- Add Nix and Conda installation details ([#874](https://github.com/j178/prek/pull/874))\n\n### Contributors\n\n- @mondeja\n- @j178\n- @bbannier\n- @yihong0618\n- @colindean\n\n## 0.2.5\n\nReleased on 2025-10-10.\n\n### Enhancements\n\n- Implement `prek try-repo` ([#797](https://github.com/j178/prek/pull/797))\n- Add fallback mechanism for prek executable in git hooks ([#850](https://github.com/j178/prek/pull/850))\n- Ignore config error if the directory is skipped ([#860](https://github.com/j178/prek/pull/860))\n\n### Bug fixes\n\n- Fix panic when parse config failed ([#859](https://github.com/j178/prek/pull/859))\n\n### Other changes\n\n- Add a Dockerfile ([#852](https://github.com/j178/prek/pull/852))\n\n### Contributors\n\n- @j178\n- @luizvbo\n\n## 0.2.4\n\nReleased on 2025-10-07.\n\n### Enhancements\n\n- Add support for `.prekignore` to ignore directories from project discovery ([#826](https://github.com/j178/prek/pull/826))\n- Make `prek auto-update --jobs` default to 0 (which uses max available parallelism) ([#833](https://github.com/j178/prek/pull/833))\n- Improve install message when installing for a subproject ([#847](https://github.com/j178/prek/pull/847))\n\n### Bug fixes\n\n- Convert extension to lowercase before checking file tags ([#839](https://github.com/j178/prek/pull/839))\n- Support pass multiple files like `prek run --files a b c d` ([#828](https://github.com/j178/prek/pull/828))\n\n### Documentation\n\n- Add requests-cache to \"Who is using prek\" ([#824](https://github.com/j178/prek/pull/824))\n\n### Contributors\n\n- @SigureMo\n- @j178\n\n## 0.2.3\n\nReleased on 2025-09-29.\n\n### Enhancements\n\n- Add `--dry-run` to `prek auto-update` ([#806](https://github.com/j178/prek/pull/806))\n- Add a global `--log-file` flag to specify the log file path ([#817](https://github.com/j178/prek/pull/817))\n- Implement hook health check ([#798](https://github.com/j178/prek/pull/798))\n- Show error message in quiet mode ([#807](https://github.com/j178/prek/pull/807))\n\n### Bug fixes\n\n- Write `fail` entry into output directly ([#811](https://github.com/j178/prek/pull/811))\n\n### Documentation\n\n- Update docs about uv in prek ([#810](https://github.com/j178/prek/pull/810))\n\n### Other changes\n\n- Add a security policy for reporting vulnerabilities ([#804](https://github.com/j178/prek/pull/804))\n\n### Contributors\n\n- @mondeja\n- @j178\n\n## 0.2.2\n\nReleased on 2025-09-26.\n\n### Enhancements\n\n- Add `prek cache dir`, move `prek gc` and `prek clean` under `prek cache` ([#795](https://github.com/j178/prek/pull/795))\n- Add a hint when hooks failed in CI ([#800](https://github.com/j178/prek/pull/800))\n- Add support for specifying `PREK_UV_SOURCE` ([#766](https://github.com/j178/prek/pull/766))\n- Run docker container with `--init` ([#791](https://github.com/j178/prek/pull/791))\n- Support `--allow-multiple-documents` for `check-yaml` ([#790](https://github.com/j178/prek/pull/790))\n\n### Bug fixes\n\n- Fix interpreter identification ([#801](https://github.com/j178/prek/pull/801))\n\n### Documentation\n\n- Add PaperQA2 to \"Who is using prek\" ([#793](https://github.com/j178/prek/pull/793))\n- Clarify built-in hooks activation conditions and behavior ([#781](https://github.com/j178/prek/pull/781))\n- Deduplicate docs between README and MkDocs site ([#792](https://github.com/j178/prek/pull/792))\n- Mention `j178/prek-action` in docs ([#753](https://github.com/j178/prek/pull/753))\n\n### Other Changes\n\n- Bump `pre-commit-hooks` in sample-config to v6.0.0 ([#761](https://github.com/j178/prek/pull/761))\n- Improve arg parsing for builtin hooks ([#789](https://github.com/j178/prek/pull/789))\n\n### Contributors\n\n- @mondeja\n- @akx\n- @bxb100\n- @j178\n- @onerandomusername\n\n## 0.2.1\n\n### Enhancements\n\n- auto-update: prefer tags that are most similar to the current version ([#719](https://github.com/j178/prek/pull/719))\n\n### Bug fixes\n\n- Fix `git --no-pager diff` command syntax upon failures ([#746](https://github.com/j178/prek/pull/746))\n- Clean working tree of current workspace only ([#747](https://github.com/j178/prek/pull/747))\n- Use concurrent read and write in `git check-attr` ([#731](https://github.com/j178/prek/pull/731))\n\n### Documentation\n\n- Fix typo in language-version to language_version ([#727](https://github.com/j178/prek/pull/727))\n- Update benchmarks ([#728](https://github.com/j178/prek/pull/728))\n\n### Contributors\n\n- @j178\n- @matthiask\n- @AdrianDC\n- @onerandomusername\n\n## 0.2.0\n\nThis is a huge milestone release that introduces **Workspace Mode** — first‑class monorepo support.\n\n`prek` now allows you to manage multiple projects with their own `.pre-commit-config.yaml` within a single repository.\nIt auto‑discovers nested projects, runs hooks in project scope, and provides flexible selectors to target specific projects and hooks.\nThis makes `prek` a powerful tool for managing pre-commit hooks in complex repository structures.\n\nFor 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).\n\n**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.\n\nSpecial thanks to @potiuk for all the help and feedback in designing and testing this feature!\n\nFor 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).\n\n### Enhancements\n\n- Fix parsing of tag describe for prerelease versions ([#714](https://github.com/j178/prek/pull/714))\n- Truncate log file each time ([#717](https://github.com/j178/prek/pull/717))\n\n### Performance\n\n- Enable more aggressive optimizations for release ([#724](https://github.com/j178/prek/pull/724))\n- Speed up check_toml ([#713](https://github.com/j178/prek/pull/713))\n\n### Bug fixes\n\n- Fix hook-impl don't run hooks when specified allow missing config ([#716](https://github.com/j178/prek/pull/716))\n- fix: support py38 for pygrep ([#723](https://github.com/j178/prek/pull/723))\n\n### Other changes\n\n- Fix installation on fish and with missing tags ([#721](https://github.com/j178/prek/pull/721))\n\n### Contributors\n\n- @onerandomusername\n- @kushudai\n- @j178\n\n## 0.2.0a5\n\n### Enhancements\n\n- Add built in byte-order-marker fixer ([#700](https://github.com/j178/prek/pull/700))\n- Use bigger buffer for fixing trailing whitespace ([#705](https://github.com/j178/prek/pull/705))\n\n### Bug fixes\n\n- Fix `trailing-whitespace` & `mixed-line-ending` write file path ([#708](https://github.com/j178/prek/pull/708))\n- Fix file path handling for meta hooks in workspace mode ([#699](https://github.com/j178/prek/pull/699))\n\n### Documentation\n\n- Add docs about configuration ([#703](https://github.com/j178/prek/pull/703))\n- Add docs about debugging ([#702](https://github.com/j178/prek/pull/702))\n- Generate cli reference ([#707](https://github.com/j178/prek/pull/707))\n\n### Contributors\n\n- @kushudai\n- @j178\n\n## 0.2.0a4\n\n### Enhancements\n\n- Bring back `.pre-commit-config.yml` support ([#676](https://github.com/j178/prek/pull/676))\n- Ignore config file from hidden directory ([#677](https://github.com/j178/prek/pull/677))\n- Support selectors in `prek install/install-hooks/hook-impl` ([#683](https://github.com/j178/prek/pull/683))\n\n### Bug fixes\n\n- Do not set GOROOT for system install Go when running go hooks ([#694](https://github.com/j178/prek/pull/694))\n- Fix `check_toml` and `check_yaml` in workspace mode ([#688](https://github.com/j178/prek/pull/688))\n\n### Documentation\n\n- Add docs about TODOs ([#679](https://github.com/j178/prek/pull/679))\n- Add docs about builtin hooks ([#678](https://github.com/j178/prek/pull/678))\n\n### Other changes\n\n- docs(manifest): Correctly specify metadata for all packages ([#687](https://github.com/j178/prek/pull/687))\n- refactor(cli): Clean up usage of clap ([#689](https://github.com/j178/prek/pull/689))\n\n### Contributors\n\n- @j178\n- @epage\n- @aravindan888\n\n## 0.2.0a3\n\n### Enhancements\n\n- Add a warning to `hook-impl` when the script needs reinstall ([#647](https://github.com/j178/prek/pull/647))\n\n### Documentation\n\n- Add a notice to rerun `prek install` when upgrading to 0.2.0 ([#646](https://github.com/j178/prek/pull/646))\n\n### Contributors\n\n- @j178\n\n## 0.2.0-alpha.2\n\n*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.*\n\nThis is a huge milestone release that introduces **Workspace Mode** — first‑class monorepo support.\n\n`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.\n\n**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.\n\nFor 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).\n\nSpecial thanks to @potiuk for all the help and feedback in designing and testing this feature!\n\n### Enhancements\n\n- Support multiple `.pre-commit-config.yaml` in a workspace (monorepo mode) ([#583](https://github.com/j178/prek/pull/583))\n- Implement project and hook selector ([#623](https://github.com/j178/prek/pull/623))\n- Add `prek run --cd <dir>` to change directory before running ([#581](https://github.com/j178/prek/pull/581))\n- Support `prek list` in workspace mode ([#586](https://github.com/j178/prek/pull/586))\n- Support `prek install|install-hooks|hook-impl|init-template-dir` in workspace mode ([#595](https://github.com/j178/prek/pull/595))\n- Implement `auto-update` in workspace mode ([#605](https://github.com/j178/prek/pull/605))\n- Implement selector completion in workspace mode ([#639](https://github.com/j178/prek/pull/639))\n- Simplify `auto-update` implementation ([#608](https://github.com/j178/prek/pull/608))\n- Add a `--dry-run` flag to `prek run` ([#622](https://github.com/j178/prek/pull/622))\n- Cache workspace discovery result ([#636](https://github.com/j178/prek/pull/636))\n- Fix local script hook entry path in workspace mode ([#603](https://github.com/j178/prek/pull/603))\n- Fix `hook-impl` allow missing config ([#600](https://github.com/j178/prek/pull/600))\n- Fix docker mount in workspace mode ([#638](https://github.com/j178/prek/pull/638))\n- Show project line when project is not root ([#637](https://github.com/j178/prek/pull/637))\n\n### Documentation\n\n- Publish docs to `https://prek.j178.dev` ([#627](https://github.com/j178/prek/pull/627))\n- Improve workspace docs about skips rule ([#615](https://github.com/j178/prek/pull/615))\n- Add an full example and update docs ([#582](https://github.com/j178/prek/pull/582))\n\n### Other changes\n\n- Docs: `.pre-commit-config.yml` support has been removed ([#630](https://github.com/j178/prek/pull/630))\n- Enable publishing prereleases ([#641](https://github.com/j178/prek/pull/641))\n\n### Contributors\n\n- [@luizvbo](https://github.com/luizvbo)\n- [@j178](https://github.com/j178)\n- [@hugovk](https://github.com/hugovk)\n\n## 0.1.6\n\n### Enhancements\n\n- Improve hook install concurrency ([#611](https://github.com/j178/prek/pull/611))\n- Parse JSON from slice ([#604](https://github.com/j178/prek/pull/604))\n\n### Bug fixes\n\n- Reuse hook env only for exactly same dependencies ([#609](https://github.com/j178/prek/pull/609))\n- Workaround checkout file failure on Windows ([#616](https://github.com/j178/prek/pull/616))\n\n## 0.1.5\n\n### Enhancements\n\n- Implement `pre-push` hook type ([#598](https://github.com/j178/prek/pull/598))\n- Implement `pre-commit-hooks:check_yaml` as builtin hook ([#557](https://github.com/j178/prek/pull/557))\n- Implement `pre-commit-hooks:check-toml` as builtin hook ([#564](https://github.com/j178/prek/pull/564))\n- Add validation for file type tags ([#565](https://github.com/j178/prek/pull/565))\n- Ignore NotFound error in extracting metadata log ([#597](https://github.com/j178/prek/pull/597))\n\n### Documentation\n\n- Update project status ([#578](https://github.com/j178/prek/pull/578))\n\n### Other changes\n\n- Bump tracing-subscriber to 0.3.20 ([#567](https://github.com/j178/prek/pull/567))\n- Remove color from trace log ([#580](https://github.com/j178/prek/pull/580))\n\n## 0.1.4\n\n### Enhancements\n\n- Improve docker image labels ([#551](https://github.com/j178/prek/pull/551))\n\n### Performance\n\n- Avoid unnecessary allocation in `run_by_batch` ([#549](https://github.com/j178/prek/pull/549))\n- Cache current docker container mounts ([#552](https://github.com/j178/prek/pull/552))\n\n### Bug fixes\n\n- Fix `trailing-whitespace` cannot handle file contains invalid utf-8 data ([#544](https://github.com/j178/prek/pull/544))\n- Fix trailing-whitespace eol trimming ([#546](https://github.com/j178/prek/pull/546))\n- Fix trailing-whitespace markdown eol trimming ([#547](https://github.com/j178/prek/pull/547))\n\n### Documentation\n\n- Add authlib to `Who are using prek` ([#550](https://github.com/j178/prek/pull/550))\n\n## 0.1.3\n\n### Enhancements\n\n- Support PEP 723 scripts for Python hooks ([#529](https://github.com/j178/prek/pull/529))\n\n### Bug fixes\n\n- Fix Python hook stderr are not captured ([#530](https://github.com/j178/prek/pull/530))\n\n### Other changes\n\n- Add an error context when reading manifest failed ([#527](https://github.com/j178/prek/pull/527))\n- Add a renovate rule to bump bundled uv version ([#528](https://github.com/j178/prek/pull/528))\n- Disable semantic commits for renovate PRs ([#538](https://github.com/j178/prek/pull/538))\n\n## 0.1.2\n\n### Enhancements\n\n- Add check for missing hooks in new revision ([#521](https://github.com/j178/prek/pull/521))\n\n### Bug fixes\n\n- Fix `language: script` entry join issue ([#525](https://github.com/j178/prek/pull/525))\n\n### Other changes\n\n- Add OpenLineage to prek users ([#523](https://github.com/j178/prek/pull/523))\n\n## 0.1.1\n\n### Breaking changes\n\n- Drop support `.yml` config file ([#493](https://github.com/j178/prek/pull/493))\n\n### Enhancements\n\n- Add moving rev warning ([#488](https://github.com/j178/prek/pull/488))\n- Implement `prek auto-update` ([#511](https://github.com/j178/prek/pull/511))\n- Support local path as a `repo` url ([#513](https://github.com/j178/prek/pull/513))\n\n### Bug fixes\n\n- Fix recursion limit when checking deeply nested json ([#507](https://github.com/j178/prek/pull/507))\n- Fix rename tempfile across device ([#508](https://github.com/j178/prek/pull/508))\n- Fix build on s390x ([#518](https://github.com/j178/prek/pull/518))\n\n### Other changes\n\n- docs: install prek with mise ([#510](https://github.com/j178/prek/pull/510))\n\n## 0.0.29\n\n### Enhancements\n\n- Build wheels for more platforms ([#489](https://github.com/j178/prek/pull/489))\n\n### Bug fixes\n\n- Fix `git commit -a` does not pick up staged files correctly ([#487](https://github.com/j178/prek/pull/487))\n\n## 0.0.28\n\n### Bug fixes\n\n- Fix `inde.lock file exists` error when running `git commit -p` or `git commit -a` ([#482](https://github.com/j178/prek/pull/482))\n- Various fixes to `init-templdate-dir` and directory related bug ([#484](https://github.com/j178/prek/pull/484))\n\n## 0.0.27\n\n### Enhancements\n\n- Clone repo temporarily into scratch directory ([#478](https://github.com/j178/prek/pull/478))\n- Don’t show the progress bar if there’s no need for cloning or installing hooks ([#477](https://github.com/j178/prek/pull/477))\n- Support `language_version: lts` for node ([#473](https://github.com/j178/prek/pull/473))\n\n### Bug fixes\n\n- Adjust `sample-config` file path before writing ([#474](https://github.com/j178/prek/pull/474))\n- Resolve script shebang before running ([#475](https://github.com/j178/prek/pull/475))\n\n## 0.0.26\n\n### Enhancements\n\n- Disable `prek self update` for package managers ([#468](https://github.com/j178/prek/pull/468))\n- Download uv from github releases directly ([#464](https://github.com/j178/prek/pull/464))\n- Find `uv` alongside the `prek` binary ([#466](https://github.com/j178/prek/pull/466))\n- Run hooks with pty if color enabled ([#471](https://github.com/j178/prek/pull/471))\n- Warn unexpected keys in config ([#463](https://github.com/j178/prek/pull/463))\n\n### Bug fixes\n\n- Canonicalize prek executable path ([#467](https://github.com/j178/prek/pull/467))\n\n### Documentation\n\n- Add \"Who are using prek\" to README ([#458](https://github.com/j178/prek/pull/458))\n\n## 0.0.25\n\n### Enhancements\n\n- Add check for `minimum_prek_version` ([#437](https://github.com/j178/prefligit/pull/437))\n- Make `--to-ref` default to HEAD if `--from-ref` is specified ([#426](https://github.com/j178/prefligit/pull/426))\n- Support downloading uv from pypi and mirrors ([#449](https://github.com/j178/prefligit/pull/449))\n- Write trace log to `$PREK_HOME/prek.log` ([#447](https://github.com/j178/prefligit/pull/447))\n- Implement `mixed_line_ending` as builtin hook ([#444](https://github.com/j178/prefligit/pull/444))\n- Support `--output-format=json` in `prek list` ([#446](https://github.com/j178/prefligit/pull/446))\n- Add context message to install error ([#455](https://github.com/j178/prefligit/pull/455))\n- Add warning for non-existent hook id ([#450](https://github.com/j178/prefligit/pull/450))\n\n### Performance\n\n- Refactor `fix_trailing_whitespace` ([#411](https://github.com/j178/prefligit/pull/411))\n\n### Bug fixes\n\n- Calculate more accurate max cli length ([#442](https://github.com/j178/prefligit/pull/442))\n- Fix uv install on Windows ([#453](https://github.com/j178/prefligit/pull/453))\n- Static link `liblzma` ([#445](https://github.com/j178/prefligit/pull/445))\n\n## 0.0.24\n\n### Enhancements\n\n- Add dynamic completion of hook ids ([#380](https://github.com/j178/prek/pull/380))\n- Implement `prek list` to list available hooks ([#424](https://github.com/j178/prek/pull/424))\n- Implement `pygrep` language support ([#383](https://github.com/j178/prek/pull/383))\n- Support `prek run` multiple hooks ([#423](https://github.com/j178/prek/pull/423))\n- Implement `check_json` as builtin hook ([#416](https://github.com/j178/prek/pull/416))\n\n### Performance\n\n- 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))\n\n### Bug fixes\n\n- Do not set `GOROOT` and `GOPATH` for system found go ([#415](https://github.com/j178/prek/pull/415))\n\n### Documentation\n\n- Use `brew install j178/tap/prek` for now ([#420](https://github.com/j178/prek/pull/420))\n- chore: logo rebranded, Update README.md ([#408](https://github.com/j178/prek/pull/408))\n\n## 0.0.23\n\n### Breaking changes\n\nIn this release, we've renamed the project to `prek` from `prefligit`. It's shorter so easier to type, and it avoids typosquatting with `preflight`.\n\nThis means that the command-line name is now `prek`, and the PyPI package is now listed as [`prek`](https://pypi.org/project/prek/).\nAnd the Homebrew will be updated to `prek` as well.\n\nAnd previously, the cache directory was `~/.cache/prefligit`, now it is `~/.cache/prek`.\nYou'd have to delete the old cache directory manually, or run `prefligit clean` to clean it up.\n\nThen uninstall the old `prefligit` and install the new `prek` from scratch.\n\n### Enhancements\n\n- Relax uv version check range ([#396](https://github.com/j178/prefligit/pull/396))\n\n### Bug fixes\n\n- Fix `script` command path ([#398](https://github.com/j178/prefligit/pull/398))\n- Fix meta hook `check_useless_excludes` ([#401](https://github.com/j178/prefligit/pull/401))\n\n### Other changes\n\n- Rename to `prek` from `prefligit` ([#402](https://github.com/j178/prefligit/pull/402))\n\n## 0.0.22\n\n### Enhancements\n\n- Add value hint to `prefligit run` flags ([#373](https://github.com/j178/prefligit/pull/373))\n- Check minimum supported version for uv found from system ([#352](https://github.com/j178/prefligit/pull/352))\n\n### Bug fixes\n\n- Fix `check_added_large_files` parameter name ([#389](https://github.com/j178/prefligit/pull/389))\n- Fix `npm install` on Windows ([#374](https://github.com/j178/prefligit/pull/374))\n- Fix docker mount options ([#377](https://github.com/j178/prefligit/pull/377))\n- Fix identify tags for `Pipfile.lock` ([#391](https://github.com/j178/prefligit/pull/391))\n- Fix identifying symlinks ([#378](https://github.com/j178/prefligit/pull/378))\n- Set `GOROOT` when installing golang hook ([#381](https://github.com/j178/prefligit/pull/381))\n\n### Other changes\n\n- Add devcontainer config ([#379](https://github.com/j178/prefligit/pull/379))\n- Bump rust toolchain to 1.89 ([#386](https://github.com/j178/prefligit/pull/386))\n\n## 0.0.21\n\n### Enhancements\n\n- Add `--directory` to `prefligit run` ([#358](https://github.com/j178/prefligit/pull/358))\n- Implement `tags_from_interpreter` ([#362](https://github.com/j178/prefligit/pull/362))\n- Set GOBIN to `<hook-env>/bin`, set GOPATH to `$PREGLIGIT_HOME/cache/go` ([#369](https://github.com/j178/prefligit/pull/369))\n\n### Performance\n\n- Make Partitions iterator produce slice instead of Vec ([#361](https://github.com/j178/prefligit/pull/361))\n- Use `rustc_hash` ([#359](https://github.com/j178/prefligit/pull/359))\n\n### Bug fixes\n\n- Add `node` to PATH when running `npm` ([#371](https://github.com/j178/prefligit/pull/371))\n- Fix bug that default hook stage should be pre-commit ([#367](https://github.com/j178/prefligit/pull/367))\n- Fix cache dir permission before clean ([#368](https://github.com/j178/prefligit/pull/368))\n\n### Other changes\n\n- Move `Project` into `workspace` module ([#364](https://github.com/j178/prefligit/pull/364))\n\n## 0.0.20\n\n### Enhancements\n\n- Support golang hooks and golang toolchain management ([#355](https://github.com/j178/prefligit/pull/355))\n- Add `--last-commit` flag to `prefligit run` ([#351](https://github.com/j178/prefligit/pull/351))\n\n### Bug fixes\n\n- Fix bug that directories are ignored ([#350](https://github.com/j178/prefligit/pull/350))\n- Use `git ls-remote` to fetch go releases ([#356](https://github.com/j178/prefligit/pull/356))\n\n### Documentation\n\n- Add migration section to README ([#354](https://github.com/j178/prefligit/pull/354))\n\n## 0.0.19\n\n### Enhancements\n\n- Improve node support ([#346](https://github.com/j178/prefligit/pull/346))\n- Manage uv cache dir ([#345](https://github.com/j178/prefligit/pull/345))\n\n### Bug fixes\n\n- Add `--install-links` to `npm install` ([#347](https://github.com/j178/prefligit/pull/347))\n- Fix large file check to use staged_get instead of intent_add ([#332](https://github.com/j178/prefligit/pull/332))\n\n## 0.0.18\n\n### Enhancements\n\n- Impl `FromStr` for language request ([#338](https://github.com/j178/prefligit/pull/338))\n\n### Performance\n\n- Use DFS to find connected components in hook dependencies ([#341](https://github.com/j178/prefligit/pull/341))\n- Use more `Arc<T>` over `Box<T>` ([#333](https://github.com/j178/prefligit/pull/333))\n\n### Bug fixes\n\n- Fix node path match, add tests ([#339](https://github.com/j178/prefligit/pull/339))\n- Skipped hook name should be taken into account for columns ([#335](https://github.com/j178/prefligit/pull/335))\n\n### Documentation\n\n- Add benchmarks ([#342](https://github.com/j178/prefligit/pull/342))\n- Update docs ([#337](https://github.com/j178/prefligit/pull/337))\n\n## 0.0.17\n\n### Enhancements\n\n- Add `sample-config --file` to write sample config to file ([#313](https://github.com/j178/prefligit/pull/313))\n- Cache computed `dependencies` on hook ([#319](https://github.com/j178/prefligit/pull/319))\n- Cache the found path to uv ([#323](https://github.com/j178/prefligit/pull/323))\n- Improve `sample-config` writing file ([#314](https://github.com/j178/prefligit/pull/314))\n- Reimplement find matching env logic ([#327](https://github.com/j178/prefligit/pull/327))\n\n### Bug fixes\n\n- Fix issue that `entry` of `pygrep` is not shell commands ([#316](https://github.com/j178/prefligit/pull/316))\n- Support `python311` as a valid language version ([#321](https://github.com/j178/prefligit/pull/321))\n\n### Other changes\n\n- Bump cargo-dist to 0.29.0 ([#322](https://github.com/j178/prefligit/pull/322))\n- Update DIFF.md ([#318](https://github.com/j178/prefligit/pull/318))\n\n## 0.0.16\n\n### Enhancements\n\n- Improve error message for hook ([#308](https://github.com/j178/prefligit/pull/308))\n- Improve error message for hook installation and run ([#310](https://github.com/j178/prefligit/pull/310))\n- Improve hook invalid error message ([#307](https://github.com/j178/prefligit/pull/307))\n- Parse `entry` when constructing hook ([#306](https://github.com/j178/prefligit/pull/306))\n- Rename `autoupdate` to `auto-update`, `init-templatedir` to `init-template-dir` ([#302](https://github.com/j178/prefligit/pull/302))\n\n### Bug fixes\n\n- Fix `end-of-file-fixer` replaces `\\r\\n` with `\\n` ([#311](https://github.com/j178/prefligit/pull/311))\n\n## 0.0.15\n\nIn this release, `language: node` hooks are fully supported now (finally)!.\nGive it a try and let us know if you run into any issues!\n\n### Enhancements\n\n- Support `nodejs` language hook ([#298](https://github.com/j178/prefligit/pull/298))\n- Show unimplemented message earlier ([#296](https://github.com/j178/prefligit/pull/296))\n- Simplify npm installing dependencies ([#299](https://github.com/j178/prefligit/pull/299))\n\n### Documentation\n\n- Update readme ([#300](https://github.com/j178/prefligit/pull/300))\n\n## 0.0.14\n\n### Enhancements\n\n- Show unimplemented status instead of panic ([#290](https://github.com/j178/prefligit/pull/290))\n- Try default uv managed python first, fallback to download ([#291](https://github.com/j178/prefligit/pull/291))\n\n### Other changes\n\n- Update Rust crate fancy-regex to 0.16.0 ([#286](https://github.com/j178/prefligit/pull/286))\n- Update Rust crate indicatif to 0.18.0 ([#287](https://github.com/j178/prefligit/pull/287))\n- Update Rust crate pprof to 0.15.0 ([#288](https://github.com/j178/prefligit/pull/288))\n- Update Rust crate serde_json to v1.0.142 ([#285](https://github.com/j178/prefligit/pull/285))\n- Update astral-sh/setup-uv action to v6 ([#289](https://github.com/j178/prefligit/pull/289))\n\n## 0.0.13\n\n### Enhancements\n\n- Add `PREFLIGIT_NO_FAST_PATH` to disable Rust fast path ([#272](https://github.com/j178/prefligit/pull/272))\n- Improve subprocess error message ([#276](https://github.com/j178/prefligit/pull/276))\n- Remove `LanguagePreference` and improve language check ([#277](https://github.com/j178/prefligit/pull/277))\n- Support downloading requested Python version automatically ([#281](https://github.com/j178/prefligit/pull/281))\n- Implement language specific version parsing ([#273](https://github.com/j178/prefligit/pull/273))\n\n### Bug fixes\n\n- Fix python version matching ([#275](https://github.com/j178/prefligit/pull/275))\n- Show progress bar in verbose mode ([#278](https://github.com/j178/prefligit/pull/278))\n\n## 0.0.12\n\n### Bug fixes\n\n- Ignore `config not staged` error for config outside the repo ([#270](https://github.com/j178/prefligit/pull/270))\n\n### Other changes\n\n- Add test fixture files ([#266](https://github.com/j178/prefligit/pull/266))\n- Use `sync_all` over `flush` ([#269](https://github.com/j178/prefligit/pull/269))\n\n## 0.0.11\n\n### Enhancements\n\n- Support reading `.pre-commit-config.yml` as well ([#213](https://github.com/j178/prefligit/pull/213))\n- Refactor language version resolution and hook install dir ([#221](https://github.com/j178/prefligit/pull/221))\n- Implement `prefligit install-hooks` command ([#258](https://github.com/j178/prefligit/pull/258))\n- Implement `pre-commit-hooks:end-of-file-fixer` hook ([#255](https://github.com/j178/prefligit/pull/255))\n- Implement `pre-commit-hooks:check_added_large_files` hook ([#219](https://github.com/j178/prefligit/pull/219))\n- Implement `script` language hooks ([#252](https://github.com/j178/prefligit/pull/252))\n- Implement node.js installer ([#152](https://github.com/j178/prefligit/pull/152))\n- Use `-v` to show only verbose message, `-vv` show debug log, `-vvv` show trace log ([#211](https://github.com/j178/prefligit/pull/211))\n- Write `.prefligit-repo.json` inside cloned repo ([#225](https://github.com/j178/prefligit/pull/225))\n- Add language name to 'not yet implemented' messages ([#251](https://github.com/j178/prefligit/pull/251))\n\n### Bug fixes\n\n- Do not install if no additional dependencies for local python hook ([#195](https://github.com/j178/prefligit/pull/195))\n- Ensure flushing log file ([#261](https://github.com/j178/prefligit/pull/261))\n- Fix zip deflate ([#194](https://github.com/j178/prefligit/pull/194))\n\n### Other changes\n\n- Bump to Rust 1.88 and `cargo update` ([#254](https://github.com/j178/prefligit/pull/254))\n- Upgrade to Rust 2024 edition ([#196](https://github.com/j178/prefligit/pull/196))\n- Bump uv version ([#260](https://github.com/j178/prefligit/pull/260))\n- Simplify archive extraction implementation ([#193](https://github.com/j178/prefligit/pull/193))\n- Use `astral-sh/rs-async-zip` ([#259](https://github.com/j178/prefligit/pull/259))\n- Use `ubuntu-latest` for release action ([#216](https://github.com/j178/prefligit/pull/216))\n- Use async closure ([#200](https://github.com/j178/prefligit/pull/200))\n\n## 0.0.10\n\n### Breaking changes\n\n**Warning**: This release changed the store layout, it's recommended to delete the old store and install from scratch.\n\nTo delete the old store, run:\n\n```sh\nrm -rf ~/.cache/prefligit\n```\n\n### Enhancements\n\n- Restructure store folders layout ([#181](https://github.com/j178/prefligit/pull/181))\n- Fallback some env vars to to pre-commit ([#175](https://github.com/j178/prefligit/pull/175))\n- Save patches to `$PREFLIGIT_HOME/patches` ([#182](https://github.com/j178/prefligit/pull/182))\n\n### Bug fixes\n\n- Fix removing git env vars ([#176](https://github.com/j178/prefligit/pull/176))\n- Fix typo in Cargo.toml ([#160](https://github.com/j178/prefligit/pull/160))\n\n### Other changes\n\n- Do not publish to crates.io ([#191](https://github.com/j178/prefligit/pull/191))\n- Bump cargo-dist to v0.28.0 ([#170](https://github.com/j178/prefligit/pull/170))\n- Bump uv version to 0.6.0 ([#184](https://github.com/j178/prefligit/pull/184))\n- Configure Renovate ([#168](https://github.com/j178/prefligit/pull/168))\n- Format sample config output ([#172](https://github.com/j178/prefligit/pull/172))\n- Make env vars a shareable crate ([#171](https://github.com/j178/prefligit/pull/171))\n- Reduce String alloc ([#166](https://github.com/j178/prefligit/pull/166))\n- Skip common git flags in command trace log ([#162](https://github.com/j178/prefligit/pull/162))\n- Update Rust crate clap to v4.5.29 ([#173](https://github.com/j178/prefligit/pull/173))\n- Update Rust crate which to v7.0.2 ([#163](https://github.com/j178/prefligit/pull/163))\n- Update astral-sh/setup-uv action to v5 ([#164](https://github.com/j178/prefligit/pull/164))\n- Upgrade Rust to 1.84 and upgrade dependencies ([#161](https://github.com/j178/prefligit/pull/161))\n\n## 0.0.9\n\nDue to a mistake in the release process, this release is skipped.\n\n## 0.0.8\n\n### Enhancements\n\n- Move home dir to `~/.cache/prefligit` ([#154](https://github.com/j178/prefligit/pull/154))\n- Implement trailing-whitespace in Rust ([#137](https://github.com/j178/prefligit/pull/137))\n- Limit hook install concurrency ([#145](https://github.com/j178/prefligit/pull/145))\n- Simplify language default version implementation ([#150](https://github.com/j178/prefligit/pull/150))\n- Support install uv from pypi ([#149](https://github.com/j178/prefligit/pull/149))\n- Add executing command to error message ([#141](https://github.com/j178/prefligit/pull/141))\n\n### Bug fixes\n\n- Use hook `args` in fast path ([#139](https://github.com/j178/prefligit/pull/139))\n\n### Other changes\n\n- Remove hook install_key ([#153](https://github.com/j178/prefligit/pull/153))\n- Remove pyvenv.cfg patch ([#156](https://github.com/j178/prefligit/pull/156))\n- Try to use D drive on Windows CI ([#157](https://github.com/j178/prefligit/pull/157))\n- Tweak trailing-whitespace-fixer ([#140](https://github.com/j178/prefligit/pull/140))\n- Upgrade dist to v0.27.0 ([#158](https://github.com/j178/prefligit/pull/158))\n- Uv install python into tools path ([#151](https://github.com/j178/prefligit/pull/151))\n\n## 0.0.7\n\n### Enhancements\n\n- Add progress bar for hook init and install ([#122](https://github.com/j178/prefligit/pull/122))\n- Add color to command help ([#131](https://github.com/j178/prefligit/pull/131))\n- Add commit info to version display ([#130](https://github.com/j178/prefligit/pull/130))\n- Support meta hooks reading ([#134](https://github.com/j178/prefligit/pull/134))\n- Implement meta hooks ([#135](https://github.com/j178/prefligit/pull/135))\n\n### Bug fixes\n\n- Fix same repo clone multiple times ([#125](https://github.com/j178/prefligit/pull/125))\n- Fix logging level after renaming ([#119](https://github.com/j178/prefligit/pull/119))\n- Fix version tag distance ([#132](https://github.com/j178/prefligit/pull/132))\n\n### Other changes\n\n- Disable uv cache on Windows ([#127](https://github.com/j178/prefligit/pull/127))\n- Impl Eq and Hash for ConfigRemoteRepo ([#126](https://github.com/j178/prefligit/pull/126))\n- Make `pass_env_vars` runs on Windows ([#133](https://github.com/j178/prefligit/pull/133))\n- Run cargo update ([#129](https://github.com/j178/prefligit/pull/129))\n- Update Readme ([#128](https://github.com/j178/prefligit/pull/128))\n\n## 0.0.6\n\n### Breaking changes\n\nIn 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.\n\n- The command-line name is now `prefligit`. We suggest uninstalling any previous version of `pre-commit-rs` and installing `prefligit` from scratch.\n- The PyPI package is now listed as [`prefligit`](https://pypi.org/project/prefligit/).\n- The Cargo package is also now [`prefligit`](https://crates.io/crates/prefligit).\n- The Homebrew formula has been updated to `prefligit`.\n\n### Enhancements\n\n- Support `docker_image` language ([#113](https://github.com/j178/pre-commit-rs/pull/113))\n- Support `init-templatedir` subcommand ([#101](https://github.com/j178/pre-commit-rs/pull/101))\n- Implement get filenames from merge conflicts ([#103](https://github.com/j178/pre-commit-rs/pull/103))\n\n### Bug fixes\n\n- Fix `prefligit install --hook-type` name ([#102](https://github.com/j178/pre-commit-rs/pull/102))\n\n### Other changes\n\n- Apply color option to log ([#100](https://github.com/j178/pre-commit-rs/pull/100))\n- Improve tests ([#106](https://github.com/j178/pre-commit-rs/pull/106))\n- Remove intermedia Language enum ([#107](https://github.com/j178/pre-commit-rs/pull/107))\n- Run `cargo clippy` in the dev drive workspace ([#115](https://github.com/j178/pre-commit-rs/pull/115))\n\n## 0.0.5\n\n### Enhancements\n\nv0.0.4 release process was broken, so this release is a actually a re-release of v0.0.4.\n\n- Improve subprocess trace and error output ([#92](https://github.com/j178/pre-commit-rs/pull/92))\n- Stash working tree before running hooks ([#96](https://github.com/j178/pre-commit-rs/pull/96))\n- Add color to command trace ([#94](https://github.com/j178/pre-commit-rs/pull/94))\n- Improve hook output display ([#79](https://github.com/j178/pre-commit-rs/pull/79))\n- Improve uv installation ([#78](https://github.com/j178/pre-commit-rs/pull/78))\n- Support docker language ([#67](https://github.com/j178/pre-commit-rs/pull/67))\n\n## 0.0.4\n\n### Enhancements\n\n- Improve subprocess trace and error output ([#92](https://github.com/j178/pre-commit-rs/pull/92))\n- Stash working tree before running hooks ([#96](https://github.com/j178/pre-commit-rs/pull/96))\n- Add color to command trace ([#94](https://github.com/j178/pre-commit-rs/pull/94))\n- Improve hook output display ([#79](https://github.com/j178/pre-commit-rs/pull/79))\n- Improve uv installation ([#78](https://github.com/j178/pre-commit-rs/pull/78))\n- Support docker language ([#67](https://github.com/j178/pre-commit-rs/pull/67))\n\n## 0.0.3\n\n### Bug fixes\n\n- Check uv installed after acquired lock ([#72](https://github.com/j178/pre-commit-rs/pull/72))\n\n### Other changes\n\n- Add copyright of the original pre-commit to LICENSE ([#74](https://github.com/j178/pre-commit-rs/pull/74))\n- Add profiler ([#71](https://github.com/j178/pre-commit-rs/pull/71))\n- Publish to PyPI ([#70](https://github.com/j178/pre-commit-rs/pull/70))\n- Publish to crates.io ([#75](https://github.com/j178/pre-commit-rs/pull/75))\n- Rename pypi package to `pre-commit-rusty` ([#76](https://github.com/j178/pre-commit-rs/pull/76))\n\n## 0.0.2\n\n### Enhancements\n\n- Add `pre-commit self update` ([#68](https://github.com/j178/pre-commit-rs/pull/68))\n- Auto install uv ([#66](https://github.com/j178/pre-commit-rs/pull/66))\n- Generate shell completion ([#20](https://github.com/j178/pre-commit-rs/pull/20))\n- Implement `pre-commit clean` ([#24](https://github.com/j178/pre-commit-rs/pull/24))\n- Implement `pre-commit install` ([#28](https://github.com/j178/pre-commit-rs/pull/28))\n- Implement `pre-commit sample-config` ([#37](https://github.com/j178/pre-commit-rs/pull/37))\n- Implement `pre-commit uninstall` ([#36](https://github.com/j178/pre-commit-rs/pull/36))\n- Implement `pre-commit validate-config` ([#25](https://github.com/j178/pre-commit-rs/pull/25))\n- Implement `pre-commit validate-manifest` ([#26](https://github.com/j178/pre-commit-rs/pull/26))\n- Implement basic `pre-commit hook-impl` ([#63](https://github.com/j178/pre-commit-rs/pull/63))\n- Partition filenames and delegate to multiple subprocesses ([#7](https://github.com/j178/pre-commit-rs/pull/7))\n- Refactor xargs ([#8](https://github.com/j178/pre-commit-rs/pull/8))\n- Skip empty config argument ([#64](https://github.com/j178/pre-commit-rs/pull/64))\n- Use `fancy-regex` ([#62](https://github.com/j178/pre-commit-rs/pull/62))\n- feat: add fail language support ([#60](https://github.com/j178/pre-commit-rs/pull/60))\n\n### Bug Fixes\n\n- Fix stage operate_on_files ([#65](https://github.com/j178/pre-commit-rs/pull/65))\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to prek\n\nThanks 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.\n\n## 1. Set up the Rust development environment\n\n1. **Install Rust with `rustup`** (recommended)\n\n    Install `rustup` from <https://rustup.rs> if you do not already have it. Then install the toolchain pinned in `rust-toolchain.toml` (currently Rust 1.90):\n\n    ```bash\n    rustup show\n    ```\n\n    Finally, add the common developer components:\n\n    ```bash\n    rustup component add rustfmt clippy\n    ```\n\n2. **Install project helper tools**\n\n    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).\n\n3. (Optional) **Bootstrap git hooks**\n\n    ```bash\n    prek install\n    ```\n\n    This installs a `pre-push` git hook that keeps formatting and linting checks aligned with CI before you push changes.\n\n## 2. Writing tests with `insta` snapshot assertions\n\nprek uses [insta](https://insta.rs/) for snapshot testing. It's recommended (but not necessary) to use `cargo-insta` for a better snapshot review experience.\n\nIf you are contributing new functionality, please include coverage via unit tests (in `src/…` using `#[cfg(test)]`) or integration tests (under `tests/`).\n\nIn integration tests, you can use `cmd_snapshot!` macro to simplify creating snapshots for prek commands. For example:\n\n```rust\n#[test]\nfn test_run() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\");\n}\n```\n\n## 3. Running tests and updating snapshots\n\nYou can invoke the test suite directly with Cargo or use the convenience tasks defined in `mise.toml`.\n\n### Direct Cargo commands\n\n- To run and review a specific snapshot test:\n\n    ```bash\n    cargo test --package <package> --test <test> -- <test_name> -- --exact\n    cargo insta review\n    ```\n\nWhere `<package>` is the crate name (for example, `prek`), `<test>` is the integration test file name (for example, `builtin_hooks`), and `<test_name>` is the specific test function name.\n\n- Run snapshot-aware tests with the review UI:\n\n    ```bash\n    cargo insta test --review [test arguments]\n    ```\n\n    This command runs the selected tests, shows snapshot diffs, and lets you approve or reject updates interactively.\n\n### Using mise tasks\n\n`mise run <task>` picks up the arguments and environment declared in `mise.toml`. Helpful tasks include:\n\n- `mise run test-unit -- <filter>` – run binary/unit tests matching `<filter>` with `cargo insta test --review --bin prek`.\n- `mise run test-all-unit` – run all unit tests with snapshot review enabled.\n- `mise run test-integration <test> [filter]` – run one integration test (for example `mise run test-integration builtin_hooks detect_private_key_hook`).\n- `mise run test-all-integration` – execute the full integration test suite with review prompts.\n- `mise run test` – run `cargo test` across the workspace without the snapshot review flow.\n- `mise run lint` – run `cargo fmt` and `cargo clippy` (useful before opening a pull request).\n\n## 4. Before you open a pull request\n\n- Ensure `mise run lint` passes without errors.\n- Include documentation updates if your change alters the user-facing behavior.\n- Keep commits focused and write descriptive messages—this helps reviewers follow along.\n\nThanks again for contributing!\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"3\"\n\n[workspace.package]\nversion = \"0.3.6\"\nedition = \"2024\"\nrust-version = \"1.92.0\"\nrepository = \"https://github.com/j178/prek\"\nhomepage = \"https://prek.j178.dev/\"\nlicense = \"MIT\"\n\n[workspace.dependencies]\nprek-consts = { path = \"crates/prek-consts\", version = \"0.3.6\" }\nprek-identify = { path = \"crates/prek-identify\", version = \"0.3.6\" }\nprek-pty = { path = \"crates/prek-pty\", version = \"0.3.6\" }\n\naho-corasick = { version = \"1.1.4\" }\nanstream = { version = \"1.0.0\" }\nanstyle-query = { version = \"1.1.5\" }\nanyhow = { version = \"1.0.86\" }\nasync-compression = { version = \"0.4.18\", features = [\"gzip\", \"xz\", \"tokio\"] }\nasync_zip = { version = \"0.0.17\", package = \"astral_async_zip\", features = [\n  \"deflate\",\n  \"tokio\",\n] }\naxoupdater = { version = \"0.10.0\", default-features = false, features = [\n  \"github_releases\",\n] }\nbstr = { version = \"1.11.0\" }\ncargo_metadata = { version = \"0.23.1\" }\nclap = { version = \"4.6.0\", features = [\n  \"derive\",\n  \"env\",\n  \"string\",\n  \"wrap_help\",\n] }\nclap_complete = { version = \"4.6.0\", features = [\"unstable-dynamic\"] }\nctrlc = { version = \"3.4.5\" }\ndunce = { version = \"1.0.5\" }\netcetera = { version = \"0.11.0\" }\nfancy-regex = { version = \"0.17.0\" }\nfs-err = { version = \"3.3.0\", features = [\"tokio\"] }\nfutures = { version = \"0.3.31\" }\nhex = { version = \"0.4.3\" }\nhttp = { version = \"1.1.0\" }\nignore = { version = \"0.4.23\" }\nindicatif = { version = \"0.18.0\" }\nindoc = { version = \"2.0.5\" }\nitertools = { version = \"0.14.0\" }\njson5 = { version = \"1.3.0\" }\nlazy-regex = { version = \"3.4.2\" }\nlevenshtein = { version = \"1.0.5\" }\nlibc = { version = \"0.2.182\" }\n# Enable static linking for liblzma\n# This is required for the `xz` feature in `async-compression`\nliblzma = { version = \"0.4.5\", features = [\"static\"] }\nmea = { version = \"0.6.3\" }\nmemchr = { version = \"2.7.5\" }\nowo-colors = { version = \"4.1.0\" }\npath-clean = { version = \"1.0.1\" }\nphf = { version = \"0.13.1\", default-features = false, features = [\"macros\"] }\npprof = { version = \"0.15.0\" }\nquick-xml = { version = \"0.39\" }\nrand = { version = \"0.10.0\" }\nrayon = { version = \"1.10.0\" }\nreqwest = { version = \"0.13.2\", default-features = false, features = [\n  \"http2\",\n  \"stream\",\n  \"json\",\n  \"rustls\",\n  \"system-proxy\",\n  \"socks\",\n] }\nrustc-hash = { version = \"2.1.1\" }\nrustix = { version = \"1.0.8\", features = [\"pty\", \"process\", \"fs\", \"termios\"] }\nsame-file = { version = \"1.0.6\" }\nsemver = { version = \"1.0.24\", features = [\"serde\"] }\nserde = { version = \"1.0.210\", features = [\"derive\"] }\nserde_json = { version = \"1.0.132\", features = [\n  \"preserve_order\",\n  \"unbounded_depth\",\n] }\nserde_stacker = { version = \"0.1.12\" }\nserde-saphyr = { version = \"0.0.21\", default-features = false }\nshlex = { version = \"1.3.0\" }\nglobset = { version = \"0.4.18\" }\nstrum = { version = \"0.28.0\", features = [\"derive\"] }\ntarget-lexicon = { version = \"0.13.0\" }\ntempfile = { version = \"3.25.0\" }\nthiserror = { version = \"2.0.11\" }\ntokio = { version = \"1.47.1\", features = [\n  \"fs\",\n  \"io-std\",\n  \"process\",\n  \"rt\",\n  \"sync\",\n  \"macros\",\n  \"net\",\n] }\ntokio-tar = { version = \"0.6.0\", package = \"astral-tokio-tar\" }\ntokio-util = { version = \"0.7.13\" }\ntoml = { version = \"1.0.1\", default-features = false, features = [\n  \"fast_hash\",\n  \"parse\",\n  \"preserve_order\",\n  \"serde\",\n] }\ntoml_edit = { version = \"0.25.1\" }\ntracing = { version = \"0.1.40\" }\ntracing-subscriber = { version = \"0.3.20\", features = [\"env-filter\"] }\nunicode-width = { version = \"0.2.0\", default-features = false }\nwalkdir = { version = \"2.5.0\" }\nwebpki-root-certs = { version = \"1.0.6\" }\nwhich = { version = \"8.0.0\" }\n\n# dev-dependencies\nassert_cmd = { version = \"2.2.0\" }\nassert_fs = { version = \"1.1.2\" }\ninsta = { version = \"1.40.0\", features = [\"filters\"] }\ninsta-cmd = { version = \"0.6.0\" }\nmarkdown = { version = \"1.0.0\" }\npredicates = { version = \"3.1.2\" }\npretty_assertions = { version = \"1.4.1\" }\nregex = { version = \"1.11.0\" }\nschemars = { version = \"1.1.0\" }\ntextwrap = { version = \"0.16.0\" }\n\n[workspace.lints.rust]\ndead_code = \"allow\"\n\n[workspace.lints.clippy]\npedantic = { level = \"warn\", priority = -2 }\n# Allowed pedantic lints\ncollapsible_else_if = \"allow\"\ncollapsible_if = \"allow\"\nif_not_else = \"allow\"\nimplicit_hasher = \"allow\"\nmap_unwrap_or = \"allow\"\nmatch_same_arms = \"allow\"\nmissing_errors_doc = \"allow\"\nmissing_panics_doc = \"allow\"\nmodule_name_repetitions = \"allow\"\nmust_use_candidate = \"allow\"\nsimilar_names = \"allow\"\ntoo_many_arguments = \"allow\"\ntoo_many_lines = \"allow\"\nused_underscore_binding = \"allow\"\nitems_after_statements = \"allow\"\niter-without-into-iter = \"allow\"\n# Disallowed restriction lints\nprint_stdout = \"warn\"\nprint_stderr = \"warn\"\ndbg_macro = \"warn\"\nempty_drop = \"warn\"\nempty_structs_with_brackets = \"warn\"\nexit = \"warn\"\nget_unwrap = \"warn\"\nrc_buffer = \"warn\"\nrc_mutex = \"warn\"\nrest_pat_in_fully_bound_structs = \"warn\"\n\n[workspace.metadata.typos.default]\nextend-ignore-re = [\n  '(?s)-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',\n]\n\n[workspace.metadata.typos.default.extend-words]\nedn = \"edn\"\nstyl = \"styl\"\njod = \"jod\"\n\n[workspace.metadata.typos.files]\nextend-exclude = [\"scripts/macports\"]\n\n[workspace.metadata.cargo-shear]\nignored = [\"liblzma\"]\n\n[profile.dev.package]\n# Insta suggests compiling these packages in opt mode for faster testing.\n# See https://docs.rs/insta/latest/insta/#optional-faster-runs.\ninsta.opt-level = 3\nsimilar.opt-level = 3\n\n# Profile for fast test execution: Skip debug info generation, and\n# apply basic optimization, which speed up build and running tests.\n[profile.fast-build]\ninherits = \"dev\"\ndebug = 0\nstrip = \"debuginfo\"\n\n# Profile for faster builds: Skip debug info generation, for faster\n# builds of smaller binaries.\n[profile.no-debug]\ninherits = \"dev\"\ndebug = 0\nstrip = \"debuginfo\"\n\n[profile.profiling]\ninherits = \"release\"\nstrip = false\ndebug = \"full\"\nlto = false\ncodegen-units = 16\n\n[profile.minimal-size]\ninherits = \"release\"\n# Enable Full LTO for the best optimizations\nlto = \"fat\"\n# Reduce codegen units to 1 for better optimizations\ncodegen-units = 1\nstrip = true\npanic = \"abort\"\n\n# The profile that 'cargo dist' will build with\n[profile.dist]\ninherits = \"minimal-size\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM --platform=$BUILDPLATFORM ubuntu AS build\nENV HOME=\"/root\"\nWORKDIR $HOME\n\nRUN apt update \\\n  && apt install -y --no-install-recommends \\\n  build-essential \\\n  curl \\\n  python3-venv \\\n  && apt clean \\\n  && rm -rf /var/lib/apt/lists/*\n\n# Setup zig as cross compiling linker\nRUN python3 -m venv $HOME/.venv\nRUN .venv/bin/pip install cargo-zigbuild\nENV PATH=\"$HOME/.venv/bin:$PATH\"\n\n# Install rust\nARG TARGETPLATFORM\nRUN case \"$TARGETPLATFORM\" in \\\n  \"linux/arm64\") echo \"aarch64-unknown-linux-musl\" > rust_target.txt ;; \\\n  \"linux/amd64\") echo \"x86_64-unknown-linux-musl\" > rust_target.txt ;; \\\n  *) exit 1 ;; \\\n  esac\n\n# Update rustup whenever we bump the rust version\nCOPY rust-toolchain.toml rust-toolchain.toml\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --target $(cat rust_target.txt) --profile minimal --default-toolchain none\nENV PATH=\"$HOME/.cargo/bin:$PATH\"\n# Install the toolchain then the musl target\nRUN rustup toolchain install\nRUN rustup target add $(cat rust_target.txt)\n\n# Build\nCOPY ./Cargo.toml Cargo.toml\nCOPY ./Cargo.lock Cargo.lock\nCOPY crates crates\nRUN case \"${TARGETPLATFORM}\" in \\\n  \"linux/arm64\") export JEMALLOC_SYS_WITH_LG_PAGE=16;; \\\n  esac && \\\n  cargo zigbuild --bin prek --profile dist --target $(cat rust_target.txt)\nRUN cp target/$(cat rust_target.txt)/dist/prek /prek\n# TODO: Optimize binary size, with a version that also works when cross compiling\n# RUN strip --strip-all /prek\n\nFROM scratch\nCOPY --from=build /prek /\nWORKDIR /io\nENTRYPOINT [\"/prek\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 j178\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n<img width=\"180\" alt=\"prek\" src=\"https://raw.githubusercontent.com/j178/prek/master/docs/assets/logo.webp\" />\n<h1>prek</h1>\n\n[![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)\n[![PyPI version](https://img.shields.io/pypi/v/prek.svg)](https://pypi.python.org/pypi/prek)\n[![codecov](https://codecov.io/github/j178/prek/graph/badge.svg?token=MP6TY24F43)](https://codecov.io/github/j178/prek)\n[![PyPI Downloads](https://img.shields.io/pypi/dm/prek?logo=python)](https://pepy.tech/projects/prek)\n[![Discord](https://img.shields.io/discord/1403581202102878289?logo=discord)](https://discord.gg/3NRJUqJz86)\n\n</div>\n\n<!-- --8<-- [start: description] -->\n\n[pre-commit](https://pre-commit.com/) is a framework to run hooks written in many languages, and it manages the\nlanguage toolchain and dependencies for running the hooks.\n\n*prek* is a reimagined version of pre-commit, built in Rust.\nIt is designed to be a faster, dependency-free and drop-in alternative for it,\nwhile also providing some additional long-requested features.\n\n<!-- --8<-- [end: description] -->\n\n> [!NOTE]\n> 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!\n>\n> 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.\n\n<!-- --8<-- [start:features] -->\n\n## Features\n\n- A single binary with no dependencies, does not require Python or any other runtime.\n- [Faster](https://prek.j178.dev/benchmark/) than `pre-commit` and more efficient in disk space usage.\n- Fully compatible with the original pre-commit configurations and hooks.\n- Built-in support for monorepos (i.e. [workspace mode](https://prek.j178.dev/workspace/)).\n- Integration with [`uv`](https://github.com/astral-sh/uv) for managing Python virtual environments and dependencies.\n- Improved toolchain installations for Python, Node.js, Bun, Go, Rust and Ruby, shared between hooks.\n- [Built-in](https://prek.j178.dev/builtin/) Rust-native implementation of some common hooks.\n\n<!-- --8<-- [end:features] -->\n\n## Table of contents\n\n- [Installation](#installation)\n- [Quick start](#quick-start)\n- [Why prek?](#why-prek)\n- [Who is using prek?](#who-is-using-prek)\n- [Acknowledgements](#acknowledgements)\n\n## Installation\n\n<details>\n<summary>Standalone installer</summary>\n\nprek provides a standalone installer script to download and install the tool,\n\nOn Linux and macOS:\n\n<!-- --8<-- [start: linux-standalone-install] -->\n\n```bash\ncurl --proto '=https' --tlsv1.2 -LsSf https://github.com/j178/prek/releases/download/v0.3.6/prek-installer.sh | sh\n```\n\n<!-- --8<-- [end: linux-standalone-install] -->\n\nOn Windows:\n\n<!-- --8<-- [start: windows-standalone-install] -->\n\n```powershell\npowershell -ExecutionPolicy ByPass -c \"irm https://github.com/j178/prek/releases/download/v0.3.6/prek-installer.ps1 | iex\"\n```\n\n<!-- --8<-- [end: windows-standalone-install] -->\n\n</details>\n\n<details>\n<summary>PyPI</summary>\n\n<!-- --8<-- [start: pypi-install] -->\n\nprek is published as Python binary wheel to PyPI, you can install it using `pip`, `uv` (recommended), or `pipx`:\n\n```bash\n# Using uv (recommended)\nuv tool install prek\n\n# Using uvx (install and run in one command)\nuvx prek\n\n# Adding prek to the project dev-dependencies\nuv add --dev prek\n\n# Using pip\npip install prek\n\n# Using pipx\npipx install prek\n```\n\n<!-- --8<-- [end: pypi-install] -->\n\n</details>\n\n<details>\n<summary>Homebrew</summary>\n\n<!-- --8<-- [start: homebrew-install] -->\n\n```bash\nbrew install prek\n```\n\n<!-- --8<-- [end: homebrew-install] -->\n\n</details>\n\n<details>\n<summary>mise</summary>\n\n<!-- --8<-- [start: mise-install] -->\n\nTo use prek with [mise](https://mise.jdx.dev) ([v2025.8.11](https://github.com/jdx/mise/releases/tag/v2025.8.11) or later):\n\n```bash\nmise use prek\n```\n\n<!-- --8<-- [end: mise-install] -->\n\n</details>\n\n<details>\n<summary>Cargo binstall</summary>\n\n<!-- --8<-- [start: cargo-binstall] -->\n\nInstall pre-compiled binaries from GitHub using [cargo-binstall](https://github.com/cargo-bins/cargo-binstall):\n\n```bash\ncargo binstall prek\n```\n\n<!-- --8<-- [end: cargo-binstall] -->\n\n</details>\n\n<details>\n<summary>Cargo</summary>\n\n<!-- --8<-- [start: cargo-install] -->\n\nBuild from source using Cargo (Rust 1.89+ is required):\n\n```bash\ncargo install --locked prek\n```\n\n<!-- --8<-- [end: cargo-install] -->\n\n</details>\n\n<details>\n<summary>npmjs</summary>\n\n<!-- --8<-- [start: npmjs-install] -->\n\nprek is published as a [Node.js package](https://www.npmjs.com/package/@j178/prek)\nand can be installed with any npm-compatible package manager:\n\n```bash\n# As a dev dependency\nnpm add -D @j178/prek\npnpm add -D @j178/prek\nbun add -D @j178/prek\n\n# Or install globally\nnpm install -g @j178/prek\npnpm add -g @j178/prek\nbun install -g @j178/prek\n\n# Or run directly without installing\nnpx @j178/prek --version\nbunx @j178/prek --version\n```\n\n<!-- --8<-- [end: npmjs-install] -->\n\n</details>\n\n<details>\n<summary>Nix</summary>\n\n<!-- --8<-- [start: nix-install] -->\n\nprek is available via [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=prek&query=prek).\n\n```shell\n# Choose what's appropriate for your use case.\n# One-off in a shell:\nnix-shell -p prek\n\n# NixOS or non-NixOS without flakes:\nnix-env -iA nixos.prek\n\n# Non-NixOS with flakes:\nnix profile install nixpkgs#prek\n```\n\n<!-- --8<-- [end: nix-install] -->\n\n</details>\n\n<details>\n<summary>Conda</summary>\n\n<!-- --8<-- [start: conda-forge-install] -->\n\nprek is available as `prek` via [conda-forge](https://anaconda.org/conda-forge/prek).\n\n```shell\nconda install conda-forge::prek\n```\n\n<!-- --8<-- [end: conda-forge-install] -->\n\n</details>\n\n<details>\n<summary>Scoop (Windows)</summary>\n\n<!-- --8<-- [start: scoop-install] -->\n\nprek is available via [Scoop](https://scoop.sh/#/apps?q=prek).\n\n```powershell\nscoop install main/prek\n```\n\n<!-- --8<-- [end: scoop-install] -->\n\n</details>\n\n<details>\n<summary>Winget (Windows)</summary>\n\n<!-- --8<-- [start: winget-install] -->\n\nprek is available via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/).\n\n```powershell\nwinget install --id j178.Prek\n```\n\n<!-- --8<-- [end: winget-install] -->\n\n</details>\n\n<details>\n<summary>MacPorts</summary>\n\n<!-- --8<-- [start: macports-install] -->\n\nprek is available via [MacPorts](https://ports.macports.org/port/prek/).\n\n```bash\nsudo port install prek\n```\n\n<!-- --8<-- [end: macports-install] -->\n\n</details>\n\n<details>\n<summary>GitHub Releases</summary>\n\n<!-- --8<-- [start: pre-built-binaries] -->\n\nPre-built binaries are available for download from the [GitHub releases](https://github.com/j178/prek/releases) page.\n\n<!-- --8<-- [end: pre-built-binaries] -->\n\n</details>\n\n<details>\n<summary>GitHub Actions</summary>\n\n<!-- --8<-- [start: github-actions] -->\n\nprek can be used in GitHub Actions via the [j178/prek-action](https://github.com/j178/prek-action) repository.\n\nExample workflow:\n\n```yaml\nname: Prek checks\non: [push, pull_request]\n\njobs:\n  prek:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: j178/prek-action@v2\n```\n\nThis action installs prek and runs `prek run --all-files` on your repository.\n\nprek is also available via [`taiki-e/install-action`](https://github.com/taiki-e/install-action) for installing various tools.\n\n<!-- --8<-- [end: github-actions] -->\n\n</details>\n\n<!-- --8<-- [start: self-update] -->\n\nIf installed via the standalone installer, prek can update itself to the latest version:\n\n```bash\nprek self update\n```\n\n<!-- --8<-- [end: self-update] -->\n\n## Quick start\n\n- **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.\n- **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).\n\n<!-- --8<-- [start: why] -->\n\n## Why prek?\n\n### prek is faster\n\n- It is [multiple times faster](https://prek.j178.dev/benchmark/) than `pre-commit` and takes up half the disk space.\n- 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.\n- Repositories are cloned in parallel, and hooks are installed in parallel if their dependencies are disjoint.\n- 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.\n- It uses [`uv`](https://github.com/astral-sh/uv) for creating Python virtualenvs and installing dependencies, which is known for its speed and efficiency.\n- It implements some common hooks in Rust, [built in prek](https://prek.j178.dev/builtin/), which are faster than their Python counterparts.\n- It supports `repo: builtin` for offline, zero-setup hooks, which is not available in `pre-commit`.\n\n### prek provides a better user experience\n\n- No need to install Python or any other runtime, just download a single binary.\n- No hassle with your Python version or virtual environments, prek automatically installs the required Python version and creates a virtual environment for you.\n- Built-in support for [workspaces](https://prek.j178.dev/workspace/) (or monorepos), each subproject can have its own `.pre-commit-config.yaml` file.\n- [`prek run`](https://prek.j178.dev/cli/#prek-run) has some nifty improvements over `pre-commit run`, such as:\n    - `prek run --directory <dir>` runs hooks for files in the specified directory, no need to use `git ls-files -- <dir> | xargs pre-commit run --files` anymore.\n    - `prek run --last-commit` runs hooks for files changed in the last commit.\n    - `prek run [HOOK] [HOOK]` selects and runs multiple hooks.\n- [`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.\n- [`prek auto-update`](https://prek.j178.dev/cli/#prek-auto-update) supports `--cooldown-days` to mitigate open source supply chain attacks.\n- prek provides shell completions for `prek run <hook_id>` command, making it easier to run specific hooks without remembering their ids.\n\nFor more detailed improvements prek offers, take a look at [Difference from pre-commit](https://prek.j178.dev/diff/).\n\n## Who is using prek?\n\nprek is pretty new, but it is already being used or recommend by some projects and organizations:\n\n- [apache/airflow](https://github.com/apache/airflow/issues/44995)\n- [python/cpython](https://github.com/python/cpython/issues/143148)\n- [pdm-project/pdm](https://github.com/pdm-project/pdm/pull/3593)\n- [fastapi/fastapi](https://github.com/fastapi/fastapi/pull/14572)\n- [fastapi/typer](https://github.com/fastapi/typer/pull/1453)\n- [fastapi/asyncer](https://github.com/fastapi/asyncer/pull/437)\n- [astral-sh/ruff](https://github.com/astral-sh/ruff/pull/22505)\n- [astral-sh/ty](https://github.com/astral-sh/ty/pull/2469)\n- [openclaw/openclaw](https://github.com/openclaw/openclaw/pull/1720)\n- [home-assistant/core](https://github.com/home-assistant/core/pull/160427)\n- [python-telegram-bot/python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot/pull/5142)\n- [DetachHead/basedpyright](https://github.com/DetachHead/basedpyright/pull/1413)\n- [OpenLineage/OpenLineage](https://github.com/OpenLineage/OpenLineage/pull/3965)\n- [authlib/authlib](https://github.com/authlib/authlib/pull/804)\n- [django/djangoproject.com](https://github.com/django/djangoproject.com/pull/2252)\n- [Future-House/paper-qa](https://github.com/Future-House/paper-qa/pull/1098)\n- [requests-cache/requests-cache](https://github.com/requests-cache/requests-cache/pull/1116)\n- [Goldziher/kreuzberg](https://github.com/Goldziher/kreuzberg/pull/142)\n- [python-attrs/attrs](https://github.com/python-attrs/attrs/commit/c95b177682e76a63478d29d040f9cb36a8d31915)\n- [jlowin/fastmcp](https://github.com/jlowin/fastmcp/pull/2309)\n- [apache/iceberg-python](https://github.com/apache/iceberg-python/pull/2533)\n- [apache/iggy](https://github.com/apache/iggy/pull/2383)\n- [apache/lucene](https://github.com/apache/lucene/pull/15629)\n- [jcrist/msgspec](https://github.com/jcrist/msgspec/pull/918)\n- [python-humanize/humanize](https://github.com/python-humanize/humanize/pull/276)\n- [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli/pull/535)\n- [simple-icons/simple-icons](https://github.com/simple-icons/simple-icons/pull/14245)\n- [ast-grep/ast-grep](https://github.com/ast-grep/ast-grep.github.io/commit/e30818144b2967a7f9172c8cf2f4596bba219bf5)\n- [commitizen-tools/commitizen](https://github.com/commitizen-tools/commitizen)\n- [cocoindex-io/cocoindex](https://github.com/cocoindex-io/cocoindex/pull/1564)\n- [cachix/devenv](https://github.com/cachix/devenv/pull/2304)\n- [copper-project/copper-rs](https://github.com/copper-project/copper-rs/pull/783)\n- [bramstroker/homeassistant-powercalc](https://github.com/bramstroker/homeassistant-powercalc/pull/3978)\n\n<!-- --8<-- [end: why] -->\n\n## Acknowledgements\n\nThis project is heavily inspired by the original [pre-commit](https://pre-commit.com/) tool, and it wouldn't be possible without the hard work\nof the maintainers and contributors of that project.\n\nAnd a special thanks to the [Astral](https://github.com/astral-sh) team for their remarkable projects, particularly [uv](https://github.com/astral-sh/uv),\nfrom which I've learned a lot on how to write efficient and idiomatic Rust code.\n"
  },
  {
    "path": "clippy.toml",
    "content": "disallowed-methods = [\"std::env::var\", \"std::env::var_os\"]\n"
  },
  {
    "path": "crates/prek/Cargo.toml",
    "content": "[package]\nname = \"prek\"\nauthors = [\"j178 <hi@j178.dev>\"]\ndescription = \"Better `pre-commit`, re-engineered in Rust\"\nreadme = \"../../README.md\"\nversion = { workspace = true }\nedition = { workspace = true }\nrust-version = { workspace = true }\nrepository = { workspace = true }\nhomepage = { workspace = true }\nlicense = { workspace = true }\n\n[features]\ndefault = [\"docker\"]\n# Adds self-update functionality. This feature is only enabled for prek built binarys\n# and should be left unselected when building prek for package managers.\nself-update = [\"dep:axoupdater\"]\n# Enable the profiler for benchmarking\nprofiler = [\"dep:pprof\", \"pprof/flamegraph\"]\n# Enable docker related tests in integration tests\ndocker = []\n# Enable generation of JSON schema\nschemars = [\"dep:schemars\", \"prek-identify/schemars\"]\n\n[dependencies]\nprek-consts = { workspace = true }\nprek-identify = { workspace = true, features = [\"serde\"] }\n\naho-corasick = { workspace = true }\nanstream = { workspace = true }\nanstyle-query = { workspace = true }\nanyhow = { workspace = true }\nasync-compression = { workspace = true }\nasync_zip = { workspace = true }\naxoupdater = { workspace = true, optional = true }\nbstr = { workspace = true }\ncargo_metadata = { workspace = true }\nclap = { workspace = true }\nclap_complete = { workspace = true }\nctrlc = { workspace = true }\ndunce = { workspace = true }\netcetera = { workspace = true }\nfancy-regex = { workspace = true }\nfs-err = { workspace = true }\nfutures = { workspace = true }\nhex = { workspace = true }\nhttp = { workspace = true }\nignore = { workspace = true }\nindicatif = { workspace = true }\nindoc = { workspace = true }\nitertools = { workspace = true }\njson5 = { workspace = true }\nlazy-regex = { workspace = true }\nlevenshtein = { workspace = true }\nglobset = { workspace = true }\n# Enable static linking for liblzma\n# This is required for the `xz` feature in `async-compression`\nliblzma = { workspace = true }\nmea = { workspace = true }\nmemchr = { workspace = true }\nowo-colors = { workspace = true }\npath-clean = { workspace = true }\nquick-xml = { workspace = true }\nrand = { workspace = true }\nrayon = { workspace = true }\nreqwest = { workspace = true }\nrustc-hash = { workspace = true }\nsame-file = { workspace = true }\nschemars = { workspace = true, optional = true }\nsemver = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nserde_stacker = { workspace = true }\nserde-saphyr = { workspace = true }\nshlex = { workspace = true }\nstrum = { workspace = true }\ntarget-lexicon = { workspace = true }\ntempfile = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true }\ntokio-tar = { workspace = true }\ntokio-util = { workspace = true }\ntoml = { workspace = true }\ntoml_edit = { workspace = true }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nunicode-width = { workspace = true }\nwalkdir = { workspace = true }\nwebpki-root-certs = { workspace = true }\nwhich = { workspace = true }\n\n[target.'cfg(unix)'.dependencies]\nprek-pty = { workspace = true }\n\nlibc = { workspace = true }\npprof = { workspace = true, optional = true }\nrustix = { workspace = true }\n\n[build-dependencies]\nfs-err = { workspace = true }\n\n[dev-dependencies]\nassert_cmd = { workspace = true }\nassert_fs = { workspace = true }\netcetera = { workspace = true }\ninsta = { workspace = true }\ninsta-cmd = { workspace = true }\nmarkdown = { workspace = true }\npredicates = { workspace = true }\npretty_assertions = { workspace = true }\nregex = { workspace = true }\ntempfile = { workspace = true }\ntextwrap = { workspace = true }\n\n[package.metadata.binstall]\npkg-url = \"{ repo }/releases/download/v{ version }/{ name }-{ target }{ archive-suffix }\"\npkg-fmt = \"tgz\"\n\n[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]\npkg-fmt = \"zip\"\npkg-url = \"{ repo }/releases/download/v{ version }/{ name }-{ target }.zip\"\n\n[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]\npkg-fmt = \"zip\"\npkg-url = \"{ repo }/releases/download/v{ version }/{ name }-{ target }.zip\"\n\n[package.metadata.cargo-shear]\nignored = [\"liblzma\"]\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/prek/build.rs",
    "content": "/* MIT License\n\nCopyright (c) 2023 Astral Software Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n*/\n\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse fs_err as fs;\n\nfn main() {\n    // The workspace root directory is not available without walking up the tree\n    // https://github.com/rust-lang/cargo/issues/3946\n    #[allow(clippy::disallowed_methods)]\n    let workspace_root = Path::new(&std::env::var(\"CARGO_MANIFEST_DIR\").unwrap())\n        .parent()\n        .expect(\"CARGO_MANIFEST_DIR should be nested in workspace\")\n        .parent()\n        .expect(\"CARGO_MANIFEST_DIR should be doubly nested in workspace\")\n        .to_path_buf();\n\n    commit_info(&workspace_root);\n}\n\nfn commit_info(workspace_root: &Path) {\n    // If not in a git repository, do not attempt to retrieve commit information\n    let git_dir = workspace_root.join(\".git\");\n    if !git_dir.exists() {\n        return;\n    }\n\n    if let Some(git_head_path) = git_head(&git_dir) {\n        println!(\"cargo:rerun-if-changed={}\", git_head_path.display());\n\n        let git_head_contents = fs::read_to_string(git_head_path);\n        if let Ok(git_head_contents) = git_head_contents {\n            // The contents are either a commit or a reference in the following formats\n            // - \"<commit>\" when the head is detached\n            // - \"ref <ref>\" when working on a branch\n            // If a commit, checking if the HEAD file has changed is sufficient\n            // If a ref, we need to add the head file for that ref to rebuild on commit\n            let mut git_ref_parts = git_head_contents.split_whitespace();\n            git_ref_parts.next();\n            if let Some(git_ref) = git_ref_parts.next() {\n                let git_ref_path = git_dir.join(git_ref);\n                println!(\"cargo:rerun-if-changed={}\", git_ref_path.display());\n            }\n        }\n    }\n\n    let output = match Command::new(\"git\")\n        .arg(\"log\")\n        .arg(\"-1\")\n        .arg(\"--date=short\")\n        .arg(\"--abbrev=9\")\n        // describe:tags => Instead of only considering annotated tags, consider lightweight tags as well.\n        .arg(\"--format='%H %h %cd %(describe:tags)'\")\n        .output()\n    {\n        Ok(output) if output.status.success() => output,\n        _ => return,\n    };\n    let stdout = String::from_utf8(output.stdout).unwrap();\n    let mut parts = stdout.split_whitespace();\n    let mut next = || parts.next().unwrap();\n    println!(\"cargo:rustc-env=PREK_COMMIT_HASH={}\", next());\n    println!(\"cargo:rustc-env=PREK_COMMIT_SHORT_HASH={}\", next());\n    println!(\"cargo:rustc-env=PREK_COMMIT_DATE={}\", next());\n\n    // Describe can fail for some commits\n    // https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem\n    if let Some(describe) = parts.next() {\n        // e.g. 'v0.2.0-alpha.5-1-g4e9faf2'\n        let mut describe_parts = describe.rsplitn(3, '-');\n        describe_parts.next();\n        println!(\n            \"cargo:rustc-env=PREK_LAST_TAG_DISTANCE={}\",\n            describe_parts.next().unwrap_or(\"0\")\n        );\n        if let Some(last_tag) = describe_parts.next() {\n            println!(\"cargo:rustc-env=PREK_LAST_TAG={last_tag}\");\n        }\n    }\n}\n\nfn git_head(git_dir: &Path) -> Option<PathBuf> {\n    // The typical case is a standard git repository.\n    let git_head_path = git_dir.join(\"HEAD\");\n    if git_head_path.exists() {\n        return Some(git_head_path);\n    }\n    if !git_dir.is_file() {\n        return None;\n    }\n    // If `.git/HEAD` doesn't exist and `.git` is actually a file,\n    // then let's try to attempt to read it as a worktree. If it's\n    // a worktree, then its contents will look like this, e.g.:\n    //\n    //     gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2\n    //\n    // And the HEAD file we want to watch will be at:\n    //\n    //     /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD\n    let contents = fs::read_to_string(git_dir).ok()?;\n    let (label, worktree_path) = contents.split_once(':')?;\n    if label != \"gitdir\" {\n        return None;\n    }\n    let worktree_path = worktree_path.trim();\n    Some(PathBuf::from(worktree_path))\n}\n"
  },
  {
    "path": "crates/prek/src/archive.rs",
    "content": "// MIT License\n//\n// Copyright (c) 2023 Astral Software Inc.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\nuse std::ffi::OsString;\nuse std::fmt::{Display, Formatter};\nuse std::path::{Component, Path, PathBuf};\n\nuse async_compression::tokio::bufread::{GzipDecoder, XzDecoder};\nuse async_zip::base::read::stream::ZipFileReader;\nuse rustc_hash::FxHashSet;\nuse tokio::io::{AsyncRead, BufReader};\nuse tokio_tar::ArchiveBuilder;\nuse tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};\nuse tracing::warn;\n\n#[derive(Debug, thiserror::Error)]\npub enum Error {\n    #[error(transparent)]\n    AsyncZip(#[from] async_zip::error::ZipError),\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(\"Unsupported archive type: {0}\")]\n    UnsupportedArchive(PathBuf),\n    #[error(\n        \"The top-level of the archive must only contain a list directory, but it contains: {0:?}\"\n    )]\n    NonSingularArchive(Vec<OsString>),\n    #[error(\"The top-level of the archive must only contain a list directory, but it's empty\")]\n    EmptyArchive,\n}\n\nconst DEFAULT_BUF_SIZE: usize = 128 * 1024;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ArchiveExtension {\n    Zip,\n    TarGz,\n    TarBz2,\n    TarXz,\n    TarZst,\n    TarLzma,\n    Tar,\n}\n\nimpl ArchiveExtension {\n    /// Extract the [`ArchiveExtension`] from a path.\n    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, Error> {\n        /// Returns true if the path is a tar file (e.g., `.tar.gz`).\n        fn is_tar(path: &Path) -> bool {\n            path.file_stem().is_some_and(|stem| {\n                Path::new(stem)\n                    .extension()\n                    .is_some_and(|ext| ext.eq_ignore_ascii_case(\"tar\"))\n            })\n        }\n\n        let Some(extension) = path.as_ref().extension().and_then(|ext| ext.to_str()) else {\n            return Err(Error::UnsupportedArchive(path.as_ref().to_path_buf()));\n        };\n\n        match extension {\n            \"zip\" => Ok(Self::Zip),\n            \"whl\" => Ok(Self::Zip), // Wheel files are zip files\n            \"tar\" => Ok(Self::Tar),\n            \"tgz\" => Ok(Self::TarGz),\n            \"tbz\" => Ok(Self::TarBz2),\n            \"txz\" => Ok(Self::TarXz),\n            \"tlz\" => Ok(Self::TarLzma),\n            \"gz\" if is_tar(path.as_ref()) => Ok(Self::TarGz),\n            \"bz2\" if is_tar(path.as_ref()) => Ok(Self::TarBz2),\n            \"xz\" if is_tar(path.as_ref()) => Ok(Self::TarXz),\n            \"lz\" | \"lzma\" if is_tar(path.as_ref()) => Ok(Self::TarLzma),\n            \"zst\" if is_tar(path.as_ref()) => Ok(Self::TarZst),\n            _ => Err(Error::UnsupportedArchive(path.as_ref().to_path_buf())),\n        }\n    }\n}\n\nimpl Display for ArchiveExtension {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::Zip => f.write_str(\"zip\"),\n            Self::TarGz => f.write_str(\"tar.gz\"),\n            Self::TarBz2 => f.write_str(\"tar.bz2\"),\n            Self::TarXz => f.write_str(\"tar.xz\"),\n            Self::TarZst => f.write_str(\"tar.zst\"),\n            Self::TarLzma => f.write_str(\"tar.lzma\"),\n            Self::Tar => f.write_str(\"tar\"),\n        }\n    }\n}\n\n/// Extract the top-level directory from an unpacked archive.\n///\n/// This function returns the path to that top-level directory.\npub fn strip_component(source: impl AsRef<Path>) -> Result<PathBuf, Error> {\n    let top_level =\n        fs_err::read_dir(source.as_ref())?.collect::<std::io::Result<Vec<fs_err::DirEntry>>>()?;\n    match top_level.as_slice() {\n        [root] => Ok(root.path()),\n        [] => Err(Error::EmptyArchive),\n        _ => Err(Error::NonSingularArchive(\n            top_level\n                .into_iter()\n                .map(|entry| entry.file_name())\n                .collect(),\n        )),\n    }\n}\n\n/// Unpack a `.zip` archive into the target directory, without requiring `Seek`.\n///\n/// This is useful for unzipping files as they're being downloaded. If the archive\n/// is already fully on disk, consider using `unzip_archive`, which can use multiple\n/// threads to work faster in that case.\npub async fn unzip<R: AsyncRead + Unpin>(reader: R, target: impl AsRef<Path>) -> Result<(), Error> {\n    /// Ensure the file path is safe to use as a [`Path`].\n    ///\n    /// See: <https://docs.rs/zip/latest/zip/read/struct.ZipFile.html#method.enclosed_name>\n    pub(crate) fn enclosed_name(file_name: &str) -> Option<PathBuf> {\n        if file_name.contains('\\0') {\n            return None;\n        }\n        let path = PathBuf::from(file_name);\n        let mut depth = 0usize;\n        for component in path.components() {\n            match component {\n                Component::Prefix(_) | Component::RootDir => return None,\n                Component::ParentDir => depth = depth.checked_sub(1)?,\n                Component::Normal(_) => depth += 1,\n                Component::CurDir => (),\n            }\n        }\n        Some(path)\n    }\n\n    let target = target.as_ref();\n    let mut reader = futures::io::BufReader::with_capacity(DEFAULT_BUF_SIZE, reader.compat());\n    let mut zip = ZipFileReader::new(&mut reader);\n\n    let mut directories = FxHashSet::default();\n    let mut offset = 0;\n\n    while let Some(mut entry) = zip.next_with_entry().await? {\n        // Construct the (expected) path to the file on-disk.\n        let path = entry.reader().entry().filename().as_str()?;\n\n        // Sanitize the file name to prevent directory traversal attacks.\n        let Some(path) = enclosed_name(path) else {\n            warn!(\"Skipping unsafe file name: {path}\");\n\n            // Close current file prior to proceeding, as per:\n            // https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/\n            (.., zip) = entry.skip().await?;\n\n            // Store the current offset.\n            offset = zip.offset();\n\n            continue;\n        };\n\n        let path = target.join(path);\n        let is_dir = entry.reader().entry().dir()?;\n\n        // Either create the directory or write the file to disk.\n        if is_dir {\n            if directories.insert(path.clone()) {\n                fs_err::tokio::create_dir_all(path).await?;\n            }\n        } else {\n            if let Some(parent) = path.parent() {\n                if directories.insert(parent.to_path_buf()) {\n                    fs_err::tokio::create_dir_all(parent).await?;\n                }\n            }\n\n            // We don't know the file permissions here, because we haven't seen the central directory yet.\n            let file = fs_err::tokio::File::create(&path).await?;\n            let size = entry.reader().entry().uncompressed_size();\n            let mut writer = if let Ok(size) = usize::try_from(size) {\n                tokio::io::BufWriter::with_capacity(std::cmp::min(size, 1024 * 1024), file)\n            } else {\n                tokio::io::BufWriter::new(file)\n            };\n            let mut reader = entry.reader_mut().compat();\n            tokio::io::copy(&mut reader, &mut writer).await?;\n        }\n\n        // Close current file prior to proceeding, as per:\n        // https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/\n        (.., zip) = entry.skip().await?;\n\n        // Store the current offset.\n        offset = zip.offset();\n    }\n\n    // On Unix, we need to set file permissions, which are stored in the central directory, at the\n    // end of the archive. The `ZipFileReader` reads until it sees a central directory signature,\n    // which indicates the first entry in the central directory. So we continue reading from there.\n    #[cfg(unix)]\n    {\n        use async_zip::base::read::cd::CentralDirectoryReader;\n        use async_zip::base::read::cd::Entry;\n        use std::fs::Permissions;\n        use std::os::unix::fs::PermissionsExt;\n\n        let mut directory = CentralDirectoryReader::new(&mut reader, offset);\n        while let Entry::CentralDirectoryEntry(entry) = directory.next().await? {\n            if entry.dir()? {\n                continue;\n            }\n\n            let Some(mode) = entry.unix_permissions() else {\n                continue;\n            };\n\n            // Construct the (expected) path to the file on-disk.\n            let path = entry.filename().as_str()?;\n            let Some(path) = enclosed_name(path) else {\n                continue;\n            };\n            let path = target.join(path);\n            fs_err::tokio::set_permissions(&path, Permissions::from_mode(mode)).await?;\n        }\n    }\n    #[cfg(not(unix))]\n    {\n        let _ = offset;\n    }\n\n    Ok(())\n}\n\n/// Unpack a `.tar.gz` archive into the target directory, without requiring `Seek`.\n///\n/// This is useful for unpacking files as they're being downloaded.\npub async fn untar_gz<R: AsyncRead + Unpin>(\n    reader: R,\n    target: impl AsRef<Path>,\n) -> Result<(), Error> {\n    let reader = BufReader::with_capacity(DEFAULT_BUF_SIZE, reader);\n    let reader = GzipDecoder::new(reader);\n\n    let mut archive = ArchiveBuilder::new(reader)\n        .set_preserve_mtime(true)\n        .set_preserve_permissions(true)\n        .set_allow_external_symlinks(false)\n        .build();\n\n    archive.unpack(target.as_ref()).await?;\n    Ok(())\n}\n\n/// Unpack a `.tar.xz` archive into the target directory, without requiring `Seek`.\n///\n/// This is useful for unpacking files as they're being downloaded.\npub async fn untar_xz<R: AsyncRead + Unpin>(\n    reader: R,\n    target: impl AsRef<Path>,\n) -> Result<(), Error> {\n    let reader = BufReader::with_capacity(DEFAULT_BUF_SIZE, reader);\n    let reader = XzDecoder::new(reader);\n\n    let mut archive = ArchiveBuilder::new(reader)\n        .set_preserve_mtime(true)\n        .set_preserve_permissions(true)\n        .set_allow_external_symlinks(false)\n        .build();\n\n    archive.unpack(target.as_ref()).await?;\n    Ok(())\n}\n\n/// Unpack a `.tar` archive into the target directory, without requiring `Seek`.\n///\n/// This is useful for unpacking files as they're being downloaded.\npub async fn untar<R: AsyncRead + Unpin>(reader: R, target: impl AsRef<Path>) -> Result<(), Error> {\n    let reader = BufReader::with_capacity(DEFAULT_BUF_SIZE, reader);\n\n    let mut archive = ArchiveBuilder::new(reader)\n        .set_preserve_mtime(true)\n        .set_preserve_permissions(true)\n        .set_allow_external_symlinks(false)\n        .build();\n\n    archive.unpack(target.as_ref()).await?;\n    Ok(())\n}\n\n/// Unpack a `.zip`, `.tar.gz`, `.tar.bz2`, `.tar.zst`, or `.tar.xz` archive into the target directory,\n/// without requiring `Seek`.\npub async fn unpack<R: AsyncRead + Unpin>(\n    reader: R,\n    ext: ArchiveExtension,\n    target: impl AsRef<Path>,\n) -> Result<(), Error> {\n    match ext {\n        ArchiveExtension::Zip => unzip(reader, target).await,\n        ArchiveExtension::Tar => untar(reader, target).await,\n        ArchiveExtension::TarGz => untar_gz(reader, target).await,\n        ArchiveExtension::TarXz => untar_xz(reader, target).await,\n        _ => Err(Error::UnsupportedArchive(target.as_ref().to_path_buf())),\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cleanup.rs",
    "content": "use std::sync::Mutex;\n\nstatic CLEANUP_HOOKS: Mutex<Vec<Box<dyn Fn() + Send>>> = Mutex::new(Vec::new());\n\n/// Run all cleanup functions.\npub fn cleanup() {\n    let mut cleanup = CLEANUP_HOOKS.lock().unwrap();\n    for f in cleanup.drain(..) {\n        f();\n    }\n}\n\n/// Add a cleanup function to be run when the program is interrupted.\npub fn add_cleanup<F: Fn() + Send + 'static>(f: F) {\n    let mut cleanup = CLEANUP_HOOKS.lock().unwrap();\n    cleanup.push(Box::new(f));\n}\n"
  },
  {
    "path": "crates/prek/src/cli/auto_update.rs",
    "content": "use std::fmt::Write;\nuse std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse anyhow::{Context, Result};\nuse futures::StreamExt;\nuse itertools::Itertools;\nuse lazy_regex::regex;\nuse owo_colors::OwoColorize;\nuse prek_consts::PRE_COMMIT_HOOKS_YAML;\nuse rustc_hash::FxHashMap;\nuse rustc_hash::FxHashSet;\nuse semver::Version;\nuse toml_edit::DocumentMut;\nuse tracing::{debug, trace};\n\nuse crate::cli::ExitStatus;\nuse crate::cli::reporter::AutoUpdateReporter;\nuse crate::cli::run::Selectors;\nuse crate::config::{RemoteRepo, Repo};\nuse crate::fs::{CWD, Simplified};\nuse crate::printer::Printer;\nuse crate::run::CONCURRENCY;\nuse crate::store::Store;\nuse crate::workspace::{Project, Workspace};\nuse crate::yaml::serialize_yaml_scalar;\nuse crate::{config, git};\n\n#[derive(Default, Clone)]\nstruct Revision {\n    rev: String,\n    frozen: Option<String>,\n}\n\npub(crate) async fn auto_update(\n    store: &Store,\n    config: Option<PathBuf>,\n    filter_repos: Vec<String>,\n    bleeding_edge: bool,\n    freeze: bool,\n    jobs: usize,\n    dry_run: bool,\n    cooldown_days: u8,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    struct RepoInfo<'a> {\n        project: &'a Project,\n        remote_size: usize,\n        remote_index: usize,\n    }\n\n    let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;\n    // TODO: support selectors?\n    let selectors = Selectors::default();\n    let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), true)?;\n\n    // Collect repos and deduplicate by RemoteRepo\n    #[allow(clippy::mutable_key_type)]\n    let mut repo_updates: FxHashMap<&RemoteRepo, Vec<RepoInfo>> = FxHashMap::default();\n\n    for project in workspace.projects() {\n        let remote_size = project\n            .config()\n            .repos\n            .iter()\n            .filter(|r| matches!(r, Repo::Remote(_)))\n            .count();\n\n        let mut remote_index = 0;\n        for repo in &project.config().repos {\n            if let Repo::Remote(remote_repo) = repo {\n                let updates = repo_updates.entry(remote_repo).or_default();\n                updates.push(RepoInfo {\n                    project,\n                    remote_size,\n                    remote_index,\n                });\n                remote_index += 1;\n            }\n        }\n    }\n\n    let jobs = if jobs == 0 { *CONCURRENCY } else { jobs };\n    let jobs = jobs\n        .min(if filter_repos.is_empty() {\n            repo_updates.len()\n        } else {\n            filter_repos.len()\n        })\n        .max(1);\n\n    let reporter = AutoUpdateReporter::new(printer);\n\n    let mut tasks = futures::stream::iter(repo_updates.iter().filter(|(remote_repo, _)| {\n        // Filter by user specified repositories\n        if filter_repos.is_empty() {\n            true\n        } else {\n            filter_repos.iter().any(|r| r == remote_repo.repo.as_str())\n        }\n    }))\n    .map(async |(remote_repo, _)| {\n        let progress = reporter.on_update_start(&remote_repo.to_string());\n\n        let result = update_repo(remote_repo, bleeding_edge, freeze, cooldown_days).await;\n\n        reporter.on_update_complete(progress);\n\n        (*remote_repo, result)\n    })\n    .buffer_unordered(jobs)\n    .collect::<Vec<_>>()\n    .await;\n\n    // Sort tasks by repository URL for consistent output order\n    tasks.sort_by(|(a, _), (b, _)| a.repo.cmp(&b.repo));\n\n    reporter.on_complete();\n\n    // Group results by project config file\n    #[allow(clippy::mutable_key_type)]\n    let mut project_updates: FxHashMap<&Project, Vec<Option<Revision>>> = FxHashMap::default();\n    let mut failure = false;\n\n    for (remote_repo, result) in tasks {\n        match result {\n            Ok(new_rev) => {\n                let is_changed = remote_repo.rev != new_rev.rev;\n\n                if is_changed {\n                    writeln!(\n                        printer.stdout(),\n                        \"[{}] updating {} -> {}\",\n                        remote_repo.repo.as_str().cyan(),\n                        remote_repo.rev,\n                        new_rev.rev\n                    )?;\n                } else {\n                    writeln!(\n                        printer.stdout(),\n                        \"[{}] already up to date\",\n                        remote_repo.repo.as_str().yellow()\n                    )?;\n                }\n\n                // Apply this update to all projects that reference this repo\n                if is_changed && let Some(projects) = repo_updates.get(&remote_repo) {\n                    for RepoInfo {\n                        project,\n                        remote_size,\n                        remote_index,\n                    } in projects\n                    {\n                        let revisions = project_updates\n                            .entry(project)\n                            .or_insert_with(|| vec![None; *remote_size]);\n                        revisions[*remote_index] = Some(new_rev.clone());\n                    }\n                }\n            }\n            Err(e) => {\n                failure = true;\n                writeln!(\n                    printer.stderr(),\n                    \"[{}] update failed: {e}\",\n                    remote_repo.repo.as_str().red()\n                )?;\n            }\n        }\n    }\n\n    if !dry_run {\n        // Update each project config file\n        for (project, revisions) in project_updates {\n            let has_changes = revisions.iter().any(Option::is_some);\n            if has_changes {\n                write_new_config(project.config_file(), &revisions).await?;\n            }\n        }\n    }\n\n    if failure {\n        return Ok(ExitStatus::Failure);\n    }\n    Ok(ExitStatus::Success)\n}\n\nasync fn update_repo(\n    repo: &RemoteRepo,\n    bleeding_edge: bool,\n    freeze: bool,\n    cooldown_days: u8,\n) -> Result<Revision> {\n    let tmp_dir = tempfile::tempdir()?;\n    let repo_path = tmp_dir.path();\n\n    trace!(\n        \"Cloning repository `{}` to `{}`\",\n        repo.repo,\n        repo_path.display()\n    );\n\n    setup_and_fetch_repo(repo.repo.as_str(), repo_path).await?;\n\n    let rev = resolve_revision(repo_path, &repo.rev, bleeding_edge, cooldown_days).await?;\n\n    let Some(rev) = rev else {\n        debug!(\"No suitable revision found for repo `{}`\", repo.repo);\n        return Ok(Revision {\n            rev: repo.rev.clone(),\n            frozen: None,\n        });\n    };\n\n    let (rev, frozen) = if freeze && let Some(exact) = freeze_revision(repo_path, &rev).await? {\n        debug!(\"Freezing revision `{rev}` to `{exact}`\");\n        (exact, Some(rev))\n    } else {\n        (rev, None)\n    };\n\n    checkout_and_validate_manifest(repo_path, &rev, repo).await?;\n\n    Ok(Revision { rev, frozen })\n}\n\nasync fn setup_and_fetch_repo(repo_url: &str, repo_path: &Path) -> Result<()> {\n    git::init_repo(repo_url, repo_path).await?;\n    git::git_cmd(\"git config\")?\n        .arg(\"config\")\n        .arg(\"extensions.partialClone\")\n        .arg(\"true\")\n        .current_dir(repo_path)\n        .remove_git_envs()\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status()\n        .await?;\n    git::git_cmd(\"git fetch\")?\n        .arg(\"fetch\")\n        .arg(\"origin\")\n        .arg(\"HEAD\")\n        .arg(\"--quiet\")\n        .arg(\"--filter=blob:none\")\n        .arg(\"--tags\")\n        .current_dir(repo_path)\n        .remove_git_envs()\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status()\n        .await?;\n\n    Ok(())\n}\n\nasync fn resolve_bleeding_edge(repo_path: &Path) -> Result<Option<String>> {\n    let output = git::git_cmd(\"git describe\")?\n        .arg(\"describe\")\n        .arg(\"FETCH_HEAD\")\n        // Instead of using only the annotated tags, use any tag found in refs/tags namespace.\n        // This option enables matching a lightweight (non-annotated) tag.\n        .arg(\"--tags\")\n        // Only output exact matches (a tag directly references the supplied commit).\n        // This is a synonym for --candidates=0.\n        .arg(\"--exact-match\")\n        .check(false)\n        .current_dir(repo_path)\n        .remove_git_envs()\n        .output()\n        .await?;\n    let rev = if output.status.success() {\n        String::from_utf8_lossy(&output.stdout).trim().to_string()\n    } else {\n        debug!(\"No matching tag for `FETCH_HEAD`, using rev-parse instead\");\n        // \"fatal: no tag exactly matches xxx\"\n        let output = git::git_cmd(\"git rev-parse\")?\n            .arg(\"rev-parse\")\n            .arg(\"FETCH_HEAD\")\n            .check(true)\n            .current_dir(repo_path)\n            .remove_git_envs()\n            .output()\n            .await?;\n        String::from_utf8_lossy(&output.stdout).trim().to_string()\n    };\n\n    debug!(\"Resolved `FETCH_HEAD` to `{rev}`\");\n    Ok(Some(rev))\n}\n\n/// Returns all tags and their Unix timestamps (newest first).\n///\n/// Within groups of tags sharing the same timestamp, semver-parseable tags\n/// are sorted highest version first; non-semver tags sort after them.\nasync fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {\n    let output = git::git_cmd(\"git for-each-ref\")?\n        .arg(\"for-each-ref\")\n        .arg(\"--sort=-creatordate\")\n        // `creatordate` is the date the tag was created (annotated tags) or the commit date (lightweight tags)\n        // `lstrip=2` removes the \"refs/tags/\" prefix\n        .arg(\"--format=%(refname:lstrip=2) %(creatordate:unix)\")\n        .arg(\"refs/tags\")\n        .check(true)\n        .current_dir(repo)\n        .remove_git_envs()\n        .output()\n        .await?;\n\n    let mut tags: Vec<(String, u64)> = String::from_utf8_lossy(&output.stdout)\n        .lines()\n        .filter_map(|line| {\n            let mut parts = line.split_whitespace();\n            let tag = parts.next()?.trim_ascii();\n            let ts_str = parts.next()?.trim_ascii();\n            let ts: u64 = ts_str.parse().ok()?;\n            Some((tag.to_string(), ts))\n        })\n        .collect();\n\n    // Deterministic sort: primary key is timestamp (newest first).\n    // Within equal timestamps, prefer higher semver versions; non-semver tags\n    // sort after semver ones. As a final tie-breaker, compare the tag refname\n    // so ordering is stable across platforms/filesystems.\n    tags.sort_by(|(tag_a, ts_a), (tag_b, ts_b)| {\n        ts_b.cmp(ts_a).then_with(|| {\n            let ver_a = Version::parse(tag_a.strip_prefix('v').unwrap_or(tag_a));\n            let ver_b = Version::parse(tag_b.strip_prefix('v').unwrap_or(tag_b));\n            match (ver_a, ver_b) {\n                (Ok(a), Ok(b)) => b.cmp(&a).then_with(|| tag_a.cmp(tag_b)),\n                (Ok(_), Err(_)) => std::cmp::Ordering::Less,\n                (Err(_), Ok(_)) => std::cmp::Ordering::Greater,\n                (Err(_), Err(_)) => tag_a.cmp(tag_b),\n            }\n        })\n    });\n\n    Ok(tags)\n}\n\nasync fn resolve_revision(\n    repo_path: &Path,\n    current_rev: &str,\n    bleeding_edge: bool,\n    cooldown_days: u8,\n) -> Result<Option<String>> {\n    if bleeding_edge {\n        return resolve_bleeding_edge(repo_path).await;\n    }\n\n    let tags_with_ts = get_tag_timestamps(repo_path).await?;\n\n    let cutoff_secs = u64::from(cooldown_days) * 86400;\n    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();\n    let cutoff = now.saturating_sub(cutoff_secs);\n\n    // tags_with_ts is sorted newest -> oldest; find the first bucket where ts <= cutoff.\n    let left = match tags_with_ts.binary_search_by(|(_, ts)| ts.cmp(&cutoff).reverse()) {\n        Ok(i) | Err(i) => i,\n    };\n\n    let Some((target_tag, target_ts)) = tags_with_ts.get(left) else {\n        trace!(\"No tags meet cooldown cutoff {cutoff_secs}s\");\n        return Ok(None);\n    };\n\n    debug!(\"Using tag `{target_tag}` cutoff timestamp {target_ts}\");\n\n    let best = get_best_candidate_tag(repo_path, target_tag, current_rev)\n        .await\n        .unwrap_or_else(|_| target_tag.clone());\n    debug!(\"Using best candidate tag `{best}` for revision `{target_tag}`\");\n\n    Ok(Some(best))\n}\n\nasync fn freeze_revision(repo_path: &Path, rev: &str) -> Result<Option<String>> {\n    let exact = git::git_cmd(\"git rev-parse\")?\n        .arg(\"rev-parse\")\n        .arg(format!(\"{rev}^{{}}\"))\n        .current_dir(repo_path)\n        .remove_git_envs()\n        .output()\n        .await?\n        .stdout;\n    let exact = str::from_utf8(&exact)?.trim();\n    if rev == exact {\n        Ok(None)\n    } else {\n        Ok(Some(exact.to_string()))\n    }\n}\n\nasync fn checkout_and_validate_manifest(\n    repo_path: &Path,\n    rev: &str,\n    repo: &RemoteRepo,\n) -> Result<()> {\n    // Workaround for Windows: https://github.com/pre-commit/pre-commit/issues/2865,\n    // https://github.com/j178/prek/issues/614\n    if cfg!(windows) {\n        git::git_cmd(\"git show\")?\n            .arg(\"show\")\n            .arg(format!(\"{rev}:{PRE_COMMIT_HOOKS_YAML}\"))\n            .current_dir(repo_path)\n            .remove_git_envs()\n            .stdout(Stdio::null())\n            .stderr(Stdio::null())\n            .status()\n            .await?;\n    }\n\n    git::git_cmd(\"git checkout\")?\n        .arg(\"checkout\")\n        .arg(\"--quiet\")\n        .arg(rev)\n        .arg(\"--\")\n        .arg(PRE_COMMIT_HOOKS_YAML)\n        .current_dir(repo_path)\n        .remove_git_envs()\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status()\n        .await?;\n\n    let manifest = config::read_manifest(&repo_path.join(PRE_COMMIT_HOOKS_YAML))?;\n    let new_hook_ids = manifest\n        .hooks\n        .into_iter()\n        .map(|h| h.id)\n        .collect::<FxHashSet<_>>();\n    let hooks_missing = repo\n        .hooks\n        .iter()\n        .filter(|h| !new_hook_ids.contains(&h.id))\n        .map(|h| h.id.clone())\n        .collect::<Vec<_>>();\n    if !hooks_missing.is_empty() {\n        anyhow::bail!(\n            \"Cannot update to rev `{}`, hook{} {} missing: {}\",\n            rev,\n            if hooks_missing.len() > 1 { \"s\" } else { \"\" },\n            if hooks_missing.len() > 1 { \"are\" } else { \"is\" },\n            hooks_missing.join(\", \")\n        );\n    }\n\n    Ok(())\n}\n\n/// Multiple tags can exist on an SHA. Sometimes a moving tag is attached\n/// to a version tag. Try to pick the tag that looks like a version and most similar\n/// to the current revision.\nasync fn get_best_candidate_tag(repo: &Path, rev: &str, current_rev: &str) -> Result<String> {\n    let stdout = git::git_cmd(\"git tag\")?\n        .arg(\"tag\")\n        .arg(\"--points-at\")\n        .arg(format!(\"{rev}^{{}}\"))\n        .check(true)\n        .current_dir(repo)\n        .remove_git_envs()\n        .output()\n        .await?\n        .stdout;\n\n    String::from_utf8_lossy(&stdout)\n        .lines()\n        .filter(|line| line.contains('.'))\n        .sorted_by_key(|tag| {\n            // Prefer tags that are more similar to the current revision\n            levenshtein::levenshtein(tag, current_rev)\n        })\n        .next()\n        .map(ToString::to_string)\n        .with_context(|| format!(\"No tags found for revision {rev}\"))\n}\n\nasync fn write_new_config(path: &Path, revisions: &[Option<Revision>]) -> Result<()> {\n    let content = fs_err::tokio::read_to_string(path).await?;\n    let new_content = match path.extension() {\n        Some(ext) if ext.eq_ignore_ascii_case(\"toml\") => {\n            render_updated_toml_config(path, &content, revisions)?\n        }\n        _ => render_updated_yaml_config(path, &content, revisions)?,\n    };\n\n    fs_err::tokio::write(path, new_content)\n        .await\n        .with_context(|| {\n            format!(\n                \"Failed to write updated config file `{}`\",\n                path.user_display()\n            )\n        })?;\n\n    Ok(())\n}\n\nfn render_updated_toml_config(\n    path: &Path,\n    content: &str,\n    revisions: &[Option<Revision>],\n) -> Result<String> {\n    let mut doc = content.parse::<DocumentMut>()?;\n    let Some(repos) = doc\n        .get_mut(\"repos\")\n        .and_then(|item| item.as_array_of_tables_mut())\n    else {\n        anyhow::bail!(\"Missing `[[repos]]` array in `{}`\", path.user_display());\n    };\n\n    let mut remote_repos = Vec::new();\n    for table in repos.iter_mut() {\n        let repo_value = table\n            .get(\"repo\")\n            .and_then(|item| item.as_value())\n            .and_then(|value| value.as_str())\n            .unwrap_or_default();\n\n        if matches!(repo_value, \"local\" | \"meta\" | \"builtin\") {\n            continue;\n        }\n\n        if !table.contains_key(\"rev\") {\n            anyhow::bail!(\n                \"Found remote repo without `rev` in `{}`\",\n                path.user_display()\n            );\n        }\n\n        remote_repos.push(table);\n    }\n\n    if remote_repos.len() != revisions.len() {\n        anyhow::bail!(\n            \"Found {} remote repos in `{}` but expected {}, file content may have changed\",\n            remote_repos.len(),\n            path.user_display(),\n            revisions.len()\n        );\n    }\n\n    for (table, revision) in remote_repos.into_iter().zip_eq(revisions) {\n        let Some(revision) = revision else {\n            continue;\n        };\n\n        let Some(value) = table.get_mut(\"rev\").and_then(|item| item.as_value_mut()) else {\n            continue;\n        };\n\n        let suffix = value\n            .decor()\n            .suffix()\n            .and_then(|s| s.as_str())\n            .filter(|s| !s.trim_start().starts_with(\"# frozen:\"))\n            .map(str::to_string);\n\n        *value = toml_edit::Value::from(revision.rev.clone());\n\n        if let Some(frozen) = &revision.frozen {\n            value.decor_mut().set_suffix(format!(\" # frozen: {frozen}\"));\n        } else if let Some(suffix) = suffix {\n            value.decor_mut().set_suffix(suffix);\n        }\n    }\n\n    Ok(doc.to_string())\n}\n\nfn render_updated_yaml_config(\n    path: &Path,\n    content: &str,\n    revisions: &[Option<Revision>],\n) -> Result<String> {\n    let mut lines = content\n        .split_inclusive('\\n')\n        .map(ToString::to_string)\n        .collect::<Vec<_>>();\n\n    let rev_regex = regex!(r#\"^(\\s+)rev:(\\s*)(['\"]?)([^\\s#]+)(.*)(\\r?\\n)$\"#);\n\n    let rev_lines = lines\n        .iter()\n        .enumerate()\n        .filter_map(|(line_no, line)| {\n            if rev_regex.is_match(line) {\n                Some(line_no)\n            } else {\n                None\n            }\n        })\n        .collect::<Vec<_>>();\n\n    if rev_lines.len() != revisions.len() {\n        anyhow::bail!(\n            \"Found {} `rev:` lines in `{}` but expected {}, file content may have changed\",\n            rev_lines.len(),\n            path.user_display(),\n            revisions.len()\n        );\n    }\n\n    for (line_no, revision) in rev_lines.iter().zip_eq(revisions) {\n        let Some(revision) = revision else {\n            // This repo was not updated, skip\n            continue;\n        };\n\n        let caps = rev_regex\n            .captures(&lines[*line_no])\n            .context(\"Failed to capture rev line\")?;\n\n        let new_rev = serialize_yaml_scalar(&revision.rev, &caps[3])?;\n\n        let comment = if let Some(frozen) = &revision.frozen {\n            format!(\"  # frozen: {frozen}\")\n        } else if caps[5].trim_start().starts_with(\"# frozen:\") {\n            String::new()\n        } else {\n            caps[5].to_string()\n        };\n\n        lines[*line_no] = format!(\n            \"{}rev:{}{}{}{}\",\n            &caps[1], &caps[2], new_rev, comment, &caps[6]\n        );\n    }\n\n    Ok(lines.join(\"\"))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::process::Cmd;\n    use std::time::{SystemTime, UNIX_EPOCH};\n\n    async fn setup_test_repo() -> tempfile::TempDir {\n        let tmp = tempfile::tempdir().unwrap();\n        let repo = tmp.path();\n\n        // Initialize git repo\n        git::git_cmd(\"git init\")\n            .unwrap()\n            .arg(\"init\")\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        // Configure git user\n        git::git_cmd(\"git config\")\n            .unwrap()\n            .args([\"config\", \"user.email\", \"test@test.com\"])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        git::git_cmd(\"git config\")\n            .unwrap()\n            .args([\"config\", \"user.name\", \"Test\"])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        // First commit (required before creating a branch)\n        git::git_cmd(\"git commit\")\n            .unwrap()\n            .args([\n                \"-c\",\n                \"commit.gpgsign=false\",\n                \"commit\",\n                \"--allow-empty\",\n                \"-m\",\n                \"initial\",\n            ])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        // Create a trunk branch (avoid dangling commits)\n        git::git_cmd(\"git checkout\")\n            .unwrap()\n            .args([\"branch\", \"-M\", \"trunk\"])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        tmp\n    }\n\n    fn git_cmd(dir: impl AsRef<Path>, summary: &str) -> Cmd {\n        let mut cmd = git::git_cmd(summary).unwrap();\n        cmd.current_dir(dir)\n            .args([\"-c\", \"commit.gpgsign=false\"])\n            .args([\"-c\", \"tag.gpgsign=false\"]);\n        cmd\n    }\n\n    async fn create_commit(repo: &Path, message: &str) {\n        git_cmd(repo, \"git commit\")\n            .args([\"commit\", \"--allow-empty\", \"-m\", message])\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n    }\n\n    async fn create_backdated_commit(repo: &Path, message: &str, days_ago: u64) {\n        let timestamp = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .unwrap()\n            .as_secs()\n            - (days_ago * 86400);\n\n        let date_str = format!(\"{timestamp} +0000\");\n\n        git_cmd(repo, \"git commit\")\n            .args([\"commit\", \"--allow-empty\", \"-m\", message])\n            .env(\"GIT_AUTHOR_DATE\", &date_str)\n            .env(\"GIT_COMMITTER_DATE\", &date_str)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n    }\n\n    async fn create_lightweight_tag(repo: &Path, tag: &str) {\n        git_cmd(repo, \"git tag\")\n            .arg(\"tag\")\n            .arg(tag)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n    }\n\n    async fn create_annotated_tag(repo: &Path, tag: &str, days_ago: u64) {\n        let timestamp = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .unwrap()\n            .as_secs()\n            - (days_ago * 86400);\n\n        let date_str = format!(\"{timestamp} +0000\");\n\n        git_cmd(repo, \"git tag\")\n            .arg(\"tag\")\n            .arg(tag)\n            .arg(\"-m\")\n            .arg(tag)\n            .env(\"GIT_AUTHOR_DATE\", &date_str)\n            .env(\"GIT_COMMITTER_DATE\", &date_str)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n    }\n\n    fn get_backdated_timestamp(days_ago: u64) -> u64 {\n        let now = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .unwrap()\n            .as_secs();\n        now - (days_ago * 86400)\n    }\n\n    #[tokio::test]\n    async fn test_get_tag_timestamps() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_backdated_commit(repo, \"old\", 5).await;\n        create_lightweight_tag(repo, \"v0.1.0\").await;\n\n        create_backdated_commit(repo, \"new\", 2).await;\n        create_lightweight_tag(repo, \"v0.2.0\").await;\n        create_annotated_tag(repo, \"alias-v0.2.0\", 0).await;\n\n        let timestamps = get_tag_timestamps(repo).await.unwrap();\n        assert_eq!(timestamps.len(), 3);\n        assert_eq!(timestamps[0].0, \"alias-v0.2.0\");\n        assert_eq!(timestamps[1].0, \"v0.2.0\");\n        assert_eq!(timestamps[2].0, \"v0.1.0\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_bleeding_edge_prefers_exact_tag() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_commit(repo, \"tagged\").await;\n        create_lightweight_tag(repo, \"v1.2.3\").await;\n\n        git::git_cmd(\"git fetch\")\n            .unwrap()\n            .args([\"fetch\", \".\", \"HEAD\"])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        let rev = resolve_bleeding_edge(repo).await.unwrap();\n        assert_eq!(rev, Some(\"v1.2.3\".to_string()));\n    }\n\n    #[tokio::test]\n    async fn test_resolve_bleeding_edge_falls_back_to_rev_parse() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_commit(repo, \"untagged\").await;\n\n        git::git_cmd(\"git fetch\")\n            .unwrap()\n            .args([\"fetch\", \".\", \"HEAD\"])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap();\n\n        let rev = resolve_bleeding_edge(repo).await.unwrap();\n\n        let head = git::git_cmd(\"git rev-parse\")\n            .unwrap()\n            .args([\"rev-parse\", \"HEAD\"])\n            .current_dir(repo)\n            .remove_git_envs()\n            .output()\n            .await\n            .unwrap()\n            .stdout;\n        let head = String::from_utf8_lossy(&head).trim().to_string();\n\n        assert_eq!(rev, Some(head));\n    }\n\n    #[tokio::test]\n    async fn test_resolve_revision_uses_cooldown_bucket() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_backdated_commit(repo, \"candidate\", 5).await;\n        create_lightweight_tag(repo, \"v2.0.0-rc1\").await;\n        create_lightweight_tag(repo, \"totally-different\").await;\n\n        create_backdated_commit(repo, \"latest\", 1).await;\n        create_lightweight_tag(repo, \"v2.0.0\").await;\n\n        let rev = resolve_revision(repo, \"v2.0.0\", false, 3).await.unwrap();\n\n        assert_eq!(rev, Some(\"v2.0.0-rc1\".to_string()));\n    }\n\n    #[tokio::test]\n    async fn test_resolve_revision_returns_none_when_all_tags_too_new() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_backdated_commit(repo, \"recent-1\", 2).await;\n        create_lightweight_tag(repo, \"v1.0.0\").await;\n\n        create_backdated_commit(repo, \"recent-2\", 1).await;\n        create_lightweight_tag(repo, \"v1.1.0\").await;\n\n        let rev = resolve_revision(repo, \"v1.1.0\", false, 5).await.unwrap();\n\n        assert_eq!(rev, None);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_revision_picks_oldest_eligible_bucket() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_backdated_commit(repo, \"oldest\", 10).await;\n        create_lightweight_tag(repo, \"v1.0.0\").await;\n\n        create_backdated_commit(repo, \"mid\", 4).await;\n        create_lightweight_tag(repo, \"v1.1.0\").await;\n\n        create_backdated_commit(repo, \"newest\", 1).await;\n        create_lightweight_tag(repo, \"v1.2.0\").await;\n\n        let rev = resolve_revision(repo, \"v1.2.0\", false, 5).await.unwrap();\n\n        assert_eq!(rev, Some(\"v1.0.0\".to_string()));\n    }\n\n    #[tokio::test]\n    async fn test_resolve_revision_prefers_version_like_tags() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_backdated_commit(repo, \"eligible\", 2).await;\n        create_lightweight_tag(repo, \"moving-tag\").await;\n        create_lightweight_tag(repo, \"v1.0.0\").await;\n\n        // Even though the current rev matches the moving tag exactly, the dotted tag\n        // should be preferred.\n        let rev = resolve_revision(repo, \"moving-tag\", false, 1)\n            .await\n            .unwrap();\n\n        assert_eq!(rev, Some(\"v1.0.0\".to_string()));\n    }\n\n    #[tokio::test]\n    async fn test_resolve_revision_picks_closest_version_string() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        create_backdated_commit(repo, \"eligible\", 3).await;\n        create_lightweight_tag(repo, \"v1.2.0\").await;\n        create_lightweight_tag(repo, \"foo-1.2.0\").await;\n        create_lightweight_tag(repo, \"v2.0.0\").await;\n\n        let rev = resolve_revision(repo, \"v1.2.3\", false, 1).await.unwrap();\n\n        assert_eq!(rev, Some(\"v1.2.0\".to_string()));\n    }\n\n    #[tokio::test]\n    async fn test_get_tag_timestamps_stable_order_for_equal_timestamps() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        // Create multiple tags on the same commit (same timestamp)\n        create_backdated_commit(repo, \"release\", 5).await;\n        create_lightweight_tag(repo, \"v1.0.0\").await;\n        create_lightweight_tag(repo, \"v1.0.3\").await;\n        create_lightweight_tag(repo, \"v1.0.5\").await;\n        create_lightweight_tag(repo, \"v1.0.2\").await;\n\n        let timestamps = get_tag_timestamps(repo).await.unwrap();\n\n        // All timestamps are equal (tags on same commit).\n        // Within equal timestamps, semver tags should sort highest version first.\n        let tags: Vec<&str> = timestamps.iter().map(|(t, _)| t.as_str()).collect();\n        assert_eq!(tags, vec![\"v1.0.5\", \"v1.0.3\", \"v1.0.2\", \"v1.0.0\"]);\n    }\n\n    #[tokio::test]\n    async fn test_get_tag_timestamps_deterministic_order_for_equal_timestamp_non_semver() {\n        let tmp = setup_test_repo().await;\n        let repo = tmp.path();\n\n        // Lightweight tags on the same commit share a timestamp.\n        create_backdated_commit(repo, \"release\", 5).await;\n        create_lightweight_tag(repo, \"beta\").await;\n        create_lightweight_tag(repo, \"alpha\").await;\n        create_lightweight_tag(repo, \"gamma\").await;\n\n        let timestamps = get_tag_timestamps(repo).await.unwrap();\n        let tags: Vec<&str> = timestamps.iter().map(|(t, _)| t.as_str()).collect();\n        assert_eq!(tags, vec![\"alpha\", \"beta\", \"gamma\"]);\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/cache_clean.rs",
    "content": "use std::fmt::Write;\nuse std::fs::FileType;\nuse std::io;\nuse std::path::Path;\n\nuse anyhow::Result;\nuse owo_colors::OwoColorize;\nuse tracing::error;\n\nuse crate::cli::ExitStatus;\nuse crate::cli::cache_size::human_readable_bytes;\nuse crate::cli::reporter::CleaningReporter;\nuse crate::printer::Printer;\nuse crate::store::{CacheBucket, Store};\n\npub(crate) fn cache_clean(store: &Store, printer: Printer) -> Result<ExitStatus> {\n    if !store.path().exists() {\n        writeln!(printer.stdout(), \"{}\", \"Nothing to clean\".bold())?;\n        return Ok(ExitStatus::Success);\n    }\n\n    let num_paths = walkdir::WalkDir::new(store.path()).into_iter().count();\n    let reporter = CleaningReporter::new(printer, num_paths);\n\n    if let Err(e) = fix_permissions(store.cache_path(CacheBucket::Go))\n        && e.kind() != io::ErrorKind::NotFound\n    {\n        error!(\"Failed to fix permissions: {}\", e);\n    }\n\n    let removal = remove_dir_all(store.path(), Some(&reporter))?;\n\n    match (removal.num_files, removal.num_dirs) {\n        (0, 0) => {\n            write!(printer.stderr(), \"No cache entries found\")?;\n        }\n        (0, 1) => {\n            write!(printer.stderr(), \"Removed 1 directory\")?;\n        }\n        (0, num_dirs_removed) => {\n            write!(printer.stderr(), \"Removed {num_dirs_removed} directories\")?;\n        }\n        (1, _) => {\n            write!(printer.stderr(), \"Removed 1 file\")?;\n        }\n        (num_files_removed, _) => {\n            write!(printer.stderr(), \"Removed {num_files_removed} files\")?;\n        }\n    }\n\n    // If any, write a summary of the total byte count removed.\n    if removal.total_bytes > 0 {\n        let (bytes, unit) = human_readable_bytes(removal.total_bytes);\n        let bytes = format!(\"{bytes:.1}{unit}\");\n        write!(printer.stderr(), \" ({})\", bytes.cyan().bold())?;\n    }\n\n    writeln!(printer.stderr())?;\n\n    Ok(ExitStatus::Success)\n}\n\n#[derive(Debug, Default)]\npub struct RemovalStats {\n    pub num_files: u64,\n    pub num_dirs: u64,\n    pub total_bytes: u64,\n}\n\n/// Recursively remove a directory and all its contents.\nfn remove_dir_all(path: &Path, reporter: Option<&CleaningReporter>) -> io::Result<RemovalStats> {\n    match fs_err::symlink_metadata(path) {\n        Ok(metadata) => {\n            if !metadata.is_dir() {\n                return Err(io::Error::new(\n                    io::ErrorKind::NotADirectory,\n                    format!(\n                        \"Expected a directory at {}, but found a file\",\n                        path.display()\n                    ),\n                ));\n            }\n        }\n        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(RemovalStats::default()),\n        Err(err) => return Err(err),\n    }\n\n    let mut stats = RemovalStats::default();\n\n    for entry in walkdir::WalkDir::new(path).contents_first(true) {\n        let entry = entry?;\n        if entry.file_type().is_symlink() {\n            stats.num_files += 1;\n            if let Ok(metadata) = entry.metadata() {\n                stats.total_bytes += metadata.len();\n            }\n            remove_symlink(entry.path(), entry.file_type())?;\n        } else if entry.file_type().is_dir() {\n            stats.num_dirs += 1;\n            fs_err::remove_dir_all(entry.path())?;\n        } else {\n            stats.num_files += 1;\n            if let Ok(metadata) = entry.metadata() {\n                stats.total_bytes += metadata.len();\n            }\n            fs_err::remove_file(entry.path())?;\n        }\n\n        reporter.map(CleaningReporter::on_clean);\n    }\n\n    reporter.map(CleaningReporter::on_complete);\n\n    Ok(stats)\n}\n\nfn remove_symlink(path: &Path, file_type: FileType) -> io::Result<()> {\n    #[cfg(windows)]\n    {\n        use std::os::windows::fs::FileTypeExt;\n\n        if file_type.is_symlink_dir() {\n            fs_err::remove_dir(path)\n        } else {\n            fs_err::remove_file(path)\n        }\n    }\n    #[cfg(not(windows))]\n    {\n        let _ = file_type;\n        fs_err::remove_file(path)\n    }\n}\n\n/// Add write permission to GOMODCACHE directory recursively.\n/// Go sets the permissions to read-only by default.\n#[cfg(not(windows))]\npub fn fix_permissions<P: AsRef<Path>>(path: P) -> io::Result<()> {\n    use std::fs;\n    use std::os::unix::fs::PermissionsExt;\n\n    let path = path.as_ref();\n    let metadata = fs::metadata(path)?;\n\n    let mut permissions = metadata.permissions();\n    let current_mode = permissions.mode();\n\n    // Add write permissions for owner, group, and others\n    let new_mode = current_mode | 0o222;\n    permissions.set_mode(new_mode);\n    fs::set_permissions(path, permissions)?;\n\n    // If it's a directory, recursively process its contents\n    if metadata.is_dir() {\n        let entries = fs::read_dir(path)?;\n        for entry in entries {\n            let entry = entry?;\n            fix_permissions(entry.path())?;\n        }\n    }\n\n    Ok(())\n}\n\n#[cfg(windows)]\n#[allow(clippy::unnecessary_wraps)]\npub fn fix_permissions<P: AsRef<Path>>(_path: P) -> io::Result<()> {\n    // On Windows, permissions are handled differently and this function does nothing.\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::remove_dir_all;\n    use assert_fs::fixture::TempDir;\n\n    #[test]\n    fn rm_rf_counts_and_removes_tree() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n        let cache_root = temp.path().join(\"cache\");\n        fs_err::create_dir_all(cache_root.join(\"nested/deep\"))?;\n        fs_err::write(cache_root.join(\"root.txt\"), b\"hello\")?;\n        fs_err::write(cache_root.join(\"nested/data.txt\"), b\"abc\")?;\n        fs_err::write(cache_root.join(\"nested/deep/end.bin\"), b\"zz\")?;\n\n        let stats = remove_dir_all(&cache_root, None)?;\n        assert_eq!(stats.num_files, 3);\n        assert_eq!(stats.num_dirs, 3);\n        assert_eq!(stats.total_bytes, 10);\n        assert!(!cache_root.exists());\n\n        Ok(())\n    }\n\n    #[test]\n    fn rm_rf_empty_directory() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n        let cache_root = temp.path().join(\"cache\");\n        fs_err::create_dir_all(&cache_root)?;\n\n        let stats = remove_dir_all(&cache_root, None)?;\n        assert_eq!(stats.num_files, 0);\n        assert_eq!(stats.num_dirs, 1);\n        assert_eq!(stats.total_bytes, 0);\n        assert!(!cache_root.exists());\n\n        Ok(())\n    }\n\n    #[test]\n    fn rm_rf_rejects_non_directory() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n        let file_path = temp.path().join(\"not-a-dir.txt\");\n        fs_err::write(&file_path, b\"important data\")?;\n\n        let err = remove_dir_all(&file_path, None).unwrap_err();\n        assert_eq!(err.kind(), std::io::ErrorKind::NotADirectory);\n        assert!(file_path.exists(), \"file must not be deleted\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn rm_rf_non_exist_directory() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n        let dir_path = temp.path().join(\"non-existent\");\n\n        let stats = remove_dir_all(&dir_path, None)?;\n        assert_eq!(stats.num_files, 0);\n        assert_eq!(stats.num_dirs, 0);\n        assert_eq!(stats.total_bytes, 0);\n\n        Ok(())\n    }\n\n    #[cfg(unix)]\n    #[test]\n    fn rm_rf_counts_symlink_entries() -> anyhow::Result<()> {\n        use std::os::unix::fs::symlink;\n\n        let temp = TempDir::new()?;\n        let cache_root = temp.path().join(\"cache\");\n        fs_err::create_dir_all(&cache_root)?;\n\n        let link_path = cache_root.join(\"link-to-missing\");\n        symlink(\"missing-target\", &link_path)?;\n        let expected_len = fs_err::symlink_metadata(&link_path)?.len();\n\n        let stats = remove_dir_all(&cache_root, None)?;\n        assert_eq!(stats.num_files, 1);\n        assert_eq!(stats.num_dirs, 1);\n        assert_eq!(stats.total_bytes, expected_len);\n        assert!(!cache_root.exists());\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/cache_gc.rs",
    "content": "use std::fmt::Write;\nuse std::fmt::{Display, Formatter};\nuse std::ops::AddAssign;\nuse std::path::Path;\n\nuse anyhow::Result;\nuse owo_colors::OwoColorize;\nuse rustc_hash::FxHashMap;\nuse rustc_hash::FxHashSet;\nuse strum::IntoEnumIterator;\nuse tracing::{debug, trace, warn};\n\nuse crate::cli::ExitStatus;\nuse crate::cli::cache_size::{dir_size_bytes, human_readable_bytes};\nuse crate::config::{self, Error as ConfigError, Repo as ConfigRepo, load_config};\nuse crate::hook::{HOOK_MARKER, HookEnvKey, HookSpec, InstallInfo, Repo as HookRepo};\nuse crate::printer::Printer;\nuse crate::store::{CacheBucket, REPO_MARKER, Store, ToolBucket};\n\n#[derive(Debug, Copy, Clone, Eq, PartialEq)]\nenum RemovalKind {\n    Repos,\n    HookEnvs,\n    Tools,\n    CacheEntries,\n}\n\nimpl RemovalKind {\n    fn display(self, count: usize) -> &'static str {\n        if count > 1 {\n            match self {\n                RemovalKind::Repos => \"repos\",\n                RemovalKind::HookEnvs => \"hook envs\",\n                RemovalKind::Tools => \"tools\",\n                RemovalKind::CacheEntries => \"cache entries\",\n            }\n        } else {\n            match self {\n                RemovalKind::Repos => \"repo\",\n                RemovalKind::HookEnvs => \"hook env\",\n                RemovalKind::Tools => \"tool\",\n                RemovalKind::CacheEntries => \"cache entry\",\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct RemovalItem {\n    label: String,\n    abs_path: String,\n    lines: Vec<String>,\n}\n\nimpl RemovalItem {\n    fn new(label: String, abs_path: String) -> Self {\n        Self {\n            label,\n            abs_path,\n            lines: Vec::new(),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\nstruct Removal {\n    kind: RemovalKind,\n    count: usize,\n    bytes: u64,\n    items: Vec<RemovalItem>,\n}\n\nimpl Removal {\n    fn new(kind: RemovalKind) -> Self {\n        Self {\n            kind,\n            count: 0,\n            bytes: 0,\n            items: Vec::new(),\n        }\n    }\n}\n\nimpl Display for Removal {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(\n            f,\n            \"{} {}\",\n            self.count.cyan().bold(),\n            self.kind.display(self.count)\n        )\n    }\n}\n\nimpl AddAssign for Removal {\n    fn add_assign(&mut self, rhs: Self) {\n        debug_assert_eq!(self.kind, rhs.kind);\n\n        self.count += rhs.count;\n        self.bytes = self.bytes.saturating_add(rhs.bytes);\n        self.items.extend(rhs.items);\n    }\n}\n\n#[derive(Debug, Default)]\nstruct RemovalSummary {\n    parts: Vec<String>,\n    count: usize,\n    bytes: u64,\n}\n\nimpl RemovalSummary {\n    fn is_empty(&self) -> bool {\n        self.parts.is_empty()\n    }\n\n    fn joined(&self) -> String {\n        self.parts.join(\", \")\n    }\n\n    fn total_bytes(&self) -> u64 {\n        self.bytes\n    }\n}\n\nimpl AddAssign<&Removal> for RemovalSummary {\n    fn add_assign(&mut self, rhs: &Removal) {\n        if rhs.count > 0 {\n            self.parts.push(rhs.to_string());\n        }\n        self.count += rhs.count;\n        self.bytes = self.bytes.saturating_add(rhs.bytes);\n    }\n}\n\npub(crate) async fn cache_gc(\n    store: &Store,\n    dry_run: bool,\n    verbose: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let _lock = store.lock_async().await?;\n\n    let tracked_configs = store.tracked_configs()?;\n    if tracked_configs.is_empty() {\n        writeln!(printer.stdout(), \"{}\", \"Nothing to clean\".bold())?;\n        return Ok(ExitStatus::Success);\n    }\n\n    let mut kept_configs: FxHashSet<&Path> = FxHashSet::default();\n    let mut used_repo_keys: FxHashSet<String> = FxHashSet::default();\n    let mut used_hook_env_dirs: FxHashSet<String> = FxHashSet::default();\n    let mut used_tools: FxHashSet<ToolBucket> = FxHashSet::default();\n    let mut used_tool_versions: FxHashMap<ToolBucket, FxHashSet<String>> = FxHashMap::default();\n    let mut used_cache: FxHashSet<CacheBucket> = FxHashSet::default();\n    let mut used_env_keys: Vec<HookEnvKey> = Vec::new();\n\n    // Always keep Prek's own cache.\n    used_cache.insert(CacheBucket::Prek);\n\n    let installed = store.installed_hooks().await;\n\n    for config_path in &tracked_configs {\n        let config = match load_config(config_path) {\n            Ok(config) => {\n                trace!(path = %config_path.display(), \"Found tracked config\");\n                config\n            }\n            Err(err) => match err {\n                ConfigError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {\n                    debug!(path = %config_path.display(), \"Tracked config does not exist, dropping\");\n                    continue;\n                }\n                err => {\n                    warn!(path = %config_path.display(), %err, \"Failed to parse config, skipping for GC\");\n                    kept_configs.insert(config_path);\n                    continue;\n                }\n            },\n        };\n        kept_configs.insert(config_path);\n\n        used_env_keys.extend(hook_env_keys_from_config(store, &config));\n\n        // Mark repos referenced by this config (if present in store).\n        // We do this via config parsing (no clone), so GC won't keep repos for missing configs.\n        for repo in &config.repos {\n            if let ConfigRepo::Remote(remote) = repo {\n                let key = Store::repo_key(remote);\n                used_repo_keys.insert(key);\n            }\n        }\n    }\n\n    // Mark tools/caches from hook languages.\n    for key in &used_env_keys {\n        used_tools.extend(key.language.tool_buckets());\n        used_cache.extend(key.language.cache_buckets());\n    }\n\n    // Mark hook environments by matching already-installed env metadata.\n    // While doing this, try to derive the specific tool *version* directories in use from\n    // `InstallInfo.toolchain` (which is persisted in `.prek-hook.json`).\n    for info in &installed {\n        if used_env_keys.iter().any(|k| k.matches_install_info(info)) {\n            if let Some(dir) = info\n                .env_path\n                .file_name()\n                .and_then(|s| s.to_str())\n                .map(str::to_string)\n            {\n                used_hook_env_dirs.insert(dir);\n            }\n\n            mark_tool_versions_from_install_info(store, info, &mut used_tool_versions);\n        }\n    }\n\n    // Update tracking file to drop configs that no longer exist.\n    if !dry_run && kept_configs.len() != tracked_configs.len() {\n        let kept_configs = kept_configs.into_iter().map(Path::to_path_buf).collect();\n        store.update_tracked_configs(&kept_configs)?;\n    }\n\n    // Sweep repos/<hash>\n    let removed_repos = sweep_dir_by_name(\n        RemovalKind::Repos,\n        &store.repos_dir(),\n        &used_repo_keys,\n        dry_run,\n        verbose,\n    )?;\n\n    // Sweep hooks/<hash>\n    let removed_hooks = sweep_dir_by_name(\n        RemovalKind::HookEnvs,\n        &store.hooks_dir(),\n        &used_hook_env_dirs,\n        dry_run,\n        verbose,\n    )?;\n\n    // Sweep tools/<bucket>\n    let tools_root = store.tools_dir();\n    let used_tool_names: FxHashSet<String> = used_tools.iter().map(ToString::to_string).collect();\n    let removed_tool_buckets = sweep_dir_by_name(\n        RemovalKind::Tools,\n        &tools_root,\n        &used_tool_names,\n        dry_run,\n        verbose,\n    )?;\n\n    // Sweep tools/<bucket>/<version>\n    let removed_tool_versions = sweep_tool_versions(store, &used_tool_versions, dry_run, verbose)?;\n\n    let mut removed_tools = removed_tool_buckets;\n    removed_tools += removed_tool_versions;\n\n    // Sweep cache/<bucket>\n    let cache_root = store.cache_dir();\n    let used_cache_names: FxHashSet<String> = used_cache.iter().map(ToString::to_string).collect();\n    let removed_cache = sweep_dir_by_name(\n        RemovalKind::CacheEntries,\n        &cache_root,\n        &used_cache_names,\n        dry_run,\n        verbose,\n    )?;\n\n    // Seep scratch/, as it is only temporary data.\n    if !dry_run {\n        let _ = fs_err::remove_dir_all(store.scratch_path());\n    }\n    // NOTE: Do not clear `patches/` here. It can contain user-important temporary patches.\n    // A future enhancement could implement a safer cleanup strategy (e.g. GC patches older\n    // than a configurable age, or only remove patches known to be orphaned).\n    // let _ = fs_err::remove_dir_all(store.patches_dir())?;\n\n    let mut removed = RemovalSummary::default();\n    removed += &removed_repos;\n    removed += &removed_hooks;\n    removed += &removed_tools;\n    removed += &removed_cache;\n\n    let removed_total_bytes = removed.total_bytes();\n    let (removed_bytes, removed_unit) = human_readable_bytes(removed_total_bytes);\n\n    let verb = if dry_run { \"Would remove\" } else { \"Removed\" };\n    if removed.is_empty() {\n        writeln!(printer.stdout(), \"{}\", \"Nothing to clean\".bold())?;\n    } else {\n        writeln!(\n            printer.stdout(),\n            \"{verb} {} ({})\",\n            removed.joined(),\n            format!(\"{removed_bytes:.1}{removed_unit}\").cyan().bold(),\n        )?;\n\n        if verbose {\n            print_removed_details(printer, verb, &removed_repos)?;\n            print_removed_details(printer, verb, &removed_hooks)?;\n            print_removed_details(printer, verb, &removed_tools)?;\n            print_removed_details(printer, verb, &removed_cache)?;\n        }\n    }\n\n    Ok(ExitStatus::Success)\n}\n\nfn print_removed_details(printer: Printer, verb: &str, removal: &Removal) -> Result<()> {\n    if removal.count == 0 {\n        return Ok(());\n    }\n\n    writeln!(\n        printer.stdout(),\n        \"\\n{}:\",\n        format!(\"{verb} {removal}\").bold()\n    )?;\n\n    let mut items = removal.items.clone();\n    items.sort_unstable_by(|a, b| a.label.cmp(&b.label));\n    for item in items {\n        writeln!(printer.stdout(), \"{} {}\", \"-\".dimmed(), item.label.bold())?;\n        writeln!(\n            printer.stdout(),\n            \"  {}: {}\",\n            \"path\".bold().dimmed(),\n            item.abs_path\n        )?;\n\n        for line in item.lines {\n            writeln!(printer.stdout(), \"  {line}\")?;\n        }\n    }\n\n    Ok(())\n}\n\nfn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec<HookEnvKey> {\n    let mut keys = Vec::new();\n\n    for repo_config in &config.repos {\n        match repo_config {\n            ConfigRepo::Remote(repo_config) => {\n                let repo_path = store.repo_path(repo_config);\n                if !repo_path.is_dir() {\n                    continue;\n                }\n\n                let repo = match HookRepo::remote(\n                    repo_config.repo.clone(),\n                    repo_config.rev.clone(),\n                    repo_path,\n                ) {\n                    Ok(repo) => repo,\n                    Err(err) => {\n                        warn!(repo = %repo_config.repo, %err, \"Failed to load repo manifest, skipping\");\n                        continue;\n                    }\n                };\n\n                let remote_dep = repo_config.to_string();\n\n                for hook_config in &repo_config.hooks {\n                    let Some(manifest_hook) = repo.get_hook(&hook_config.id) else {\n                        continue;\n                    };\n\n                    let mut hook_spec = manifest_hook.clone();\n                    hook_spec.apply_remote_hook_overrides(hook_config);\n\n                    match HookEnvKey::from_hook_spec(config, hook_spec, Some(&remote_dep)) {\n                        Ok(Some(key)) => keys.push(key),\n                        Ok(None) => {}\n                        Err(err) => {\n                            warn!(hook = %hook_config.id, repo = %remote_dep, %err, \"Failed to compute hook env key, skipping\");\n                        }\n                    }\n                }\n            }\n            ConfigRepo::Local(repo_config) => {\n                for hook in &repo_config.hooks {\n                    let hook_spec = HookSpec::from(hook.clone());\n                    match HookEnvKey::from_hook_spec(config, hook_spec, None) {\n                        Ok(Some(key)) => keys.push(key),\n                        Ok(None) => {}\n                        Err(err) => {\n                            warn!(hook = %hook.id, %err, \"Failed to compute hook env key, skipping\");\n                        }\n                    }\n                }\n            }\n            _ => {} // Meta repos and builtin repos do not have hook envs.\n        }\n    }\n\n    keys\n}\n\nfn mark_tool_versions_from_install_info(\n    store: &Store,\n    info: &InstallInfo,\n    used_tool_versions: &mut FxHashMap<ToolBucket, FxHashSet<String>>,\n) {\n    // NOTE: `InstallInfo.toolchain` is typically the executable path (e.g.\n    // tools/go/1.24.0/bin/go). We keep the first path component under the tool bucket.\n    // If we can't recognize it, we do nothing (and GC will keep all versions).\n    for bucket in info.language.tool_buckets() {\n        let bucket_root = store.tools_path(*bucket);\n        if let Some(version) = tool_version_dir_name(&bucket_root, &info.toolchain) {\n            used_tool_versions\n                .entry(*bucket)\n                .or_default()\n                .insert(version);\n        }\n    }\n}\n\nfn tool_version_dir_name(bucket_root: &Path, toolchain: &Path) -> Option<String> {\n    let rel = toolchain.strip_prefix(bucket_root).ok()?;\n    let version = rel.components().next()?.as_os_str().to_str()?;\n    if version.is_empty() {\n        return None;\n    }\n    Some(version.to_string())\n}\n\nfn sweep_tool_versions(\n    store: &Store,\n    used_tool_versions: &FxHashMap<ToolBucket, FxHashSet<String>>,\n    dry_run: bool,\n    verbose: bool,\n) -> Result<Removal> {\n    let mut total = Removal::new(RemovalKind::Tools);\n\n    for bucket in ToolBucket::iter() {\n        let bucket_root = store.tools_path(bucket);\n        let keep_versions = used_tool_versions.get(&bucket);\n        let removed =\n            sweep_tool_bucket_versions(bucket, &bucket_root, keep_versions, dry_run, verbose)?;\n        total += removed;\n    }\n\n    Ok(total)\n}\n\nfn sweep_tool_bucket_versions(\n    bucket: ToolBucket,\n    bucket_root: &Path,\n    keep_versions: Option<&FxHashSet<String>>,\n    dry_run: bool,\n    collect_names: bool,\n) -> Result<Removal> {\n    let mut removal = Removal::new(RemovalKind::Tools);\n\n    let entries = match fs_err::read_dir(bucket_root) {\n        Ok(entries) => entries,\n        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n            return Ok(Removal::new(RemovalKind::Tools));\n        }\n        Err(err) => return Err(err.into()),\n    };\n\n    for entry in entries {\n        let entry = match entry {\n            Ok(entry) => entry,\n            Err(err) => {\n                warn!(%err, root = %bucket_root.display(), \"Failed to read tool bucket entry\");\n                continue;\n            }\n        };\n        let path = entry.path();\n        // Don't remove files (uv, and rustup are files inside tools/).\n        if !path.is_dir() {\n            continue;\n        }\n\n        let Some(version_name) = path.file_name().and_then(|n| n.to_str()) else {\n            continue;\n        };\n        // Skip hidden/system dirs.\n        if version_name.starts_with('.') {\n            continue;\n        }\n        if keep_versions.is_some_and(|keep| keep.contains(version_name)) {\n            continue;\n        }\n\n        let entry_bytes = dir_size_bytes(&path);\n\n        let item = if collect_names {\n            Some(RemovalItem::new(\n                format!(\"{bucket}/{version_name}\"),\n                path.to_string_lossy().to_string(),\n            ))\n        } else {\n            None\n        };\n\n        if dry_run {\n            removal.count += 1;\n            removal.bytes = removal.bytes.saturating_add(entry_bytes);\n            if let Some(item) = item {\n                removal.items.push(item);\n            }\n            continue;\n        }\n\n        if let Err(err) = fs_err::remove_dir_all(&path) {\n            warn!(%err, path = %path.display(), \"Failed to remove unused tool version\");\n        } else {\n            removal.count += 1;\n            removal.bytes = removal.bytes.saturating_add(entry_bytes);\n            if let Some(item) = item {\n                removal.items.push(item);\n            }\n        }\n    }\n\n    Ok(removal)\n}\n\nfn sweep_dir_by_name(\n    kind: RemovalKind,\n    root: &Path,\n    keep_names: &FxHashSet<String>,\n    dry_run: bool,\n    collect_names: bool,\n) -> Result<Removal> {\n    let mut removal = Removal::new(kind);\n    let entries = match fs_err::read_dir(root) {\n        Ok(entries) => entries,\n        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Removal::new(kind)),\n        Err(err) => return Err(err.into()),\n    };\n\n    for entry in entries {\n        let entry = match entry {\n            Ok(entry) => entry,\n            Err(err) => {\n                warn!(%err, root = %root.display(), \"Failed to read store entry\");\n                continue;\n            }\n        };\n        let path = entry.path();\n        if !path.is_dir() {\n            continue;\n        }\n\n        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {\n            continue;\n        };\n        // Skip hidden/system dirs.\n        if name.starts_with('.') {\n            continue;\n        }\n        if keep_names.contains(name) {\n            continue;\n        }\n\n        let entry_bytes = dir_size_bytes(&path);\n\n        let item = if collect_names {\n            let repo_marker = (kind == RemovalKind::Repos)\n                .then(|| read_repo_marker(&path))\n                .flatten();\n            let hook_marker = (kind == RemovalKind::HookEnvs)\n                .then(|| read_hook_marker(&path))\n                .flatten();\n\n            let mut item = RemovalItem::new(name.to_string(), path.to_string_lossy().to_string());\n\n            if let Some(label) = label_for_entry(kind, repo_marker.as_ref(), hook_marker.as_ref()) {\n                item.label = label;\n            }\n\n            item.lines = detail_lines_for_entry(kind, repo_marker.as_ref(), hook_marker.as_ref());\n            Some(item)\n        } else {\n            None\n        };\n\n        if dry_run {\n            removal.count += 1;\n            removal.bytes = removal.bytes.saturating_add(entry_bytes);\n            if collect_names && let Some(item) = item {\n                removal.items.push(item);\n            }\n            continue;\n        }\n\n        // Best-effort cleanup.\n        if let Err(err) = fs_err::remove_dir_all(&path) {\n            warn!(%err, path = %path.display(), \"Failed to remove unused cache entry\");\n        } else {\n            removal.count += 1;\n            removal.bytes = removal.bytes.saturating_add(entry_bytes);\n            if collect_names {\n                if let Some(item) = item {\n                    removal.items.push(item);\n                }\n            }\n        }\n    }\n\n    Ok(removal)\n}\n\nfn label_for_entry(\n    kind: RemovalKind,\n    repo_marker: Option<&RepoMarker>,\n    hook_marker: Option<&InstallInfo>,\n) -> Option<String> {\n    match kind {\n        RemovalKind::Repos => repo_marker.map(|repo| format!(\"{}@{}\", repo.repo, repo.rev)),\n        RemovalKind::HookEnvs => hook_marker.map(|info| {\n            // Keep this short; more info goes in detail lines.\n            format!(\"{} env\", info.language.as_ref())\n        }),\n        _ => None,\n    }\n}\n\nfn detail_lines_for_entry(\n    kind: RemovalKind,\n    _repo_marker: Option<&RepoMarker>,\n    hook_marker: Option<&InstallInfo>,\n) -> Vec<String> {\n    const MAX_VALUE_CHARS: usize = 140;\n\n    match kind {\n        RemovalKind::Repos => vec![],\n        RemovalKind::HookEnvs => {\n            let Some(info) = hook_marker else {\n                return Vec::new();\n            };\n\n            let mut lines = Vec::new();\n            lines.push(format!(\n                \"{}: {} ({})\",\n                \"language\".dimmed().bold(),\n                info.language.as_ref(),\n                info.language_version\n            ));\n\n            let (repo_dep, deps) = split_repo_dependency(&info.dependencies);\n            if let Some(repo_dep) = repo_dep {\n                lines.push(format!(\n                    \"{}: {}\",\n                    \"repo\".dimmed().bold(),\n                    truncate_end(&repo_dep, MAX_VALUE_CHARS)\n                ));\n            }\n            if !deps.is_empty() {\n                let deps_str = format_dependency_list(&deps, 6, MAX_VALUE_CHARS);\n                lines.push(format!(\"{}: {deps_str}\", \"deps\".dimmed().bold()));\n            }\n            lines\n        }\n        _ => Vec::new(),\n    }\n}\n\n#[derive(Debug, serde::Deserialize)]\nstruct RepoMarker {\n    repo: String,\n    rev: String,\n}\n\nfn read_repo_marker(root: &Path) -> Option<RepoMarker> {\n    // NOTE: `Store::clone_repo` serializes `RemoteRepo`, but with some fields skipped during\n    // serialization (e.g. `hooks`). That means deserializing back into `RemoteRepo` can fail.\n    // For GC display, we only need `repo` + `rev`.\n    let content = fs_err::read_to_string(root.join(REPO_MARKER)).ok()?;\n    serde_json::from_str(&content).ok()\n}\n\nfn read_hook_marker(root: &Path) -> Option<InstallInfo> {\n    let content = fs_err::read_to_string(root.join(HOOK_MARKER)).ok()?;\n    serde_json::from_str(&content).ok()\n}\n\nfn truncate_end(s: &str, max_chars: usize) -> String {\n    if s.chars().count() <= max_chars {\n        return s.to_string();\n    }\n    let mut out = s\n        .chars()\n        .take(max_chars.saturating_sub(1))\n        .collect::<String>();\n    out.push('…');\n    out\n}\n\nfn split_repo_dependency(deps: &FxHashSet<String>) -> (Option<String>, Vec<String>) {\n    // Best-effort: the remote repo dependency is typically `repo@rev`.\n    // Prefer URL-like values to avoid accidentally treating PEP508 deps as repo identifiers.\n    let mut repo_dep: Option<String> = None;\n    let mut rest = Vec::new();\n\n    for dep in deps {\n        if repo_dep.is_none()\n            && dep.contains('@')\n            && (dep.contains(\"://\")\n                || dep.starts_with('/')\n                || dep.starts_with(\"..\")\n                || dep.starts_with('.'))\n        {\n            repo_dep = Some(dep.clone());\n        } else {\n            rest.push(dep.clone());\n        }\n    }\n\n    rest.sort_unstable();\n    (repo_dep, rest)\n}\n\nfn format_dependency_list(deps: &[String], max_items: usize, max_chars: usize) -> String {\n    if deps.is_empty() {\n        return String::new();\n    }\n\n    let shown: Vec<&str> = deps.iter().take(max_items).map(String::as_str).collect();\n    let extra = deps.len().saturating_sub(shown.len());\n    let mut rendered = shown.join(\", \");\n    if extra > 0 {\n        let _ = write!(&mut rendered, \", … (+{extra} more)\");\n    }\n    truncate_end(&rendered, max_chars)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn truncate_end_returns_input_when_short_enough() {\n        assert_eq!(truncate_end(\"abc\", 3), \"abc\");\n        assert_eq!(truncate_end(\"abc\", 10), \"abc\");\n    }\n\n    #[test]\n    fn truncate_end_truncates_and_appends_ellipsis() {\n        assert_eq!(truncate_end(\"abcd\", 3), \"ab…\");\n        assert_eq!(truncate_end(\"abcdef\", 5), \"abcd…\");\n    }\n\n    #[test]\n    fn truncate_end_counts_chars_not_bytes() {\n        // 3 unicode scalar values.\n        assert_eq!(truncate_end(\"ééé\", 3), \"ééé\");\n        assert_eq!(truncate_end(\"ééé\", 2), \"é…\");\n    }\n\n    #[test]\n    fn split_repo_dependency_prefers_url_like_repo_at_rev() {\n        let mut deps = FxHashSet::default();\n        deps.insert(\"requests==2.32.0\".to_string());\n        deps.insert(\"black==24.1.0\".to_string());\n        deps.insert(\"https://github.com/pre-commit/pre-commit-hooks@v1.0.0\".to_string());\n\n        let (repo_dep, rest) = split_repo_dependency(&deps);\n\n        assert_eq!(\n            repo_dep.as_deref(),\n            Some(\"https://github.com/pre-commit/pre-commit-hooks@v1.0.0\")\n        );\n        assert_eq!(rest, vec![\"black==24.1.0\", \"requests==2.32.0\"]);\n    }\n\n    #[test]\n    fn split_repo_dependency_returns_none_when_no_repo_like_dep() {\n        let mut deps = FxHashSet::default();\n        deps.insert(\"requests==2.32.0\".to_string());\n        deps.insert(\"black==24.1.0\".to_string());\n\n        let (repo_dep, rest) = split_repo_dependency(&deps);\n        assert!(repo_dep.is_none());\n        assert_eq!(rest, vec![\"black==24.1.0\", \"requests==2.32.0\"]);\n    }\n\n    #[test]\n    fn format_dependency_list_includes_more_suffix() {\n        let deps = vec![\"a\".to_string(), \"b\".to_string(), \"c\".to_string()];\n        assert_eq!(format_dependency_list(&deps, 2, 200), \"a, b, … (+1 more)\");\n    }\n\n    #[test]\n    fn format_dependency_list_truncates_rendered_string() {\n        let deps = vec![\"abcdef\".to_string()];\n        assert_eq!(format_dependency_list(&deps, 6, 5), \"abcd…\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/cache_size.rs",
    "content": "use std::fmt::Write;\nuse std::path::Path;\n\nuse anyhow::Result;\n\nuse crate::cli::ExitStatus;\nuse crate::printer::Printer;\nuse crate::store::Store;\n\n/// Display the total size of the cache.\npub(crate) fn cache_size(\n    store: &Store,\n    human_readable: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    // Walk the entire cache root\n    let total_bytes = dir_size_bytes(store.path());\n    if human_readable {\n        let (bytes, unit) = human_readable_bytes(total_bytes);\n        writeln!(printer.stdout_important(), \"{bytes:.1}{unit}\")?;\n    } else {\n        writeln!(printer.stdout_important(), \"{total_bytes}\")?;\n    }\n\n    Ok(ExitStatus::Success)\n}\n\n/// Formats a number of bytes into a human readable SI-prefixed size (binary units).\n///\n/// Returns a tuple of `(quantity, units)`.\n#[allow(\n    clippy::cast_possible_truncation,\n    clippy::cast_possible_wrap,\n    clippy::cast_precision_loss,\n    clippy::cast_sign_loss\n)]\npub(crate) fn human_readable_bytes(bytes: u64) -> (f32, &'static str) {\n    const UNITS: [&str; 7] = [\"B\", \"KiB\", \"MiB\", \"GiB\", \"TiB\", \"PiB\", \"EiB\"];\n\n    let bytes_f32 = bytes as f32;\n    let i = ((bytes_f32.log2() / 10.0) as usize).min(UNITS.len() - 1);\n    (bytes_f32 / 1024_f32.powi(i as i32), UNITS[i])\n}\n\npub(crate) fn dir_size_bytes(path: &Path) -> u64 {\n    if !path.exists() {\n        return 0;\n    }\n\n    walkdir::WalkDir::new(path)\n        .follow_links(false)\n        .into_iter()\n        .filter_map(Result::ok)\n        .filter_map(|entry| match entry.metadata() {\n            Ok(metadata) if metadata.is_file() => Some(metadata.len()),\n            _ => None,\n        })\n        .sum()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{dir_size_bytes, human_readable_bytes};\n    use assert_fs::fixture::TempDir;\n\n    #[test]\n    fn human_readable_bytes_handles_zero() {\n        let (value, unit) = human_readable_bytes(0);\n        assert!(value.abs() < f32::EPSILON);\n        assert_eq!(unit, \"B\");\n    }\n\n    #[test]\n    fn dir_stats_missing_directory() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n        let missing = temp.path().join(\"missing\");\n\n        assert_eq!(dir_size_bytes(&missing), 0);\n\n        Ok(())\n    }\n\n    #[test]\n    fn dir_stats_empty_directory() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n\n        assert_eq!(dir_size_bytes(temp.path()), 0);\n\n        Ok(())\n    }\n\n    #[test]\n    fn dir_stats_nested_files() -> anyhow::Result<()> {\n        let temp = TempDir::new()?;\n        let nested = temp.path().join(\"nested/deep\");\n        fs_err::create_dir_all(&nested)?;\n        fs_err::write(temp.path().join(\"root.txt\"), b\"hello\")?;\n        fs_err::write(temp.path().join(\"nested/data.txt\"), b\"abc\")?;\n        fs_err::write(temp.path().join(\"nested/deep/end.bin\"), b\"zz\")?;\n\n        assert_eq!(dir_size_bytes(temp.path()), 10);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/completion.rs",
    "content": "use std::collections::{BTreeMap, BTreeSet};\nuse std::ffi::OsStr;\nuse std::path::Path;\n\nuse clap::builder::StyledStr;\nuse clap_complete::CompletionCandidate;\n\nuse crate::config;\nuse crate::fs::CWD;\nuse crate::store::Store;\nuse crate::workspace::{Project, Workspace};\n\n/// Provide completion candidates for `include` and `skip` selectors.\npub(crate) fn selector_completer(current: &OsStr) -> Vec<CompletionCandidate> {\n    let Some(current_str) = current.to_str() else {\n        return vec![];\n    };\n\n    let Ok(store) = Store::from_settings() else {\n        return vec![];\n    };\n    let Ok(workspace) = Workspace::find_root(None, &CWD)\n        .and_then(|root| Workspace::discover(&store, root, None, None, false))\n    else {\n        return vec![];\n    };\n\n    let mut candidates: Vec<CompletionCandidate> = vec![];\n\n    // Support optional `path:hook_prefix` form while typing.\n    let (path_part, hook_prefix_opt) = match current_str.split_once(':') {\n        Some((p, rest)) => (p, Some(rest)),\n        None => (current_str, None),\n    };\n\n    if path_part.contains('/') {\n        // Provide subdirectory matches relative to cwd for the path prefix\n        let path_obj = Path::new(path_part);\n        let (base_dir, shown_prefix, filter_prefix) = if path_part.ends_with('/') {\n            (CWD.join(path_obj), path_part.to_string(), String::new())\n        } else {\n            let parent = path_obj.parent().unwrap_or(Path::new(\"\"));\n            let file = path_obj.file_name().and_then(OsStr::to_str).unwrap_or(\"\");\n            let shown_prefix = if parent.as_os_str().is_empty() {\n                String::new()\n            } else {\n                format!(\"{}/\", parent.display())\n            };\n            (CWD.join(parent), shown_prefix, file.to_string())\n        };\n        let mut had_children = false;\n        if hook_prefix_opt.is_none() {\n            let mut child_dirs = list_subdirs(&base_dir, &shown_prefix, &filter_prefix, &workspace);\n            let mut child_colons =\n                list_direct_project_colons(&base_dir, &shown_prefix, &filter_prefix, &workspace);\n            had_children = !(child_dirs.is_empty() && child_colons.is_empty());\n            candidates.append(&mut child_dirs);\n            candidates.append(&mut child_colons);\n        }\n\n        // If the path refers to a project directory in the workspace and a colon is present,\n        // suggest `path:hook_id`. For pure path input (no colon), don't suggest hooks.\n        let project_dir_abs = if path_part.ends_with('/') {\n            CWD.join(path_part.trim_end_matches('/'))\n        } else {\n            CWD.join(path_obj)\n        };\n        if hook_prefix_opt.is_some() {\n            if let Some(proj) = workspace\n                .projects()\n                .iter()\n                .find(|p| p.path() == project_dir_abs)\n            {\n                let hook_pairs = all_hooks(proj);\n                let path_prefix_display = if path_part.ends_with('/') {\n                    path_part.trim_end_matches('/')\n                } else {\n                    path_part\n                };\n                for (hid, name) in hook_pairs {\n                    if let Some(hpref) = hook_prefix_opt {\n                        if !hid.starts_with(hpref) && !hid.contains(hpref) {\n                            continue;\n                        }\n                    }\n                    let value = format!(\"{path_prefix_display}:{hid}\");\n                    candidates\n                        .push(CompletionCandidate::new(value).help(name.map(StyledStr::from)));\n                }\n            }\n        } else if path_part.ends_with('/') {\n            // No colon and trailing slash: if this base dir is a leaf project (no child projects),\n            // suggest the directory itself (with trailing '/').\n            let is_project = workspace\n                .projects()\n                .iter()\n                .any(|p| p.path() == project_dir_abs);\n            if is_project && !had_children {\n                candidates.push(CompletionCandidate::new(path_part.to_string()));\n            }\n        }\n\n        return candidates;\n    }\n\n    // No slash: match subdirectories under cwd and hook ids across workspace\n    candidates.extend(list_subdirs(&CWD, \"\", current_str, &workspace));\n    // Also suggest immediate child project roots as `name:`\n    candidates.extend(list_direct_project_colons(\n        &CWD,\n        \"\",\n        current_str,\n        &workspace,\n    ));\n\n    // If the input ends with `:`, suggest hooks for that project\n    if let Some(hook_prefix) = hook_prefix_opt {\n        if !path_part.is_empty() {\n            let project_dir_abs = CWD.join(Path::new(path_part));\n            if let Some(proj) = workspace\n                .projects()\n                .iter()\n                .find(|p| p.path() == project_dir_abs)\n            {\n                for (hid, name) in all_hooks(proj) {\n                    if !hook_prefix.is_empty()\n                        && !hid.starts_with(hook_prefix)\n                        && !hid.contains(hook_prefix)\n                    {\n                        continue;\n                    }\n                    let value = format!(\"{path_part}:{hid}\");\n                    candidates\n                        .push(CompletionCandidate::new(value).help(name.map(StyledStr::from)));\n                }\n            }\n        }\n    }\n\n    // Aggregate unique hooks and filter by id\n    let mut uniq: BTreeMap<String, Option<String>> = BTreeMap::new();\n    for proj in workspace.projects() {\n        for (id, name) in all_hooks(proj) {\n            if id.contains(current_str) || id.starts_with(current_str) {\n                uniq.entry(id).or_insert(name);\n            }\n        }\n    }\n    candidates.extend(\n        uniq.into_iter()\n            .map(|(id, name)| CompletionCandidate::new(id).help(name.map(StyledStr::from))),\n    );\n\n    candidates\n}\n\nfn all_hooks(proj: &Project) -> Vec<(String, Option<String>)> {\n    let mut out = Vec::new();\n    for repo in &proj.config().repos {\n        match repo {\n            config::Repo::Remote(cfg) => {\n                for h in &cfg.hooks {\n                    out.push((h.id.clone(), h.name.as_ref().map(ToString::to_string)));\n                }\n            }\n            config::Repo::Local(cfg) => {\n                for h in &cfg.hooks {\n                    out.push((h.id.clone(), Some(h.name.clone())));\n                }\n            }\n            config::Repo::Meta(cfg) => {\n                for h in &cfg.hooks {\n                    out.push((h.id.clone(), Some(h.name.clone())));\n                }\n            }\n            config::Repo::Builtin(cfg) => {\n                for h in &cfg.hooks {\n                    out.push((h.id.clone(), Some(h.name.clone())));\n                }\n            }\n        }\n    }\n    out\n}\n\n// List subdirectories under base that contain projects (immediate or nested),\n// derived solely from workspace discovery; always end with '/'\nfn list_subdirs(\n    base: &Path,\n    shown_prefix: &str,\n    filter_prefix: &str,\n    workspace: &Workspace,\n) -> Vec<CompletionCandidate> {\n    let mut out = Vec::new();\n    let mut first_components: BTreeSet<String> = BTreeSet::new();\n    for proj in workspace.projects() {\n        let p = proj.path();\n        if let Ok(rel) = p.strip_prefix(base) {\n            if rel.as_os_str().is_empty() {\n                // Project is exactly at base; doesn't yield a child directory\n                continue;\n            }\n            if let Some(first) = rel.components().next() {\n                let name = first.as_os_str().to_string_lossy().to_string();\n                first_components.insert(name);\n            }\n        }\n    }\n    for name in first_components {\n        if filter_prefix.is_empty()\n            || name.starts_with(filter_prefix)\n            || name.contains(filter_prefix)\n        {\n            let mut value = String::new();\n            value.push_str(shown_prefix);\n            value.push_str(&name);\n            if !value.ends_with('/') {\n                value.push('/');\n            }\n            out.push(CompletionCandidate::new(value));\n        }\n    }\n\n    out\n}\n\n// List immediate child directories under `base` that are themselves project roots,\n// suggesting them as `name:` (or `shown_prefix + name + :`)\nfn list_direct_project_colons(\n    base: &Path,\n    shown_prefix: &str,\n    filter_prefix: &str,\n    workspace: &Workspace,\n) -> Vec<CompletionCandidate> {\n    // Build a set of absolute project paths for quick lookup\n    let proj_paths: BTreeSet<_> = workspace\n        .projects()\n        .iter()\n        .map(|p| p.path().to_path_buf())\n        .collect();\n\n    // Compute immediate child names that lead to at least one project (same logic as list_subdirs)\n    // then keep only those where `base/child` is itself a project root.\n    let mut names: BTreeSet<String> = BTreeSet::new();\n    for proj in workspace.projects() {\n        let p = proj.path();\n        if let Ok(rel) = p.strip_prefix(base) {\n            if rel.as_os_str().is_empty() {\n                continue;\n            }\n            if let Some(first) = rel.components().next() {\n                let name = first.as_os_str().to_string_lossy().to_string();\n                // Only keep if this immediate child is a project root\n                let child_abs = base.join(&name);\n                if proj_paths.contains(&child_abs) {\n                    names.insert(name);\n                }\n            }\n        }\n    }\n\n    let mut out = Vec::new();\n    for name in names {\n        if filter_prefix.is_empty()\n            || name.starts_with(filter_prefix)\n            || name.contains(filter_prefix)\n        {\n            let mut value = String::new();\n            value.push_str(shown_prefix);\n            value.push_str(&name);\n            value.push(':');\n            out.push(CompletionCandidate::new(value));\n        }\n    }\n    out\n}\n"
  },
  {
    "path": "crates/prek/src/cli/hook_impl.rs",
    "content": "use std::ffi::OsString;\nuse std::fmt::Write;\nuse std::ops::RangeInclusive;\nuse std::path::PathBuf;\nuse std::process::Stdio;\n\nuse anstream::eprintln;\nuse anyhow::Result;\nuse itertools::Itertools;\nuse owo_colors::OwoColorize;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\n\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::cli::{self, ExitStatus, RunArgs};\nuse crate::config::HookType;\nuse crate::fs::CWD;\nuse crate::git::GIT_ROOT;\nuse crate::languages::resolve_command;\nuse crate::printer::Printer;\nuse crate::process::Cmd;\nuse crate::store::Store;\nuse crate::workspace;\nuse crate::workspace::Project;\nuse crate::{git, warn_user};\n\npub(crate) async fn hook_impl(\n    store: &Store,\n    config: Option<PathBuf>,\n    includes: Vec<String>,\n    skips: Vec<String>,\n    hook_type: HookType,\n    hook_dir: PathBuf,\n    skip_on_missing_config: bool,\n    script_version: Option<usize>,\n    args: Vec<OsString>,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let stdin = read_hook_stdin(hook_type).await?;\n    let legacy_code = run_legacy(hook_type, &hook_dir, &args, &stdin).await?;\n\n    if script_version != Some(cli::install::CUR_SCRIPT_VERSION) {\n        warn_user!(\n            \"The installed Git shim `{hook_type}` is outdated (version: {:?}, expected: {}). Please reinstall the Git shims with `prek install`.\",\n            script_version.unwrap_or(1),\n            cli::install::CUR_SCRIPT_VERSION\n        );\n    }\n\n    let allow_missing_config =\n        skip_on_missing_config || EnvVars::is_set(EnvVars::PREK_ALLOW_NO_CONFIG);\n    let warn_for_no_config = || {\n        eprintln!(\n            \"- To temporarily silence this, run `{}`\",\n            format!(\"{}=1 git ...\", EnvVars::PREK_ALLOW_NO_CONFIG).cyan()\n        );\n        eprintln!(\n            \"- To permanently silence this, install hooks with the `{}` flag\",\n            \"--allow-missing-config\".cyan()\n        );\n        eprintln!(\"- To uninstall hooks, run `{}`\", \"prek uninstall\".cyan());\n    };\n\n    // Check if there is config file\n    if let Some(ref config) = config {\n        if !config.try_exists()? {\n            return if allow_missing_config {\n                Ok(legacy_code.into())\n            } else {\n                eprintln!(\n                    \"{}: config file not found: `{}`\",\n                    \"error\".red().bold(),\n                    config.display().cyan()\n                );\n                warn_for_no_config();\n\n                Ok(ExitStatus::Failure)\n            };\n        }\n        writeln!(printer.stdout(), \"Using config file: {}\", config.display())?;\n    } else {\n        // Try to discover a project from current directory (after `--cd`)\n        match Project::discover(config.as_deref(), &CWD) {\n            Err(e @ workspace::Error::MissingConfigFile) => {\n                return if allow_missing_config {\n                    Ok(legacy_code.into())\n                } else {\n                    eprintln!(\"{}: {e}\", \"error\".red().bold());\n                    warn_for_no_config();\n\n                    Ok(ExitStatus::Failure)\n                };\n            }\n            Ok(project) => {\n                if project.path() != GIT_ROOT.as_ref()? {\n                    writeln!(\n                        printer.stdout(),\n                        \"Running in workspace: `{}`\",\n                        project.path().display().cyan()\n                    )?;\n                }\n            }\n            Err(e) => return Err(e.into()),\n        }\n    }\n\n    if !hook_type.num_args().contains(&args.len()) {\n        anyhow::bail!(\n            \"hook `{}` expects {} but received {}{}\",\n            hook_type.to_string().cyan(),\n            format_expected_args(hook_type.num_args()),\n            format_received_args(args.len()),\n            format_argument_dump(&args)\n        );\n    }\n\n    let Some(run_args) = to_run_args(hook_type, &args, &stdin).await else {\n        return Ok(legacy_code.into());\n    };\n\n    let status = cli::run(\n        store,\n        config,\n        includes,\n        skips,\n        Some(hook_type.into()),\n        run_args.from_ref,\n        run_args.to_ref,\n        run_args.all_files,\n        vec![],\n        vec![],\n        false,\n        false,\n        run_args.fail_fast,\n        false,\n        false,\n        run_args.extra,\n        false,\n        printer,\n    )\n    .await?;\n\n    Ok(if !matches!(status, ExitStatus::Success) {\n        status\n    } else {\n        legacy_code.into()\n    })\n}\n\nasync fn read_hook_stdin(hook_type: HookType) -> Result<Vec<u8>> {\n    if !matches!(hook_type, HookType::PrePush) {\n        return Ok(vec![]);\n    }\n\n    let mut stdin = tokio::io::stdin();\n    let mut buffer = vec![];\n    stdin.read_to_end(&mut buffer).await?;\n    Ok(buffer)\n}\n\nasync fn run_legacy(\n    hook_type: HookType,\n    hook_dir: &std::path::Path,\n    args: &[OsString],\n    stdin: &[u8],\n) -> Result<u8> {\n    if EnvVars::is_set(EnvVars::PREK_RUNNING_LEGACY) {\n        anyhow::bail!(\n            \"prek's Git shim is installed in migration mode\\n\\\n            run `prek install -f --hook-type {hook_type}` to reinstall the shim\"\n        );\n    }\n\n    let legacy_hook = hook_dir.join(format!(\"{hook_type}.legacy\"));\n    let metadata = match fs_err::tokio::metadata(&legacy_hook).await {\n        Ok(metadata) => metadata,\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {\n            // No legacy hook, so skip running it.\n            return Ok(0);\n        }\n        Err(e) => return Err(e.into()),\n    };\n    let executable;\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        executable = metadata.permissions().mode() & 0o111 != 0;\n    }\n    #[cfg(not(unix))]\n    {\n        executable = true;\n        _ = metadata;\n    }\n    if !executable {\n        return Ok(0);\n    }\n\n    let entry = resolve_command(vec![legacy_hook.to_string_lossy().into_owned()], None);\n    let mut cmd = Cmd::new(&entry[0], format!(\"legacy hook `{}`\", hook_type.as_ref()));\n    cmd.check(false).args(&entry[1..]).args(args);\n    cmd.env(EnvVars::PREK_RUNNING_LEGACY, \"1\");\n\n    let status = if stdin.is_empty() {\n        cmd.status().await?\n    } else {\n        cmd.stdin(Stdio::piped());\n        let mut child = cmd.spawn()?;\n        if let Some(mut child_stdin) = child.stdin.take() {\n            child_stdin.write_all(stdin).await?;\n        }\n        child.wait().await?\n    };\n\n    Ok(status\n        .code()\n        .and_then(|code| u8::try_from(code).ok())\n        .unwrap_or(1))\n}\n\nasync fn to_run_args(hook_type: HookType, args: &[OsString], stdin: &[u8]) -> Option<RunArgs> {\n    let mut run_args = RunArgs::default();\n\n    match hook_type {\n        HookType::PrePush => {\n            // https://git-scm.com/docs/githooks#_pre_push\n            run_args.extra.remote_name = Some(args[0].to_string_lossy().into_owned());\n            run_args.extra.remote_url = Some(args[1].to_string_lossy().into_owned());\n\n            if let Some(push_info) = parse_pre_push_info(&args[0].to_string_lossy(), stdin).await {\n                run_args.from_ref = push_info.from_ref;\n                run_args.to_ref = push_info.to_ref;\n                run_args.all_files = push_info.all_files;\n                run_args.extra.remote_branch = push_info.remote_branch;\n                run_args.extra.local_branch = push_info.local_branch;\n            } else {\n                // Nothing to push\n                return None;\n            }\n        }\n        HookType::CommitMsg => {\n            run_args.extra.commit_msg_filename = Some(args[0].to_string_lossy().into_owned());\n        }\n        HookType::PrepareCommitMsg => {\n            run_args.extra.commit_msg_filename = Some(args[0].to_string_lossy().into_owned());\n            if args.len() > 1 {\n                run_args.extra.prepare_commit_message_source =\n                    Some(args[1].to_string_lossy().into_owned());\n            }\n            if args.len() > 2 {\n                run_args.extra.commit_object_name = Some(args[2].to_string_lossy().into_owned());\n            }\n        }\n        HookType::PostCheckout => {\n            run_args.from_ref = Some(args[0].to_string_lossy().into_owned());\n            run_args.to_ref = Some(args[1].to_string_lossy().into_owned());\n            run_args.extra.checkout_type = Some(args[2].to_string_lossy().into_owned());\n        }\n        HookType::PostMerge => run_args.extra.is_squash_merge = args[0] == \"1\",\n        HookType::PostRewrite => {\n            run_args.extra.rewrite_command = Some(args[0].to_string_lossy().into_owned());\n        }\n        HookType::PreRebase => {\n            run_args.extra.pre_rebase_upstream = Some(args[0].to_string_lossy().into_owned());\n            if args.len() > 1 {\n                run_args.extra.pre_rebase_branch = Some(args[1].to_string_lossy().into_owned());\n            }\n        }\n        HookType::PostCommit | HookType::PreMergeCommit | HookType::PreCommit => {}\n    }\n\n    Some(run_args)\n}\n\n#[derive(Debug)]\nstruct PushInfo {\n    from_ref: Option<String>,\n    to_ref: Option<String>,\n    all_files: bool,\n    remote_branch: Option<String>,\n    local_branch: Option<String>,\n}\n\nasync fn parse_pre_push_info(remote_name: &str, stdin: &[u8]) -> Option<PushInfo> {\n    let buffer = String::from_utf8_lossy(stdin);\n\n    for line in buffer.lines() {\n        let parts: Vec<&str> = line.rsplitn(4, ' ').collect();\n        if parts.len() != 4 {\n            continue;\n        }\n\n        let local_branch = parts[3];\n        let local_sha = parts[2];\n        let remote_branch = parts[1];\n        let remote_sha = parts[0];\n\n        // Skip if local_sha is all zeros\n        if local_sha.bytes().all(|b| b == b'0') {\n            continue;\n        }\n\n        // If remote_sha exists and is not all zeros\n        if !remote_sha.bytes().all(|b| b == b'0')\n            && git::rev_exists(remote_sha).await.unwrap_or(false)\n        {\n            return Some(PushInfo {\n                from_ref: Some(remote_sha.to_string()),\n                to_ref: Some(local_sha.to_string()),\n                all_files: false,\n                remote_branch: Some(remote_branch.to_string()),\n                local_branch: Some(local_branch.to_string()),\n            });\n        }\n\n        // Find ancestors that don't exist in remote\n        let ancestors = git::get_ancestors_not_in_remote(local_sha, remote_name)\n            .await\n            .unwrap_or_default();\n        if ancestors.is_empty() {\n            continue;\n        }\n\n        let first_ancestor = &ancestors[0];\n        let roots = git::get_root_commits(local_sha).await.unwrap_or_default();\n\n        if roots.contains(first_ancestor) {\n            // Pushing the whole tree including root commit\n            return Some(PushInfo {\n                from_ref: None,\n                to_ref: Some(local_sha.to_string()),\n                all_files: true,\n                remote_branch: Some(remote_branch.to_string()),\n                local_branch: Some(local_branch.to_string()),\n            });\n        }\n        // Find the source (first_ancestor^)\n        if let Ok(Some(source)) = git::get_parent_commit(first_ancestor).await {\n            return Some(PushInfo {\n                from_ref: Some(source),\n                to_ref: Some(local_sha.to_string()),\n                all_files: false,\n                remote_branch: Some(remote_branch.to_string()),\n                local_branch: Some(local_branch.to_string()),\n            });\n        }\n    }\n\n    // Nothing to push\n    None\n}\n\nfn format_expected_args(range: RangeInclusive<usize>) -> String {\n    let (start, end) = (*range.start(), *range.end());\n    match (start, end) {\n        (0, 0) => \"no arguments\".to_string(),\n        (1, 1) => \"exactly 1 argument\".to_string(),\n        (s, e) if s == e => format!(\"exactly {s} arguments\"),\n        (0, e) => format!(\"up to {e} arguments\"),\n        (s, usize::MAX) => format!(\"at least {s} arguments\"),\n        (s, e) => format!(\"between {s} and {e} arguments\"),\n    }\n}\n\nfn format_received_args(received: usize) -> String {\n    match received {\n        0 => \"no arguments\".to_string(),\n        1 => \"1 argument\".to_string(),\n        n => format!(\"{n} arguments\"),\n    }\n}\n\nfn format_argument_dump(args: &[OsString]) -> String {\n    if args.is_empty() {\n        String::new()\n    } else {\n        format!(\": `{}`\", args.iter().map(|s| s.to_string_lossy()).join(\" \"))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/identify.rs",
    "content": "use std::fmt::Write;\nuse std::path::PathBuf;\n\nuse itertools::Itertools;\nuse owo_colors::OwoColorize;\nuse prek_identify::tags_from_path;\nuse serde::Serialize;\n\nuse crate::cli::{ExitStatus, IdentifyOutputFormat};\nuse crate::printer::Printer;\n\n#[derive(Serialize)]\nstruct IdentifyEntry {\n    path: String,\n    tags: Vec<String>,\n}\n\npub(crate) fn identify(\n    paths: &[PathBuf],\n    output_format: IdentifyOutputFormat,\n    printer: Printer,\n) -> anyhow::Result<ExitStatus> {\n    let mut status = ExitStatus::Success;\n    let mut outputs = Vec::new();\n\n    for path in paths {\n        match tags_from_path(path) {\n            Ok(tags) => match output_format {\n                IdentifyOutputFormat::Text => {\n                    writeln!(\n                        printer.stdout_important(),\n                        \"{}: {}\",\n                        path.display().bold(),\n                        tags.iter().join(\", \")\n                    )?;\n                }\n                IdentifyOutputFormat::Json => {\n                    outputs.push(IdentifyEntry {\n                        path: path.display().to_string(),\n                        tags: tags.iter().map(ToString::to_string).collect(),\n                    });\n                }\n            },\n            Err(err) => {\n                status = ExitStatus::Failure;\n                writeln!(\n                    printer.stderr(),\n                    \"{}: {}: {}\",\n                    \"error\".red().bold(),\n                    path.display(),\n                    err\n                )?;\n            }\n        }\n    }\n\n    if matches!(output_format, IdentifyOutputFormat::Json) {\n        let json_output = serde_json::to_string_pretty(&outputs)?;\n        writeln!(printer.stdout_important(), \"{json_output}\")?;\n    }\n\n    Ok(status)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/install.rs",
    "content": "use std::fmt::Write as _;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse bstr::ByteSlice;\nuse clap::ValueEnum;\nuse owo_colors::OwoColorize;\nuse prek_consts::CONFIG_FILENAMES;\nuse same_file::is_same_file;\n\nuse crate::cli::reporter::{HookInitReporter, HookInstallReporter};\nuse crate::cli::run;\nuse crate::cli::run::{SelectorSource, Selectors};\nuse crate::cli::{ExitStatus, HookType};\nuse crate::config::load_config;\nuse crate::fs::{CWD, Simplified};\nuse crate::git::{GIT_ROOT, git_cmd};\nuse crate::printer::Printer;\nuse crate::store::Store;\nuse crate::workspace::{Error as WorkspaceError, Project, Workspace};\nuse crate::{git, warn_user};\n\n#[allow(clippy::fn_params_excessive_bools)]\npub(crate) async fn install(\n    store: &Store,\n    config: Option<PathBuf>,\n    includes: Vec<String>,\n    skips: Vec<String>,\n    hook_types: Vec<HookType>,\n    prepare_hooks: bool,\n    overwrite: bool,\n    allow_missing_config: bool,\n    refresh: bool,\n    quiet: u8,\n    verbose: u8,\n    no_progress: bool,\n    printer: Printer,\n    git_dir: Option<&Path>,\n) -> Result<ExitStatus> {\n    if git_dir.is_none() && git::has_hooks_path_set().await? {\n        anyhow::bail!(\n            \"Cowardly refusing to install hooks with `core.hooksPath` set.\\nhint: Run these commands to remove core.hooksPath:\\nhint:   {}\\nhint:   {}\",\n            \"git config --unset-all --local core.hooksPath\".cyan(),\n            \"git config --unset-all --global core.hooksPath\".cyan()\n        );\n    }\n\n    let hook_mode = git::get_shared_repository_file_mode(0o755)\n        .await\n        .unwrap_or(0o755);\n\n    let project = match Project::discover(config.as_deref(), &CWD) {\n        Ok(project) => Some(project),\n        Err(err) => {\n            if let WorkspaceError::Config(err) = &err {\n                err.warn_parse_error();\n            }\n            None\n        }\n    };\n    let hook_types = get_hook_types(hook_types, project.as_ref(), config.as_deref());\n\n    let hooks_path = if let Some(dir) = git_dir {\n        dir.join(\"hooks\")\n    } else {\n        git::get_git_common_dir().await?.join(\"hooks\")\n    };\n    fs_err::create_dir_all(&hooks_path)?;\n\n    let selectors = if let Some(project) = &project {\n        Some(Selectors::load(&includes, &skips, project.path())?)\n    } else if !includes.is_empty() || !skips.is_empty() {\n        anyhow::bail!(\"Cannot use `--include` or `--skip` outside of a git repository\");\n    } else {\n        None\n    };\n\n    for hook_type in hook_types {\n        install_hook_script(\n            project.as_ref(),\n            config.clone(),\n            selectors.as_ref(),\n            hook_type,\n            &hooks_path,\n            overwrite,\n            allow_missing_config,\n            hook_mode,\n            quiet,\n            verbose,\n            no_progress,\n            printer,\n        )?;\n    }\n\n    if prepare_hooks {\n        self::prepare_hooks(store, config, includes, skips, refresh, printer).await?;\n    }\n\n    Ok(ExitStatus::Success)\n}\n\npub(crate) async fn prepare_hooks(\n    store: &Store,\n    config: Option<PathBuf>,\n    includes: Vec<String>,\n    skips: Vec<String>,\n    refresh: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;\n    let selectors = Selectors::load(&includes, &skips, &workspace_root)?;\n    let mut workspace =\n        Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?;\n\n    let reporter = HookInitReporter::new(printer);\n    let _lock = store.lock_async().await?;\n\n    let hooks = workspace\n        .init_hooks(store, Some(&reporter))\n        .await\n        .context(\"Failed to init hooks\")?;\n    let filtered_hooks: Vec<_> = hooks\n        .into_iter()\n        .filter(|h| selectors.matches_hook(h))\n        .map(Arc::new)\n        .collect();\n\n    let reporter = HookInstallReporter::new(printer);\n    run::install_hooks(filtered_hooks, store, &reporter).await?;\n\n    Ok(ExitStatus::Success)\n}\n\nfn get_hook_types(\n    mut hook_types: Vec<HookType>,\n    project: Option<&Project>,\n    config: Option<&Path>,\n) -> Vec<HookType> {\n    if !hook_types.is_empty() {\n        return hook_types;\n    }\n\n    hook_types = if let Some(project) = project {\n        project\n            .config()\n            .default_install_hook_types\n            .clone()\n            .unwrap_or_default()\n    } else {\n        let fallbacks = CONFIG_FILENAMES\n            .iter()\n            .map(Path::new)\n            .filter(|p| p.exists());\n        if let Some(path) = config.into_iter().chain(fallbacks).next() {\n            match load_config(path) {\n                Ok(cfg) => cfg.default_install_hook_types.clone().unwrap_or_default(),\n                Err(err) => {\n                    err.warn_parse_error();\n                    vec![]\n                }\n            }\n        } else {\n            vec![]\n        }\n    };\n    if hook_types.is_empty() {\n        hook_types = vec![HookType::PreCommit];\n    }\n\n    hook_types\n}\n\n#[allow(clippy::fn_params_excessive_bools)]\nfn install_hook_script(\n    project: Option<&Project>,\n    config: Option<PathBuf>,\n    selectors: Option<&Selectors>,\n    hook_type: HookType,\n    hooks_path: &Path,\n    overwrite: bool,\n    skip_on_missing_config: bool,\n    hook_mode: u32,\n    quiet: u8,\n    verbose: u8,\n    no_progress: bool,\n    printer: Printer,\n) -> Result<()> {\n    let hook_path = hooks_path.join(hook_type.as_ref());\n    let legacy_path = hook_path.with_added_extension(\"legacy\");\n\n    if hook_path.try_exists()? {\n        if overwrite {\n            writeln!(\n                printer.stdout(),\n                \"Overwriting existing hook at `{}`\",\n                hook_path.user_display().cyan()\n            )?;\n        } else {\n            if !is_our_script(&hook_path)? {\n                fs_err::rename(&hook_path, &legacy_path)?;\n                writeln!(\n                    printer.stdout(),\n                    \"Hook already exists at `{}`, moved it to `{}`\",\n                    hook_path.user_display().cyan(),\n                    legacy_path.user_display().yellow()\n                )?;\n            }\n        }\n    }\n\n    if legacy_path.try_exists()? {\n        if overwrite {\n            // Remove existing legacy script too if we're overwriting.\n            fs_err::remove_file(&legacy_path)?;\n        } else {\n            writeln!(\n                printer.stdout(),\n                \"Migration mode: prek will also run legacy hook `{}`. Use `--overwrite` to remove legacy hooks.\",\n                legacy_path.user_display().yellow()\n            )?;\n        }\n    }\n\n    let mut args = vec![];\n\n    // Add include/skip selectors.\n    if let Some(selectors) = selectors {\n        for include in selectors.includes() {\n            args.push(include.as_normalized_flag());\n        }\n\n        // Find any skip selectors from environment variables.\n        if let Some(env_var) = selectors.skips().iter().find_map(|skip| {\n            if let SelectorSource::EnvVar(var) = skip.source() {\n                Some(var)\n            } else {\n                None\n            }\n        }) {\n            warn_user!(\n                \"Skip selectors from environment variables `{}` are ignored during installing hooks.\",\n                env_var.cyan()\n            );\n        }\n\n        for skip in selectors.skips() {\n            if matches!(skip.source(), SelectorSource::CliFlag(_)) {\n                args.push(skip.as_normalized_flag());\n            }\n        }\n    }\n\n    args.push(format!(\"--hook-type={hook_type}\"));\n\n    let mut hint = format!(\"prek installed at `{}`\", hook_path.user_display().cyan());\n\n    // Prefer explicit config path if given (non-workspace mode).\n    // Otherwise, use the config path from the discovered project (workspace mode).\n    // If neither is available, don't pass a config path (let prek find it). In this case,\n    // we're different with `pre-commit` which always sets `--config=.pre-commit-config.yaml`.\n    if let Some(config) = config {\n        args.push(format!(r#\"--config=\"{}\"\"#, config.display()));\n\n        write!(hint, \" with specified config `{}`\", config.display().cyan())?;\n    } else if let Some(project) = project {\n        let git_root = GIT_ROOT.as_ref()?;\n        let project_path = project.path();\n        let relative_path = project_path.strip_prefix(git_root).unwrap_or(project_path);\n        if !relative_path.as_os_str().is_empty() {\n            args.push(format!(r#\"--cd=\"{}\"\"#, relative_path.display()));\n        }\n\n        // Show workspace path if it's not the root project.\n        if project_path != git_root {\n            writeln!(hint, \" for workspace `{}`\", project_path.display().cyan())?;\n            write!(\n                hint,\n                \"\\n{} this hook installed for `{}` only; run `prek install` from `{}` to install for the entire repo.\",\n                \"hint:\".bold().yellow(),\n                project_path.display().cyan(),\n                git_root.display().cyan()\n            )?;\n        }\n    }\n\n    if skip_on_missing_config {\n        args.push(\"--skip-on-missing-config\".to_string());\n    }\n\n    let prek = std::env::current_exe()?;\n    let prek = prek.simplified_display().to_string();\n    let mut prek_global_args = render_global_args(quiet, verbose, no_progress);\n    if !prek_global_args.is_empty() {\n        prek_global_args.push(' ');\n    }\n    let hook_script = HOOK_TMPL\n        .replace(\"[CUR_SCRIPT_VERSION]\", &CUR_SCRIPT_VERSION.to_string())\n        .replace(\"[PREK_PATH]\", &format!(r#\"\"{prek}\"\"#))\n        .replace(\"[PREK_GLOBAL_ARGS]\", &prek_global_args)\n        .replace(\"[PREK_ARGS]\", &args.join(\" \"));\n\n    fs_err::OpenOptions::new()\n        .write(true)\n        .create(true)\n        .truncate(true)\n        .open(&hook_path)?\n        .write_all(hook_script.as_bytes())?;\n\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n\n        let mut perms = hook_path.metadata()?.permissions();\n        perms.set_mode(hook_mode);\n        fs_err::set_permissions(&hook_path, perms)?;\n    }\n\n    // Unused on non-Unix platforms\n    #[cfg(not(unix))]\n    let _ = hook_mode;\n\n    writeln!(printer.stdout(), \"{hint}\")?;\n\n    Ok(())\n}\n\nfn render_global_args(quiet: u8, verbose: u8, no_progress: bool) -> String {\n    let mut args = Vec::with_capacity(3);\n\n    if quiet > 0 {\n        args.push(format!(\"-{}\", \"q\".repeat(quiet.into())));\n    }\n\n    if verbose > 0 {\n        args.push(format!(\"-{}\", \"v\".repeat(verbose.into())));\n    }\n\n    if no_progress {\n        args.push(\"--no-progress\".to_string());\n    }\n\n    if args.is_empty() {\n        String::new()\n    } else {\n        args.join(\" \")\n    }\n}\n\n/// The version of the hook script. Increment this when the script changes in a way that\n/// requires re-installation.\npub(crate) static CUR_SCRIPT_VERSION: usize = 4;\n\nstatic HOOK_TMPL: &str = r#\"#!/bin/sh\n# File generated by prek: https://github.com/j178/prek\n# ID: 182c10f181da4464a3eec51b83331688\n\nHERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nPREK=[PREK_PATH]\n\n# Check if the full path to prek is executable, otherwise fallback to PATH\nif [ ! -x \"$PREK\" ]; then\n    PREK=\"prek\"\nfi\n\nexec \"$PREK\" [PREK_GLOBAL_ARGS]hook-impl --hook-dir \"$HERE\" --script-version [CUR_SCRIPT_VERSION] [PREK_ARGS] -- \"$@\"\n\n\"#;\n\nstatic PRIOR_HASHES: &[&str] = &[];\n\n// Use a different hash for each change to the script.\n// Use a different hash from `pre-commit` since our script is different.\nstatic CURRENT_HASH: &str = \"182c10f181da4464a3eec51b83331688\";\n\n/// Checks if the script contains any of the hashes that `prek` has used in the past.\nfn is_our_script(hook_path: &Path) -> std::io::Result<bool> {\n    let content = fs_err::read_to_string(hook_path)?;\n    Ok(std::iter::once(CURRENT_HASH)\n        .chain(PRIOR_HASHES.iter().copied())\n        .any(|hash| content.contains(hash)))\n}\n\npub(crate) async fn uninstall(\n    config: Option<PathBuf>,\n    hook_types: Vec<HookType>,\n    all: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let project = Project::discover(config.as_deref(), &CWD).ok();\n    let hooks_path = git::get_git_common_dir().await?.join(\"hooks\");\n\n    let types: Vec<HookType> = if all {\n        HookType::value_variants().to_vec()\n    } else {\n        get_hook_types(hook_types, project.as_ref(), config.as_deref())\n    };\n\n    for hook_type in types {\n        let hook_path = hooks_path.join(hook_type.as_ref());\n        let legacy_path = hook_path.with_added_extension(\"legacy\");\n\n        if is_our_script(&legacy_path).unwrap_or(false) {\n            fs_err::remove_file(&legacy_path)?;\n            writeln!(\n                printer.stderr(),\n                \"Found legacy hook at `{}`, removing it.\",\n                legacy_path.user_display().cyan()\n            )?;\n        }\n\n        match is_our_script(&hook_path) {\n            Ok(true) => {}\n            Ok(false) => {\n                if !all {\n                    writeln!(\n                        printer.stderr(),\n                        \"`{}` is not managed by prek, skipping.\",\n                        hook_path.user_display().cyan()\n                    )?;\n                }\n                continue;\n            }\n            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n                if !all {\n                    writeln!(\n                        printer.stderr(),\n                        \"`{}` does not exist, skipping.\",\n                        hook_path.user_display().cyan()\n                    )?;\n                }\n                continue;\n            }\n            Err(err) => return Err(err.into()),\n        }\n\n        fs_err::remove_file(&hook_path)?;\n        writeln!(\n            printer.stdout(),\n            \"Uninstalled `{}`\",\n            hook_type.as_ref().cyan()\n        )?;\n\n        if legacy_path.try_exists()? {\n            fs_err::rename(&legacy_path, &hook_path)?;\n            writeln!(\n                printer.stdout(),\n                \"Restored `{}` to `{}`\",\n                legacy_path.user_display().cyan(),\n                hook_path.user_display().cyan()\n            )?;\n        }\n    }\n\n    Ok(ExitStatus::Success)\n}\n\npub(crate) async fn init_template_dir(\n    store: &Store,\n    directory: PathBuf,\n    config: Option<PathBuf>,\n    hook_types: Vec<HookType>,\n    requires_config: bool,\n    refresh: bool,\n    quiet: u8,\n    verbose: u8,\n    no_progress: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    install(\n        store,\n        config,\n        vec![],\n        vec![],\n        hook_types,\n        false,\n        true,\n        !requires_config,\n        refresh,\n        quiet,\n        verbose,\n        no_progress,\n        printer,\n        Some(&directory),\n    )\n    .await?;\n\n    let output = git_cmd(\"git config\")?\n        .arg(\"config\")\n        .arg(\"init.templateDir\")\n        .check(false)\n        .output()\n        .await?;\n    let template_dir = String::from_utf8_lossy(output.stdout.trim()).to_string();\n\n    if template_dir.is_empty() || !is_same_file(&directory, &template_dir)? {\n        warn_user!(\n            \"git config `init.templateDir` not set to the target directory, try `{}`\",\n            format!(\n                \"git config --global init.templateDir '{}'\",\n                directory.display()\n            )\n            .cyan()\n        );\n    }\n\n    Ok(ExitStatus::Success)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/list.rs",
    "content": "use std::fmt::Write;\nuse std::path::PathBuf;\n\nuse anyhow::Context;\nuse owo_colors::OwoColorize;\nuse serde::Serialize;\n\nuse crate::cli::reporter::HookInitReporter;\nuse crate::cli::run::Selectors;\nuse crate::cli::{ExitStatus, ListOutputFormat};\nuse crate::config::{Language, Stage};\nuse crate::fs::CWD;\nuse crate::printer::Printer;\nuse crate::store::Store;\nuse crate::workspace::Workspace;\n\n#[derive(Serialize)]\nstruct SerializableHook {\n    id: String,\n    full_id: String,\n    name: String,\n    alias: String,\n    language: Language,\n    description: Option<String>,\n    stages: Vec<Stage>,\n}\n\npub(crate) async fn list(\n    store: &Store,\n    config: Option<PathBuf>,\n    includes: Vec<String>,\n    skips: Vec<String>,\n    hook_stage: Option<Stage>,\n    language: Option<Language>,\n    output_format: ListOutputFormat,\n    refresh: bool,\n    verbose: bool,\n    printer: Printer,\n) -> anyhow::Result<ExitStatus> {\n    let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;\n    let selectors = Selectors::load(&includes, &skips, &workspace_root)?;\n    let mut workspace =\n        Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?;\n\n    let reporter = HookInitReporter::new(printer);\n    let lock = store.lock_async().await?;\n    let hooks = workspace\n        .init_hooks(store, Some(&reporter))\n        .await\n        .context(\"Failed to init hooks\")?;\n\n    drop(lock);\n\n    let filtered_hooks: Vec<_> = hooks\n        .into_iter()\n        .filter(|h| selectors.matches_hook(h))\n        .filter(|h| hook_stage.is_none_or(|hook_stage| h.stages.contains(hook_stage)))\n        .filter(|h| language.is_none_or(|lang| h.language == lang))\n        .collect();\n\n    selectors.report_unused();\n\n    match output_format {\n        ListOutputFormat::Text => {\n            if verbose {\n                // TODO: show repo path and environment path (if installed)\n                for hook in &filtered_hooks {\n                    writeln!(printer.stdout(), \"{}\", hook.full_id().bold())?;\n\n                    writeln!(printer.stdout(), \"  {} {}\", \"ID:\".bold().cyan(), hook.id)?;\n                    if !hook.alias.is_empty() && hook.alias != hook.id {\n                        writeln!(\n                            printer.stdout(),\n                            \"  {} {}\",\n                            \"Alias:\".bold().cyan(),\n                            hook.alias\n                        )?;\n                    }\n                    writeln!(\n                        printer.stdout(),\n                        \"  {} {}\",\n                        \"Name:\".bold().cyan(),\n                        hook.name\n                    )?;\n                    if let Some(description) = &hook.description {\n                        writeln!(\n                            printer.stdout(),\n                            \"  {} {}\",\n                            \"Description:\".bold().cyan(),\n                            description\n                        )?;\n                    }\n                    writeln!(\n                        printer.stdout(),\n                        \"  {} {}\",\n                        \"Language:\".bold().cyan(),\n                        hook.language.as_ref()\n                    )?;\n                    writeln!(\n                        printer.stdout(),\n                        \"  {} {}\",\n                        \"Stages:\".bold().cyan(),\n                        hook.stages\n                    )?;\n                    writeln!(printer.stdout())?;\n                }\n            } else {\n                for hook in &filtered_hooks {\n                    writeln!(printer.stdout(), \"{}\", hook.full_id())?;\n                }\n            }\n        }\n        ListOutputFormat::Json => {\n            let serializable_hooks: Vec<_> = filtered_hooks\n                .into_iter()\n                .map(|h| {\n                    let id = h.id.clone();\n                    let full_id = h.full_id();\n                    let stages = h.stages.to_vec();\n                    SerializableHook {\n                        id,\n                        full_id,\n                        name: h.name,\n                        alias: h.alias,\n                        language: h.language,\n                        description: h.description,\n                        stages,\n                    }\n                })\n                .collect();\n\n            let json_output = serde_json::to_string_pretty(&serializable_hooks)?;\n            writeln!(printer.stdout(), \"{json_output}\")?;\n        }\n    }\n\n    Ok(ExitStatus::Success)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/list_builtins.rs",
    "content": "use std::fmt::Write;\n\nuse owo_colors::OwoColorize;\nuse serde::Serialize;\nuse strum::IntoEnumIterator;\n\nuse crate::cli::{ExitStatus, ListOutputFormat};\nuse crate::config::BuiltinHook;\nuse crate::hooks::BuiltinHooks;\nuse crate::printer::Printer;\n\n#[derive(Serialize)]\nstruct SerializableBuiltinHook {\n    id: String,\n    name: String,\n    description: Option<String>,\n}\n\n/// List all builtin hooks.\npub(crate) fn list_builtins(\n    output_format: ListOutputFormat,\n    verbose: bool,\n    printer: Printer,\n) -> anyhow::Result<ExitStatus> {\n    let hooks = BuiltinHooks::iter().map(|variant| {\n        let id = variant.as_ref();\n        BuiltinHook::from_id(id).expect(\"All BuiltinHooks variants should be valid\")\n    });\n\n    match output_format {\n        ListOutputFormat::Text => {\n            if verbose {\n                for hook in hooks {\n                    writeln!(printer.stdout_important(), \"{}\", hook.id.bold())?;\n                    if let Some(description) = &hook.options.description {\n                        writeln!(printer.stdout_important(), \"  {description}\")?;\n                    }\n                    writeln!(printer.stdout_important())?;\n                }\n            } else {\n                for hook in hooks {\n                    writeln!(printer.stdout_important(), \"{}\", hook.id)?;\n                }\n            }\n        }\n        ListOutputFormat::Json => {\n            let serializable: Vec<_> = hooks\n                .map(|h| SerializableBuiltinHook {\n                    id: h.id,\n                    name: h.name,\n                    description: h.options.description,\n                })\n                .collect();\n            let json_output = serde_json::to_string_pretty(&serializable)?;\n            writeln!(printer.stdout_important(), \"{json_output}\")?;\n        }\n    }\n\n    Ok(ExitStatus::Success)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/mod.rs",
    "content": "use std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::ExitCode;\n\nuse clap::builder::styling::{AnsiColor, Effects};\nuse clap::builder::{ArgPredicate, Styles};\nuse clap::{ArgAction, Args, Parser, Subcommand, ValueHint};\nuse clap_complete::engine::ArgValueCompleter;\nuse prek_consts::env_vars::EnvVars;\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::{HookType, Language, Stage};\n\nmod auto_update;\nmod cache_clean;\nmod cache_gc;\nmod cache_size;\nmod completion;\nmod hook_impl;\nmod identify;\nmod install;\nmod list;\nmod list_builtins;\npub mod reporter;\npub mod run;\nmod sample_config;\n#[cfg(feature = \"self-update\")]\nmod self_update;\nmod try_repo;\nmod validate;\nmod yaml_to_toml;\n\npub(crate) use auto_update::auto_update;\npub(crate) use cache_clean::cache_clean;\npub(crate) use cache_gc::cache_gc;\npub(crate) use cache_size::cache_size;\nuse completion::selector_completer;\npub(crate) use hook_impl::hook_impl;\npub(crate) use identify::identify;\npub(crate) use install::{init_template_dir, install, prepare_hooks, uninstall};\npub(crate) use list::list;\npub(crate) use list_builtins::list_builtins;\npub(crate) use run::run;\npub(crate) use sample_config::sample_config;\n#[cfg(feature = \"self-update\")]\npub(crate) use self_update::self_update;\npub(crate) use try_repo::try_repo;\npub(crate) use validate::{validate_configs, validate_manifest};\npub(crate) use yaml_to_toml::yaml_to_toml;\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\npub(crate) enum ExitStatus {\n    /// The command succeeded.\n    Success,\n\n    /// The command failed due to an error in the user input.\n    Failure,\n\n    /// The command failed with an unexpected error.\n    Error,\n\n    /// The command was interrupted.\n    Interrupted,\n\n    /// The command's exit status is propagated from an external command.\n    External(u8),\n}\n\nimpl From<ExitStatus> for ExitCode {\n    fn from(status: ExitStatus) -> Self {\n        match status {\n            ExitStatus::Success => Self::from(0),\n            ExitStatus::Failure => Self::from(1),\n            ExitStatus::Error => Self::from(2),\n            ExitStatus::Interrupted => Self::from(130),\n            ExitStatus::External(code) => Self::from(code),\n        }\n    }\n}\n\nimpl From<u8> for ExitStatus {\n    fn from(code: u8) -> Self {\n        match code {\n            0 => Self::Success,\n            other => Self::External(other),\n        }\n    }\n}\n\n#[derive(Debug, Copy, Clone, clap::ValueEnum)]\npub enum ColorChoice {\n    /// Enables colored output only when the output is going to a terminal or TTY with support.\n    Auto,\n\n    /// Enables colored output regardless of the detected environment.\n    Always,\n\n    /// Disables colored output.\n    Never,\n}\n\nimpl From<ColorChoice> for anstream::ColorChoice {\n    fn from(value: ColorChoice) -> Self {\n        match value {\n            ColorChoice::Auto => Self::Auto,\n            ColorChoice::Always => Self::Always,\n            ColorChoice::Never => Self::Never,\n        }\n    }\n}\n\nconst STYLES: Styles = Styles::styled()\n    .header(AnsiColor::Green.on_default().effects(Effects::BOLD))\n    .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))\n    .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))\n    .placeholder(AnsiColor::Cyan.on_default());\n\n#[derive(Parser)]\n#[command(\n    name = \"prek\",\n    long_version = crate::version::version(),\n    about = \"Better pre-commit, re-engineered in Rust\"\n)]\n#[command(\n    propagate_version = true,\n    disable_help_flag = true,\n    disable_help_subcommand = true,\n    disable_version_flag = true\n)]\n#[command(styles=STYLES)]\npub(crate) struct Cli {\n    #[command(subcommand)]\n    pub(crate) command: Option<Command>,\n\n    // run as the default subcommand\n    #[command(flatten)]\n    pub(crate) run_args: RunArgs,\n\n    #[command(flatten)]\n    pub(crate) globals: GlobalArgs,\n}\n\n#[derive(Debug, Args)]\n#[command(next_help_heading = \"Global options\", next_display_order = 1000)]\n#[allow(clippy::struct_excessive_bools)]\npub(crate) struct GlobalArgs {\n    /// Path to alternate config file.\n    #[arg(global = true, short, long)]\n    pub(crate) config: Option<PathBuf>,\n\n    /// Change to directory before running.\n    #[arg(\n        global = true,\n        short = 'C',\n        long,\n        value_name = \"DIR\",\n        value_hint = ValueHint::DirPath,\n    )]\n    pub(crate) cd: Option<PathBuf>,\n\n    /// Whether to use color in output.\n    #[arg(\n        global = true,\n        long,\n        value_enum,\n        env = EnvVars::PREK_COLOR,\n        default_value_t = ColorChoice::Auto,\n    )]\n    pub(crate) color: ColorChoice,\n\n    /// Refresh all cached data.\n    #[arg(global = true, long)]\n    pub(crate) refresh: bool,\n\n    /// Display the concise help for this command.\n    #[arg(global = true, short, long, action = ArgAction::HelpShort)]\n    help: (),\n\n    /// Hide all progress outputs.\n    ///\n    /// For example, spinners or progress bars.\n    #[arg(global = true, long)]\n    pub no_progress: bool,\n\n    /// Use quiet output.\n    ///\n    /// Repeating this option, e.g., `-qq`, will enable a silent mode in which\n    /// prek will write no output to stdout.\n    #[arg(global = true, short, long, env = EnvVars::PREK_QUIET, conflicts_with = \"verbose\", action = ArgAction::Count)]\n    pub quiet: u8,\n\n    /// Use verbose output.\n    #[arg(global = true, short, long, action = ArgAction::Count)]\n    pub(crate) verbose: u8,\n\n    /// Write trace logs to the specified file.\n    /// If not specified, trace logs will be written to `$PREK_HOME/prek.log`.\n    #[arg(global = true, long, value_name = \"LOG_FILE\", value_hint = ValueHint::FilePath)]\n    pub(crate) log_file: Option<PathBuf>,\n\n    /// Do not write trace logs to a log file.\n    #[arg(global = true, long, overrides_with = \"log_file\", hide = true)]\n    pub(crate) no_log_file: bool,\n\n    /// Display the prek version.\n    #[arg(global = true, short = 'V', long, action = ArgAction::Version)]\n    version: (),\n\n    /// Show the resolved settings for the current command.\n    ///\n    /// This option is used for debugging and development purposes.\n    #[arg(global = true, long, hide = true)]\n    pub show_settings: bool,\n}\n\n#[derive(Debug, Subcommand)]\npub(crate) enum Command {\n    /// Install prek Git shims under the `.git/hooks/` directory.\n    ///\n    /// The Git shims installed by this command are determined by `--hook-type`\n    /// or `default_install_hook_types` in the config file, falling back to\n    /// `pre-commit` when neither is set.\n    ///\n    /// A hook's `stages` field does not affect which Git shims this\n    /// command installs.\n    Install(InstallArgs),\n    /// Prepare environments for all hooks used in the config file.\n    ///\n    /// This command does not install Git shims. To install the Git shims\n    /// along with the hook environments in one command, use `prek install --prepare-hooks`.\n    #[command(alias = \"install-hooks\")]\n    PrepareHooks(PrepareHooksArgs),\n    /// Run hooks.\n    Run(Box<RunArgs>),\n    /// List hooks configured in the current workspace.\n    List(ListArgs),\n    /// Uninstall prek Git shims.\n    Uninstall(UninstallArgs),\n    /// Validate configuration files (prek.toml or .pre-commit-config.yaml).\n    ValidateConfig(ValidateConfigArgs),\n    /// Validate `.pre-commit-hooks.yaml` files.\n    ValidateManifest(ValidateManifestArgs),\n    /// Produce a sample configuration file (prek.toml or .pre-commit-config.yaml).\n    SampleConfig(SampleConfigArgs),\n    /// Auto-update the `rev` field of repositories in the config file to the latest version.\n    #[command(alias = \"autoupdate\")]\n    AutoUpdate(AutoUpdateArgs),\n    /// Manage the prek cache.\n    Cache(CacheNamespace),\n    /// Clean unused cached repos.\n    #[command(hide = true)]\n    GC(CacheGcArgs),\n    /// Remove all prek cached data.\n    #[command(hide = true)]\n    Clean,\n    /// Install Git shims in a directory intended for use with `git config init.templateDir`.\n    #[command(alias = \"init-templatedir\", hide = true)]\n    InitTemplateDir(InitTemplateDirArgs),\n    /// Try the pre-commit hooks in the current repo.\n    TryRepo(Box<TryRepoArgs>),\n    /// The implementation of the prek Git shim that is installed in the `.git/hooks/` directory.\n    #[command(hide = true)]\n    HookImpl(HookImplArgs),\n    /// Utility commands.\n    Util(UtilNamespace),\n    /// `prek` self management.\n    #[command(name = \"self\")]\n    Self_(SelfNamespace),\n}\n\n#[derive(Debug, Args)]\npub(crate) struct InstallArgs {\n    /// Include the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Run all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Run all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Run only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times to select multiple hooks/projects.\n    #[arg(\n        value_name = \"HOOK|PROJECT\",\n        value_hint = ValueHint::Other,\n        add = ArgValueCompleter::new(selector_completer)\n    )]\n    pub(crate) includes: Vec<String>,\n\n    /// Skip the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Skip all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Skip all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Skip only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited).\n    #[arg(long = \"skip\", value_name = \"HOOK|PROJECT\", add = ArgValueCompleter::new(selector_completer))]\n    pub(crate) skips: Vec<String>,\n\n    /// Overwrite existing Git shims.\n    #[arg(short = 'f', long)]\n    pub(crate) overwrite: bool,\n\n    /// Also prepare environments for all hooks used in the config file.\n    #[arg(long, alias = \"install-hooks\")]\n    pub(crate) prepare_hooks: bool,\n\n    /// Which Git shim(s) to install.\n    ///\n    /// Specifies which Git hook type(s) you want to install shims for.\n    /// Can be specified multiple times to install shims for multiple hook types.\n    ///\n    /// If not specified, uses `default_install_hook_types` from the config file,\n    /// or defaults to `pre-commit` if that is also not set.\n    ///\n    /// Note: This is different from a hook's `stages` parameter in the config file,\n    /// which declares which stages a hook *can* run in.\n    #[arg(short = 't', long = \"hook-type\", value_name = \"HOOK_TYPE\", value_enum)]\n    pub(crate) hook_types: Vec<HookType>,\n\n    /// Allow a missing configuration file.\n    #[arg(long)]\n    pub(crate) allow_missing_config: bool,\n\n    /// Install Git shims into the `hooks` subdirectory of the given git directory (`<GIT_DIR>/hooks/`).\n    ///\n    /// When this flag is used, `prek install` bypasses the safety check that normally\n    /// refuses to install shims while `core.hooksPath` is set. Git itself will still\n    /// ignore `.git/hooks` while `core.hooksPath` is configured, so ensure your Git\n    /// configuration points to the directory where the shim is installed if you want\n    /// it to be executed.\n    #[arg(long, value_name = \"GIT_DIR\", value_hint = ValueHint::DirPath)]\n    pub(crate) git_dir: Option<PathBuf>,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct PrepareHooksArgs {\n    /// Include the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Run all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Run all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Run only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times to select multiple hooks/projects.\n    #[arg(\n        value_name = \"HOOK|PROJECT\",\n        value_hint = ValueHint::Other,\n        add = ArgValueCompleter::new(selector_completer)\n    )]\n    pub(crate) includes: Vec<String>,\n\n    /// Skip the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Skip all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Skip all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Skip only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited).\n    #[arg(long = \"skip\", value_name = \"HOOK|PROJECT\", add = ArgValueCompleter::new(selector_completer))]\n    pub(crate) skips: Vec<String>,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct UninstallArgs {\n    /// Uninstall all prek-managed Git shims.\n    ///\n    /// Scans the hooks directory and removes every hook managed by prek,\n    /// regardless of hook type.\n    #[arg(long, conflicts_with = \"hook_types\")]\n    pub(crate) all: bool,\n\n    /// Which Git shim(s) to uninstall.\n    ///\n    /// Specifies which Git hook type(s) you want to uninstall shims for.\n    /// Can be specified multiple times to uninstall shims for multiple hook types.\n    ///\n    /// If not specified, uses `default_install_hook_types` from the config file,\n    /// or defaults to `pre-commit` if that is also not set.\n    /// Use `--all` to remove all prek-managed hooks.\n    #[arg(short = 't', long = \"hook-type\", value_name = \"HOOK_TYPE\", value_enum)]\n    pub(crate) hook_types: Vec<HookType>,\n}\n\n#[derive(Debug, Clone, Default, Args)]\npub(crate) struct RunExtraArgs {\n    #[arg(long, hide = true)]\n    pub(crate) remote_branch: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) local_branch: Option<String>,\n    #[arg(long, hide = true, required_if_eq(\"stage\", \"pre-rebase\"))]\n    pub(crate) pre_rebase_upstream: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) pre_rebase_branch: Option<String>,\n    #[arg(long, hide = true, required_if_eq_any = [(\"stage\", \"prepare-commit-msg\"), (\"stage\", \"commit-msg\")])]\n    pub(crate) commit_msg_filename: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) prepare_commit_message_source: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) commit_object_name: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) remote_name: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) remote_url: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) checkout_type: Option<String>,\n    #[arg(long, hide = true)]\n    pub(crate) is_squash_merge: bool,\n    #[arg(long, hide = true)]\n    pub(crate) rewrite_command: Option<String>,\n}\n\n#[allow(clippy::struct_excessive_bools)]\n#[derive(Debug, Clone, Default, Args)]\npub(crate) struct RunArgs {\n    /// Include the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Run all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Run all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Run only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times to select multiple hooks/projects.\n    #[arg(\n        value_name = \"HOOK|PROJECT\",\n        value_hint = ValueHint::Other,\n        add = ArgValueCompleter::new(selector_completer)\n    )]\n    pub(crate) includes: Vec<String>,\n\n    /// Skip the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Skip all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Skip all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Skip only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited).\n    #[arg(long = \"skip\", value_name = \"HOOK|PROJECT\", add = ArgValueCompleter::new(selector_completer))]\n    pub(crate) skips: Vec<String>,\n\n    /// Run on all files in the repo.\n    #[arg(short, long, conflicts_with_all = [\"files\", \"from_ref\", \"to_ref\"])]\n    pub(crate) all_files: bool,\n    /// Specific filenames to run hooks on.\n    #[arg(\n        long,\n        conflicts_with_all = [\"all_files\", \"from_ref\", \"to_ref\"],\n        num_args = 0..,\n        value_hint = ValueHint::AnyPath)\n    ]\n    pub(crate) files: Vec<String>,\n\n    /// Run hooks on all files in the specified directories.\n    ///\n    /// You can specify multiple directories. It can be used in conjunction with `--files`.\n    #[arg(\n        short,\n        long,\n        value_name = \"DIR\",\n        conflicts_with_all = [\"all_files\", \"from_ref\", \"to_ref\"],\n        value_hint = ValueHint::DirPath\n    )]\n    pub(crate) directory: Vec<String>,\n\n    /// The original ref in a `<from_ref>...<to_ref>` diff expression.\n    /// Files changed in this diff will be run through the hooks.\n    #[arg(short = 's', long, alias = \"source\", value_hint = ValueHint::Other)]\n    pub(crate) from_ref: Option<String>,\n\n    /// The destination ref in a `from_ref...to_ref` diff expression.\n    /// Defaults to `HEAD` if `from_ref` is specified.\n    #[arg(\n        short = 'o',\n        long,\n        alias = \"origin\",\n        requires = \"from_ref\",\n        value_hint = ValueHint::Other,\n        default_value_if(\"from_ref\", ArgPredicate::IsPresent, \"HEAD\")\n    )]\n    pub(crate) to_ref: Option<String>,\n\n    /// Run hooks against the last commit. Equivalent to `--from-ref HEAD~1 --to-ref HEAD`.\n    #[arg(long, conflicts_with_all = [\"all_files\", \"files\", \"directory\", \"from_ref\", \"to_ref\"])]\n    pub(crate) last_commit: bool,\n\n    /// The stage during which the hook is fired.\n    ///\n    /// When specified, only hooks configured for that stage (for example `manual`,\n    /// `pre-commit`, or `pre-push`) will run.\n    /// Defaults to `pre-commit` if not specified.\n    /// For hooks specified directly in the command line, fallback to `manual` stage if no hooks found for `pre-commit` stage.\n    #[arg(long, value_enum, alias = \"hook-stage\")]\n    pub(crate) stage: Option<Stage>,\n\n    /// When hooks fail, run `git diff` directly afterward.\n    #[arg(long)]\n    pub(crate) show_diff_on_failure: bool,\n\n    /// Stop running hooks after the first failure.\n    #[arg(long)]\n    pub(crate) fail_fast: bool,\n\n    /// Do not run the hooks, but print the hooks that would have been run.\n    #[arg(long)]\n    pub(crate) dry_run: bool,\n\n    #[command(flatten)]\n    pub(crate) extra: RunExtraArgs,\n}\n\n#[derive(Debug, Clone, Default, Args)]\npub(crate) struct TryRepoArgs {\n    /// Repository to source hooks from.\n    pub(crate) repo: String,\n\n    /// Manually select a rev to run against, otherwise the `HEAD` revision will be used.\n    #[arg(long, alias = \"ref\")]\n    pub(crate) rev: Option<String>,\n\n    #[command(flatten)]\n    pub(crate) run_args: RunArgs,\n}\n\n#[derive(Debug, Clone, Copy, clap::ValueEnum, Default, Serialize, Deserialize)]\n#[serde(rename_all = \"kebab-case\")]\npub(crate) enum ListOutputFormat {\n    #[default]\n    Text,\n    Json,\n}\n\n#[derive(Debug, Clone, Copy, clap::ValueEnum, Default, Serialize, Deserialize)]\n#[serde(rename_all = \"kebab-case\")]\npub(crate) enum IdentifyOutputFormat {\n    #[default]\n    Text,\n    Json,\n}\n\n#[derive(Debug, Clone, Default, Args)]\npub(crate) struct ListBuiltinsArgs {\n    /// The output format.\n    #[arg(long, value_enum, default_value_t = ListOutputFormat::Text)]\n    pub(crate) output_format: ListOutputFormat,\n}\n\n#[derive(Debug, Clone, Default, Args)]\npub(crate) struct ListArgs {\n    /// Include the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Run all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Run all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Run only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times to select multiple hooks/projects.\n    #[arg(\n        value_name = \"HOOK|PROJECT\",\n        value_hint = ValueHint::Other,\n        add = ArgValueCompleter::new(selector_completer)\n    )]\n    pub(crate) includes: Vec<String>,\n\n    /// Skip the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Skip all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Skip all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Skip only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited).\n    #[arg(long = \"skip\", value_name = \"HOOK|PROJECT\", add = ArgValueCompleter::new(selector_completer))]\n    pub(crate) skips: Vec<String>,\n\n    /// Show only hooks that has the specified stage.\n    #[arg(long, value_enum)]\n    pub(crate) hook_stage: Option<Stage>,\n    /// Show only hooks that are implemented in the specified language.\n    #[arg(long, value_enum)]\n    pub(crate) language: Option<Language>,\n    /// The output format.\n    #[arg(long, value_enum, default_value_t = ListOutputFormat::Text)]\n    pub(crate) output_format: ListOutputFormat,\n}\n\n#[derive(Debug, Clone, Default, Args)]\npub(crate) struct IdentifyArgs {\n    /// The path(s) to the file(s) to identify.\n    #[arg(value_name = \"PATH\", value_hint = ValueHint::AnyPath)]\n    pub(crate) paths: Vec<PathBuf>,\n    /// The output format.\n    #[arg(long, value_enum, default_value_t = IdentifyOutputFormat::Text)]\n    pub(crate) output_format: IdentifyOutputFormat,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct ValidateConfigArgs {\n    /// The path to the configuration file.\n    #[arg(value_name = \"CONFIG\")]\n    pub(crate) configs: Vec<PathBuf>,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct ValidateManifestArgs {\n    /// The path to the manifest file.\n    #[arg(value_name = \"MANIFEST\")]\n    pub(crate) manifests: Vec<PathBuf>,\n}\n\n#[expect(clippy::option_option)]\n#[derive(Debug, Args)]\npub(crate) struct SampleConfigArgs {\n    /// Write the sample config to a file.\n    ///\n    /// Defaults to `.pre-commit-config.yaml` unless `--format toml` is set,\n    /// which uses `prek.toml`. If a path is provided without `--format`,\n    /// the format is inferred from the file extension (`.toml` uses TOML).\n    #[arg(\n        short,\n        long,\n        num_args = 0..=1,\n    )]\n    pub(crate) file: Option<Option<PathBuf>>,\n\n    /// Select the sample configuration format.\n    #[arg(long, value_enum)]\n    pub(crate) format: Option<SampleConfigFormat>,\n}\n\n#[derive(Debug, Copy, Clone, clap::ValueEnum)]\npub(crate) enum SampleConfigFormat {\n    Yaml,\n    Toml,\n}\n\n#[derive(Debug)]\npub(crate) enum SampleConfigTarget {\n    Stdout,\n    DefaultFile,\n    Path(PathBuf),\n}\n\nimpl From<Option<Option<PathBuf>>> for SampleConfigTarget {\n    fn from(value: Option<Option<PathBuf>>) -> Self {\n        match value {\n            None => Self::Stdout,\n            Some(None) => Self::DefaultFile,\n            Some(Some(path)) => Self::Path(path),\n        }\n    }\n}\n\n#[derive(Debug, Args)]\npub(crate) struct AutoUpdateArgs {\n    /// Update to the bleeding edge of the default branch instead of the latest tagged version.\n    #[arg(long)]\n    pub(crate) bleeding_edge: bool,\n    /// Store \"frozen\" hashes in `rev` instead of tag names.\n    #[arg(long)]\n    pub(crate) freeze: bool,\n    /// Only update this repository. This option may be specified multiple times.\n    #[arg(long)]\n    pub(crate) repo: Vec<String>,\n    /// Do not write changes to the config file, only display what would be changed.\n    #[arg(long)]\n    pub(crate) dry_run: bool,\n    /// Number of threads to use.\n    #[arg(short, long, default_value_t = 0)]\n    pub(crate) jobs: usize,\n    /// Minimum release age (in days) required for a version to be eligible.\n    ///\n    /// The age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags.\n    /// A value of `0` disables this check.\n    #[arg(\n        long,\n        value_name = \"DAYS\",\n        default_value_t = 0,\n        conflicts_with = \"bleeding_edge\"\n    )]\n    pub(crate) cooldown_days: u8,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct HookImplArgs {\n    /// Include the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Run all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Run all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Run only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times to select multiple hooks/projects.\n    #[arg(\n        value_name = \"HOOK|PROJECT\",\n        value_hint = ValueHint::Other,\n        add = ArgValueCompleter::new(selector_completer)\n    )]\n    pub(crate) includes: Vec<String>,\n\n    /// Skip the specified hooks or projects.\n    ///\n    /// Supports flexible selector syntax:\n    ///\n    /// - `hook-id`: Skip all hooks with the specified ID across all projects\n    ///\n    /// - `project-path/`: Skip all hooks from the specified project\n    ///\n    /// - `project-path:hook-id`: Skip only the specified hook from the specified project\n    ///\n    /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited).\n    #[arg(long = \"skip\", value_name = \"HOOK|PROJECT\", add = ArgValueCompleter::new(selector_completer))]\n    pub(crate) skips: Vec<String>,\n    #[arg(long)]\n    pub(crate) hook_type: HookType,\n    #[arg(long)]\n    pub(crate) hook_dir: PathBuf,\n    #[arg(long)]\n    pub(crate) skip_on_missing_config: bool,\n    /// The prek version that installs the hook.\n    #[arg(long)]\n    pub(crate) script_version: Option<usize>,\n    #[arg(last = true)]\n    pub(crate) args: Vec<OsString>,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct CacheNamespace {\n    #[command(subcommand)]\n    pub(crate) command: CacheCommand,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct UtilNamespace {\n    #[command(subcommand)]\n    pub(crate) command: UtilCommand,\n}\n\n#[derive(Debug, Subcommand)]\npub(crate) enum UtilCommand {\n    /// Show file identification tags.\n    Identify(IdentifyArgs),\n    /// List all built-in hooks bundled with prek.\n    ListBuiltins(ListBuiltinsArgs),\n    /// Install Git shims in a directory intended for use with `git config init.templateDir`.\n    #[command(alias = \"init-templatedir\")]\n    InitTemplateDir(InitTemplateDirArgs),\n    /// Convert a YAML configuration file to prek.toml.\n    YamlToToml(YamlToTomlArgs),\n    /// Generate shell completion scripts.\n    #[command(hide = true)]\n    GenerateShellCompletion(GenerateShellCompletionArgs),\n}\n\n#[derive(Debug, Args)]\npub(crate) struct YamlToTomlArgs {\n    /// The YAML configuration file to convert. If omitted, discovers\n    /// `.pre-commit-config.yaml` or `.pre-commit-config.yml` in the current directory.\n    #[arg(value_name = \"CONFIG\", value_hint = ValueHint::FilePath)]\n    pub(crate) input: Option<PathBuf>,\n\n    /// Path to write the generated prek.toml file.\n    /// Defaults to `prek.toml` in the same directory as the input file.\n    #[arg(short, long, value_name = \"OUTPUT\", value_hint = ValueHint::FilePath)]\n    pub(crate) output: Option<PathBuf>,\n\n    /// Overwrite the output file if it already exists.\n    #[arg(long)]\n    pub(crate) force: bool,\n}\n\n#[derive(Debug, Subcommand)]\npub(crate) enum CacheCommand {\n    /// Show the location of the prek cache.\n    Dir,\n    /// Remove unused cached repositories, hook environments, and other data.\n    GC(CacheGcArgs),\n    /// Remove all prek cached data.\n    Clean,\n    /// Show the size of the prek cache.\n    Size(SizeArgs),\n}\n\n#[derive(Args, Debug)]\npub struct SizeArgs {\n    /// Display the cache size in human-readable format (e.g., `1.2 GiB` instead of raw bytes).\n    #[arg(long = \"human\", short = 'H', alias = \"human-readable\")]\n    pub(crate) human: bool,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct CacheGcArgs {\n    /// Print what would be removed, but do not delete anything.\n    #[arg(long)]\n    pub(crate) dry_run: bool,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct SelfNamespace {\n    #[command(subcommand)]\n    pub(crate) command: SelfCommand,\n}\n\n#[derive(Debug, Subcommand)]\npub(crate) enum SelfCommand {\n    /// Update prek.\n    Update(SelfUpdateArgs),\n}\n\n#[derive(Debug, Args)]\npub(crate) struct SelfUpdateArgs {\n    /// Update to the specified version.\n    /// If not provided, prek will update to the latest version.\n    pub target_version: Option<String>,\n\n    /// A GitHub token for authentication.\n    /// A token is not required but can be used to reduce the chance of encountering rate limits.\n    #[arg(long, env = EnvVars::GITHUB_TOKEN)]\n    pub token: Option<String>,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct GenerateShellCompletionArgs {\n    /// The shell to generate the completion script for\n    #[arg(value_enum)]\n    pub shell: clap_complete::Shell,\n}\n\n#[derive(Debug, Args)]\npub(crate) struct InitTemplateDirArgs {\n    /// The directory in which to write the Git shim.\n    pub(crate) directory: PathBuf,\n\n    /// Assume cloned repos should have a `pre-commit` config.\n    #[arg(long)]\n    pub(crate) no_allow_missing_config: bool,\n\n    /// Which Git shim(s) to install.\n    ///\n    /// Specifies which Git hook type(s) you want to install shims for.\n    /// Can be specified multiple times to install shims for multiple hook types.\n    ///\n    /// If not specified, uses `default_install_hook_types` from the config file,\n    /// or defaults to `pre-commit` if that is also not set.\n    #[arg(short = 't', long = \"hook-type\", value_name = \"HOOK_TYPE\", value_enum)]\n    pub(crate) hook_types: Vec<HookType>,\n}\n\n#[cfg(test)]\nmod _gen {\n    use crate::cli::Cli;\n    use anyhow::{Result, bail};\n    use clap::{Command, CommandFactory};\n    use itertools::Itertools;\n    use prek_consts::env_vars::EnvVars;\n    use pretty_assertions::StrComparison;\n    use std::cmp::max;\n    use std::path::PathBuf;\n\n    const ROOT_DIR: &str = concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../\");\n\n    enum Mode {\n        /// Update the content.\n        Write,\n\n        /// Don't write to the file, check if the file is up-to-date and error if not.\n        Check,\n\n        /// Write the generated help to stdout.\n        DryRun,\n    }\n\n    fn generate(mut cmd: Command) -> String {\n        let mut output = String::new();\n\n        cmd.build();\n\n        let mut parents = Vec::new();\n\n        output.push_str(\"# CLI Reference\\n\\n\");\n        generate_command(&mut output, &cmd, &mut parents);\n\n        let mut output = output.replace(\"\\r\\n\", \"\\n\");\n        // Trim trailing whitespace\n        while output.ends_with('\\n') {\n            output.pop();\n        }\n        output.push('\\n');\n\n        output\n    }\n\n    #[allow(clippy::format_push_string)]\n    fn generate_command<'a>(\n        output: &mut String,\n        command: &'a Command,\n        parents: &mut Vec<&'a Command>,\n    ) {\n        if command.is_hide_set() {\n            return;\n        }\n\n        // Generate the command header.\n        let name = if parents.is_empty() {\n            command.get_name().to_string()\n        } else {\n            format!(\n                \"{} {}\",\n                parents.iter().map(|cmd| cmd.get_name()).join(\" \"),\n                command.get_name()\n            )\n        };\n\n        // Display the top-level `prek` command at the same level as its children\n        let level = max(2, parents.len() + 1);\n        output.push_str(&format!(\"{} {name}\\n\\n\", \"#\".repeat(level)));\n\n        // Display the command description.\n        if let Some(about) = command.get_long_about().or_else(|| command.get_about()) {\n            output.push_str(&about.to_string());\n            output.push_str(\"\\n\\n\");\n        }\n\n        // Display the usage\n        {\n            // This appears to be the simplest way to get rendered usage from Clap,\n            // it is complicated to render it manually. It's annoying that it\n            // requires a mutable reference but it doesn't really matter.\n            let mut command = command.clone();\n            output.push_str(\"<h3 class=\\\"cli-reference\\\">Usage</h3>\\n\\n\");\n            output.push_str(&format!(\n                \"```\\n{}\\n```\",\n                command\n                    .render_usage()\n                    .to_string()\n                    .trim_start_matches(\"Usage: \"),\n            ));\n            output.push_str(\"\\n\\n\");\n        }\n\n        // Display a list of child commands\n        let mut subcommands = command.get_subcommands().peekable();\n        let has_subcommands = subcommands.peek().is_some();\n        if has_subcommands {\n            output.push_str(\"<h3 class=\\\"cli-reference\\\">Commands</h3>\\n\\n\");\n            output.push_str(\"<dl class=\\\"cli-reference\\\">\");\n\n            for subcommand in subcommands {\n                if subcommand.is_hide_set() {\n                    continue;\n                }\n                let subcommand_name = format!(\"{name} {}\", subcommand.get_name());\n                output.push_str(&format!(\n                    \"<dt><a href=\\\"#{}\\\"><code>{subcommand_name}</code></a></dt>\",\n                    subcommand_name.replace(' ', \"-\")\n                ));\n                if let Some(about) = subcommand.get_about() {\n                    output.push_str(&format!(\n                        \"<dd>{}</dd>\\n\",\n                        markdown::to_html(&about.to_string())\n                    ));\n                }\n            }\n\n            output.push_str(\"</dl>\\n\\n\");\n        }\n\n        // Do not display options for commands with children\n        if !has_subcommands {\n            let name_key = name.replace(' ', \"-\");\n\n            // Display positional arguments\n            let mut arguments = command\n                .get_positionals()\n                .filter(|arg| !arg.is_hide_set())\n                .peekable();\n\n            if arguments.peek().is_some() {\n                output.push_str(\"<h3 class=\\\"cli-reference\\\">Arguments</h3>\\n\\n\");\n                output.push_str(\"<dl class=\\\"cli-reference\\\">\");\n\n                for arg in arguments {\n                    let id = format!(\"{name_key}--{}\", arg.get_id());\n                    output.push_str(&format!(\"<dt id=\\\"{id}\\\">\"));\n                    output.push_str(&format!(\n                        \"<a href=\\\"#{id}\\\"><code>{}</code></a>\",\n                        arg.get_value_names()\n                            .unwrap()\n                            .iter()\n                            .next()\n                            .unwrap()\n                            .to_string()\n                            .to_uppercase(),\n                    ));\n                    output.push_str(\"</dt>\");\n                    if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {\n                        output.push_str(\"<dd>\");\n                        output.push_str(&format!(\"{}\\n\", markdown::to_html(&help.to_string())));\n                        output.push_str(\"</dd>\");\n                    }\n                }\n\n                output.push_str(\"</dl>\\n\\n\");\n            }\n\n            // Display options and flags\n            let mut options = command\n                .get_arguments()\n                .filter(|arg| !arg.is_positional())\n                .filter(|arg| !arg.is_hide_set())\n                .sorted_by_key(|arg| arg.get_id())\n                .peekable();\n\n            if options.peek().is_some() {\n                output.push_str(\"<h3 class=\\\"cli-reference\\\">Options</h3>\\n\\n\");\n                output.push_str(\"<dl class=\\\"cli-reference\\\">\");\n                for opt in options {\n                    let Some(long) = opt.get_long() else { continue };\n                    let id = format!(\"{name_key}--{long}\");\n\n                    output.push_str(&format!(\"<dt id=\\\"{id}\\\">\"));\n                    output.push_str(&format!(\"<a href=\\\"#{id}\\\"><code>--{long}</code></a>\"));\n                    for long_alias in opt.get_all_aliases().into_iter().flatten() {\n                        output.push_str(&format!(\", <code>--{long_alias}</code>\"));\n                    }\n                    if let Some(short) = opt.get_short() {\n                        output.push_str(&format!(\", <code>-{short}</code>\"));\n                    }\n                    for short_alias in opt.get_all_short_aliases().into_iter().flatten() {\n                        output.push_str(&format!(\", <code>-{short_alias}</code>\"));\n                    }\n\n                    // Re-implements private `Arg::is_takes_value_set` used in `Command::get_opts`\n                    if opt\n                        .get_num_args()\n                        .unwrap_or_else(|| 1.into())\n                        .takes_values()\n                    {\n                        if let Some(values) = opt.get_value_names() {\n                            for value in values {\n                                output.push_str(&format!(\n                                    \" <i>{}</i>\",\n                                    value.to_lowercase().replace('_', \"-\")\n                                ));\n                            }\n                        }\n                    }\n                    output.push_str(\"</dt>\");\n                    if let Some(help) = opt.get_long_help().or_else(|| opt.get_help()) {\n                        output.push_str(\"<dd>\");\n                        output.push_str(&format!(\"{}\\n\", markdown::to_html(&help.to_string())));\n                        emit_env_option(opt, output);\n                        emit_default_option(opt, output);\n                        emit_possible_options(opt, output);\n                        output.push_str(\"</dd>\");\n                    }\n                }\n\n                output.push_str(\"</dl>\");\n            }\n\n            output.push_str(\"\\n\\n\");\n        }\n\n        parents.push(command);\n\n        // Recurse to all the subcommands.\n        for subcommand in command.get_subcommands() {\n            generate_command(output, subcommand, parents);\n        }\n\n        parents.pop();\n    }\n\n    fn emit_env_option(opt: &clap::Arg, output: &mut String) {\n        if opt.is_hide_env_set() {\n            return;\n        }\n        if let Some(env) = opt.get_env() {\n            output.push_str(&markdown::to_html(&format!(\n                \"May also be set with the `{}` environment variable.\",\n                env.to_string_lossy()\n            )));\n        }\n    }\n\n    fn emit_default_option(opt: &clap::Arg, output: &mut String) {\n        if opt.is_hide_default_value_set() || !opt.get_num_args().expect(\"built\").takes_values() {\n            return;\n        }\n\n        let values = opt.get_default_values();\n        if !values.is_empty() {\n            let value = format!(\n                \"\\n[default: {}]\",\n                opt.get_default_values()\n                    .iter()\n                    .map(|s| s.to_string_lossy())\n                    .join(\",\")\n            );\n            output.push_str(&markdown::to_html(&value));\n        }\n    }\n\n    fn emit_possible_options(opt: &clap::Arg, output: &mut String) {\n        if opt.is_hide_possible_values_set() {\n            return;\n        }\n\n        let values = opt.get_possible_values();\n        if !values.is_empty() {\n            let value = format!(\n                \"\\nPossible values:\\n{}\",\n                values\n                    .into_iter()\n                    .filter(|value| !value.is_hide_set())\n                    .map(|value| {\n                        let name = value.get_name();\n                        value.get_help().map_or_else(\n                            || format!(\" - `{name}`\"),\n                            |help| format!(\" - `{name}`:  {help}\"),\n                        )\n                    })\n                    .collect_vec()\n                    .join(\"\\n\"),\n            );\n            output.push_str(&markdown::to_html(&value));\n        }\n    }\n\n    #[test]\n    fn generate_cli_reference() -> Result<()> {\n        let mode = if EnvVars::is_set(EnvVars::PREK_GENERATE) {\n            Mode::Write\n        } else {\n            Mode::Check\n        };\n\n        let reference_string = generate(Cli::command());\n        let filename = \"cli.md\";\n        let reference_path = PathBuf::from(ROOT_DIR).join(\"docs\").join(filename);\n\n        match mode {\n            Mode::DryRun => {\n                anstream::println!(\"{reference_string}\");\n            }\n            Mode::Check => match fs_err::read_to_string(&reference_path) {\n                Ok(current) => {\n                    if current == reference_string {\n                        anstream::println!(\"Up-to-date: {filename}\");\n                    } else {\n                        let comparison = StrComparison::new(&current, &reference_string);\n                        bail!(\n                            \"{filename} changed, please run `mise run generate` to update:\\n{comparison}\"\n                        );\n                    }\n                }\n                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n                    bail!(\"{filename} not found, please run `mise run generate` to generate\");\n                }\n                Err(err) => {\n                    bail!(\"{filename} changed, please run `mise run generate` to update:\\n{err}\");\n                }\n            },\n            Mode::Write => match fs_err::read_to_string(&reference_path) {\n                Ok(current) => {\n                    if current == reference_string {\n                        anstream::println!(\"Up-to-date: {filename}\");\n                    } else {\n                        anstream::println!(\"Updating: {filename}\");\n                        fs_err::write(reference_path, reference_string.as_bytes())?;\n                    }\n                }\n                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n                    anstream::println!(\"Updating: {filename}\");\n                    fs_err::write(reference_path, reference_string.as_bytes())?;\n                }\n                Err(err) => {\n                    bail!(\n                        \"{filename} changed, please run `cargo dev generate-cli-reference`:\\n{err}\"\n                    );\n                }\n            },\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/reporter.rs",
    "content": "use std::borrow::Cow;\nuse std::sync::{Arc, Mutex, Weak};\nuse std::time::Duration;\n\nuse indicatif::{MultiProgress, ProgressBar, ProgressStyle};\nuse owo_colors::OwoColorize;\nuse rustc_hash::FxHashMap;\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::hook::Hook;\nuse crate::printer::Printer;\nuse crate::workspace;\n\n/// Current progress reporter used to suspend rendering while printing normal output.\nstatic CURRENT_REPORTER: Mutex<Option<Weak<ProgressReporter>>> = Mutex::new(None);\n\n/// Set the current reporter for lock acquisition warnings.\nfn set_current_reporter(reporter: Option<&Arc<ProgressReporter>>) {\n    *CURRENT_REPORTER.lock().unwrap() = reporter.map(Arc::downgrade);\n}\n\n/// Suspend progress rendering while emitting normal output.\n///\n/// If a progress reporter is currently active, this runs `f` inside\n/// `indicatif::MultiProgress::suspend` to avoid corrupting the progress display.\n/// If no reporter is active (or it has already been dropped), this just runs `f`.\npub(crate) fn suspend(f: impl FnOnce() + Send + 'static) {\n    let reporter = CURRENT_REPORTER.lock().unwrap().clone();\n    match reporter.and_then(|r| r.upgrade()) {\n        Some(reporter) => reporter.children.suspend(f),\n        None => f(),\n    }\n}\n\n#[derive(Default, Debug)]\nstruct BarState {\n    /// A map of progress bars, by ID.\n    bars: FxHashMap<usize, ProgressBar>,\n    /// A monotonic counter for bar IDs.\n    id: usize,\n}\n\nimpl BarState {\n    /// Returns a unique ID for a new progress bar.\n    fn id(&mut self) -> usize {\n        self.id += 1;\n        self.id\n    }\n}\n\nstruct ProgressReporter {\n    printer: Printer,\n    root: ProgressBar,\n    state: Arc<Mutex<BarState>>,\n    children: MultiProgress,\n}\n\nimpl ProgressReporter {\n    fn new(root: ProgressBar, children: MultiProgress, printer: Printer) -> Self {\n        Self {\n            printer,\n            root,\n            state: Arc::default(),\n            children,\n        }\n    }\n\n    fn on_start(&self, msg: impl Into<Cow<'static, str>>) -> usize {\n        let mut state = self.state.lock().unwrap();\n        let id = state.id();\n\n        let progress = self.children.insert_before(\n            &self.root,\n            ProgressBar::with_draw_target(None, self.printer.target()),\n        );\n\n        progress.set_style(ProgressStyle::with_template(\"{wide_msg}\").unwrap());\n        progress.set_message(msg);\n\n        state.bars.insert(id, progress);\n        id\n    }\n\n    fn on_progress(&self, id: usize) {\n        let progress = {\n            let mut state = self.state.lock().unwrap();\n            state.bars.remove(&id).unwrap()\n        };\n\n        self.root.inc(1);\n        progress.finish_and_clear();\n    }\n\n    fn on_complete(&self) {\n        self.root.set_message(\"\");\n        self.root.finish_and_clear();\n    }\n}\n\nimpl From<Printer> for ProgressReporter {\n    fn from(printer: Printer) -> Self {\n        let multi = MultiProgress::with_draw_target(printer.target());\n        let root = multi.add(ProgressBar::with_draw_target(None, printer.target()));\n        root.enable_steady_tick(Duration::from_millis(200));\n        root.set_style(\n            ProgressStyle::with_template(\"{spinner:.white} {msg:.dim}\")\n                .unwrap()\n                .tick_strings(&[\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"]),\n        );\n\n        Self::new(root, multi, printer)\n    }\n}\n\npub(crate) struct HookInitReporter {\n    reporter: Arc<ProgressReporter>,\n}\n\nimpl HookInitReporter {\n    pub(crate) fn new(printer: Printer) -> Self {\n        let reporter = Arc::new(ProgressReporter::from(printer));\n        set_current_reporter(Some(&reporter));\n        Self { reporter }\n    }\n}\n\nimpl workspace::HookInitReporter for HookInitReporter {\n    fn on_clone_start(&self, repo: &str) -> usize {\n        self.reporter\n            .root\n            .set_message(format!(\"{}\", \"Cloning repos...\".bold().cyan()));\n\n        self.reporter\n            .on_start(format!(\"{} {}\", \"Cloning\".bold().cyan(), repo.dimmed()))\n    }\n\n    fn on_clone_complete(&self, id: usize) {\n        self.reporter.on_progress(id);\n    }\n\n    fn on_complete(&self) {\n        self.reporter.on_complete();\n    }\n}\n\npub(crate) struct HookInstallReporter {\n    reporter: Arc<ProgressReporter>,\n}\n\nimpl HookInstallReporter {\n    pub(crate) fn new(printer: Printer) -> Self {\n        let reporter = Arc::new(ProgressReporter::from(printer));\n        set_current_reporter(Some(&reporter));\n        Self { reporter }\n    }\n}\n\nimpl HookInstallReporter {\n    pub fn on_install_start(&self, hook: &Hook) -> usize {\n        self.reporter\n            .root\n            .set_message(format!(\"{}\", \"Installing hooks...\".bold().cyan()));\n\n        self.reporter.on_start(format!(\n            \"{} {}\",\n            \"Installing\".bold().cyan(),\n            hook.id.dimmed(),\n        ))\n    }\n\n    pub fn on_install_complete(&self, id: usize) {\n        self.reporter.on_progress(id);\n    }\n\n    pub fn on_complete(&self) {\n        self.reporter.on_complete();\n    }\n}\n\npub(crate) struct HookRunReporter {\n    reporter: Arc<ProgressReporter>,\n    dots: usize,\n}\n\nimpl HookRunReporter {\n    pub fn new(printer: Printer, dots: usize) -> Self {\n        let reporter = Arc::new(ProgressReporter::from(printer));\n        set_current_reporter(Some(&reporter));\n\n        Self { reporter, dots }\n    }\n\n    pub fn on_run_start(&self, hook: &Hook, len: usize) -> usize {\n        self.reporter\n            .root\n            .set_message(format!(\"{}\", \"Running hooks...\".bold().cyan()));\n\n        let mut state = self.reporter.state.lock().unwrap();\n        let id = state.id();\n\n        // len == 0 indicates an unknown length; use 1 to show an indeterminate bar.\n        let len = if len == 0 { 1 } else { len };\n        let progress = self.reporter.children.insert_before(\n            &self.reporter.root,\n            ProgressBar::with_draw_target(Some(len as u64), self.reporter.printer.target()),\n        );\n\n        let dots = self.dots.saturating_sub(hook.name.width());\n        progress.enable_steady_tick(Duration::from_millis(200));\n        progress.set_style(\n            ProgressStyle::with_template(&format!(\"{{msg}}{{bar:{dots}.green/dim}}\"))\n                .unwrap()\n                .progress_chars(\"..\"),\n        );\n        progress.set_message(hook.name.clone());\n        state.bars.insert(id, progress);\n        id\n    }\n\n    pub fn on_run_progress(&self, id: usize, completed: u64) {\n        let state = self.reporter.state.lock().unwrap();\n        let progress = &state.bars[&id];\n        progress.inc(completed);\n    }\n\n    pub fn on_run_complete(&self, id: usize) {\n        let progress = {\n            let mut state = self.reporter.state.lock().unwrap();\n            state.bars.remove(&id).unwrap()\n        };\n\n        self.reporter.root.inc(1);\n\n        // Clear the running line; final output is printed by the caller.\n        progress.finish_and_clear();\n    }\n\n    /// Temporarily suspend progress rendering while emitting normal output.\n    ///\n    /// This helps prevent the progress UI from being corrupted by concurrent writes.\n    pub fn suspend<R>(&self, f: impl FnOnce() -> R) -> R {\n        self.reporter.children.suspend(f)\n    }\n\n    pub fn on_complete(&self) {\n        self.reporter.on_complete();\n    }\n}\n\n#[derive(Clone)]\npub(crate) struct AutoUpdateReporter {\n    reporter: Arc<ProgressReporter>,\n}\n\nimpl AutoUpdateReporter {\n    pub(crate) fn new(printer: Printer) -> Self {\n        let reporter = Arc::new(ProgressReporter::from(printer));\n        set_current_reporter(Some(&reporter));\n        Self { reporter }\n    }\n}\n\nimpl AutoUpdateReporter {\n    pub fn on_update_start(&self, repo: &str) -> usize {\n        self.reporter\n            .root\n            .set_message(format!(\"{}\", \"Updating repos...\".bold().cyan()));\n\n        self.reporter\n            .on_start(format!(\"{} {}\", \"Updating\".bold().cyan(), repo.dimmed()))\n    }\n\n    pub fn on_update_complete(&self, id: usize) {\n        self.reporter.on_progress(id);\n    }\n\n    pub fn on_complete(&self) {\n        self.reporter.on_complete();\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct CleaningReporter {\n    bar: ProgressBar,\n}\n\nimpl CleaningReporter {\n    pub(crate) fn new(printer: Printer, max: usize) -> Self {\n        let bar = ProgressBar::with_draw_target(Some(max as u64), printer.target());\n        bar.set_style(\n            ProgressStyle::with_template(\"{prefix} [{bar:20}] {percent}%\")\n                .unwrap()\n                .progress_chars(\"=> \"),\n        );\n        bar.set_prefix(format!(\"{}\", \"Cleaning\".bold().cyan()));\n        Self { bar }\n    }\n}\n\nimpl CleaningReporter {\n    pub(crate) fn on_clean(&self) {\n        self.bar.inc(1);\n    }\n\n    pub(crate) fn on_complete(&self) {\n        self.bar.finish_and_clear();\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/run/filter.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse itertools::{Either, Itertools};\nuse path_clean::PathClean;\nuse prek_consts::env_vars::EnvVars;\nuse prek_identify::{TagSet, tags_from_path};\nuse rayon::iter::{IntoParallelRefIterator, ParallelIterator};\nuse rustc_hash::FxHashSet;\nuse tracing::{debug, error, instrument};\n\nuse crate::config::{FilePattern, Stage};\nuse crate::git::GIT_ROOT;\nuse crate::hook::Hook;\nuse crate::workspace::Project;\nuse crate::{fs, git, warn_user};\n\n/// Filter filenames by include/exclude patterns.\npub(crate) struct FilenameFilter<'a> {\n    include: Option<&'a FilePattern>,\n    exclude: Option<&'a FilePattern>,\n}\n\nimpl<'a> FilenameFilter<'a> {\n    pub(crate) fn new(include: Option<&'a FilePattern>, exclude: Option<&'a FilePattern>) -> Self {\n        Self { include, exclude }\n    }\n\n    pub(crate) fn filter(&self, filename: &Path) -> bool {\n        let Some(filename) = filename.to_str() else {\n            return false;\n        };\n        if let Some(pattern) = &self.include {\n            if !pattern.is_match(filename) {\n                return false;\n            }\n        }\n        if let Some(pattern) = &self.exclude {\n            if pattern.is_match(filename) {\n                return false;\n            }\n        }\n        true\n    }\n}\n\n/// Filter files by tags.\npub(crate) struct FileTagFilter<'a> {\n    all: Option<&'a TagSet>,\n    any: Option<&'a TagSet>,\n    exclude: Option<&'a TagSet>,\n}\n\nimpl<'a> FileTagFilter<'a> {\n    fn new(\n        types: Option<&'a TagSet>,\n        types_or: Option<&'a TagSet>,\n        exclude_types: Option<&'a TagSet>,\n    ) -> Self {\n        Self {\n            all: types,\n            any: types_or,\n            exclude: exclude_types,\n        }\n    }\n\n    pub(crate) fn filter(&self, file_types: &TagSet) -> bool {\n        if self.all.is_some_and(|s| !s.is_subset(file_types)) {\n            return false;\n        }\n        if self\n            .any\n            .is_some_and(|s| !s.is_empty() && s.is_disjoint(file_types))\n        {\n            return false;\n        }\n        if self.exclude.is_some_and(|s| !s.is_disjoint(file_types)) {\n            return false;\n        }\n        true\n    }\n}\n\npub(crate) struct FileFilter<'a> {\n    filenames: Vec<&'a Path>,\n    filename_prefix: &'a Path,\n}\n\nimpl<'a> FileFilter<'a> {\n    /// Create a `FileFilter` for a project by filtering the input filenames with the project's relative path and include/exclude patterns.\n    /// `filenames` are paths relative to the workspace root.\n    #[instrument(level = \"trace\", skip_all, fields(project = %project))]\n    pub(crate) fn for_project<I>(\n        filenames: I,\n        project: &'a Project,\n        mut consumed_files: Option<&mut FxHashSet<&'a Path>>,\n    ) -> Self\n    where\n        I: Iterator<Item = &'a PathBuf> + Send,\n    {\n        let filter = FilenameFilter::new(\n            project.config().files.as_ref(),\n            project.config().exclude.as_ref(),\n        );\n\n        let orphan = project.config().orphan.unwrap_or(false);\n\n        // The order of below filters matters.\n        // If this is an orphan project, we must mark all files in its directory as consumed\n        // *before* applying the project's include/exclude patterns. This ensures that even\n        // files excluded by this project are still considered \"owned\" by it and hidden\n        // from parent projects.\n        let filenames = filenames\n            .map(PathBuf::as_path)\n            // Collect files that are inside the hook project directory.\n            .filter(|filename| filename.starts_with(project.relative_path()))\n            // Skip files that have already been consumed by subprojects.\n            .filter(|filename| {\n                if let Some(consumed_files) = consumed_files.as_mut() {\n                    if orphan {\n                        return consumed_files.insert(filename);\n                    }\n                    !consumed_files.contains(filename)\n                } else {\n                    true\n                }\n            })\n            // Strip the project-relative prefix before applying project-level include/exclude patterns.\n            .filter(|filename| {\n                let relative = filename\n                    .strip_prefix(project.relative_path())\n                    .expect(\"Filename should start with project relative path\");\n                filter.filter(relative)\n            })\n            .collect::<Vec<_>>();\n\n        Self {\n            filenames,\n            filename_prefix: project.relative_path(),\n        }\n    }\n\n    pub(crate) fn len(&self) -> usize {\n        self.filenames.len()\n    }\n\n    /// Filter filenames by type tags for a specific hook.\n    pub(crate) fn by_type(\n        &self,\n        types: Option<&TagSet>,\n        types_or: Option<&TagSet>,\n        exclude_types: Option<&TagSet>,\n    ) -> Vec<&Path> {\n        let filter = FileTagFilter::new(types, types_or, exclude_types);\n        let filenames: Vec<_> = self\n            .filenames\n            .par_iter()\n            .filter(|filename| match tags_from_path(filename) {\n                Ok(tags) => filter.filter(&tags),\n                Err(err) => {\n                    error!(filename = ?filename.display(), error = %err, \"Failed to get tags\");\n                    false\n                }\n            })\n            .copied()\n            .collect();\n\n        filenames\n    }\n\n    /// Filter filenames by file patterns and tags for a specific hook.\n    #[instrument(level = \"trace\", skip_all, fields(hook = ?hook.id))]\n    pub(crate) fn for_hook(&self, hook: &Hook) -> Vec<&Path> {\n        // Filter by hook `files` and `exclude` patterns.\n        let filter = FilenameFilter::new(hook.files.as_ref(), hook.exclude.as_ref());\n\n        let filenames = self.filenames.par_iter().filter(|filename| {\n            // Strip the project-relative prefix before applying hook-level include/exclude patterns.\n            if let Ok(relative) = filename.strip_prefix(self.filename_prefix) {\n                filter.filter(relative)\n            } else {\n                false\n            }\n        });\n\n        // Filter by hook `types`, `types_or` and `exclude_types`.\n        let filter = FileTagFilter::new(\n            Some(&hook.types),\n            Some(&hook.types_or),\n            Some(&hook.exclude_types),\n        );\n        let filenames = filenames.filter(|filename| match tags_from_path(filename) {\n            Ok(tags) => filter.filter(&tags),\n            Err(err) => {\n                error!(filename = ?filename.display(), error = %err, \"Failed to get tags\");\n                false\n            }\n        });\n\n        // Strip the prefix to get relative paths.\n        let filenames: Vec<_> = filenames\n            .map(|p| {\n                p.strip_prefix(self.filename_prefix)\n                    .expect(\"Filename should start with project relative path\")\n            })\n            .collect();\n\n        filenames\n    }\n}\n\n#[derive(Default)]\npub(crate) struct CollectOptions {\n    pub(crate) hook_stage: Stage,\n    pub(crate) from_ref: Option<String>,\n    pub(crate) to_ref: Option<String>,\n    pub(crate) all_files: bool,\n    pub(crate) files: Vec<String>,\n    pub(crate) directories: Vec<String>,\n    pub(crate) commit_msg_filename: Option<String>,\n}\n\nimpl CollectOptions {\n    pub(crate) fn all_files() -> Self {\n        Self {\n            all_files: true,\n            ..Default::default()\n        }\n    }\n}\n\n/// Get all filenames to run hooks on.\n/// Returns a list of file paths relative to the workspace root.\n#[instrument(level = \"trace\", skip_all)]\npub(crate) async fn collect_files(root: &Path, opts: CollectOptions) -> Result<Vec<PathBuf>> {\n    let CollectOptions {\n        hook_stage,\n        from_ref,\n        to_ref,\n        all_files,\n        files,\n        directories,\n        commit_msg_filename,\n    } = opts;\n\n    let git_root = GIT_ROOT.as_ref()?;\n\n    // The workspace root relative to the git root.\n    let relative_root = root.strip_prefix(git_root).with_context(|| {\n        format!(\n            \"Workspace root `{}` is not under git root `{}`\",\n            root.display(),\n            git_root.display()\n        )\n    })?;\n\n    let filenames = collect_files_from_args(\n        git_root,\n        root,\n        hook_stage,\n        from_ref,\n        to_ref,\n        all_files,\n        files,\n        directories,\n        commit_msg_filename,\n    )\n    .await?;\n\n    // Convert filenames to be relative to the workspace root.\n    let mut filenames = filenames\n        .into_iter()\n        .filter_map(|filename| {\n            // Only keep files under the workspace root.\n            filename\n                .strip_prefix(relative_root)\n                .map(|p| fs::normalize_path(p.to_path_buf()))\n                .ok()\n        })\n        .collect::<Vec<_>>();\n\n    // Sort filenames if in tests to make the order consistent.\n    if EnvVars::is_set(EnvVars::PREK_INTERNAL__SORT_FILENAMES) {\n        filenames.sort_unstable();\n    }\n\n    Ok(filenames)\n}\n\nfn adjust_relative_path(path: &str, new_cwd: &Path) -> Result<PathBuf, std::io::Error> {\n    let absolute = std::path::absolute(path)?.clean();\n    fs::relative_to(absolute, new_cwd)\n}\n\n/// Collect files to run hooks on.\n/// Returns a list of file paths relative to the git root.\n#[allow(clippy::too_many_arguments)]\nasync fn collect_files_from_args(\n    git_root: &Path,\n    workspace_root: &Path,\n    hook_stage: Stage,\n    from_ref: Option<String>,\n    to_ref: Option<String>,\n    all_files: bool,\n    files: Vec<String>,\n    directories: Vec<String>,\n    commit_msg_filename: Option<String>,\n) -> Result<Vec<PathBuf>> {\n    if !hook_stage.operate_on_files() {\n        return Ok(vec![]);\n    }\n\n    if hook_stage == Stage::PrepareCommitMsg || hook_stage == Stage::CommitMsg {\n        let path = commit_msg_filename.expect(\"commit_msg_filename should be set\");\n        let path = adjust_relative_path(&path, git_root)?;\n        return Ok(vec![path]);\n    }\n\n    if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) {\n        let files = git::get_changed_files(&from_ref, &to_ref, workspace_root).await?;\n        debug!(\n            \"Files changed between {} and {}: {}\",\n            from_ref,\n            to_ref,\n            files.len()\n        );\n        return Ok(files);\n    }\n\n    if !files.is_empty() || !directories.is_empty() {\n        // By default, `pre-commit` add `types: [file]` for all hooks,\n        // so `pre-commit` will ignore user provided directories.\n        // We do the same here for compatibility.\n        // For `types: [directory]`, `pre-commit` passes the directory names to the hook directly.\n\n        // Fun fact: if a hook specified `types: [directory]`, it won't run in `--all-files` mode.\n\n        let (exists, non_exists): (FxHashSet<_>, Vec<_>) =\n            files.into_iter().partition_map(|filename| {\n                if std::fs::exists(&filename).unwrap_or(false) {\n                    Either::Left(filename)\n                } else {\n                    Either::Right(filename)\n                }\n            });\n        if !non_exists.is_empty() {\n            if non_exists.len() == 1 {\n                warn_user!(\n                    \"This file does not exist and will be ignored: `{}`\",\n                    non_exists[0]\n                );\n            } else {\n                warn_user!(\n                    \"These files do not exist and will be ignored: `{}`\",\n                    non_exists.join(\", \")\n                );\n            }\n        }\n\n        let mut exists = exists\n            .into_iter()\n            .map(|filename| adjust_relative_path(&filename, git_root).map(fs::normalize_path))\n            .collect::<Result<FxHashSet<_>, _>>()?;\n\n        for dir in directories {\n            let dir = adjust_relative_path(&dir, git_root)?;\n            let dir_files = git::ls_files(git_root, &dir).await?;\n            for file in dir_files {\n                let file = fs::normalize_path(file);\n                exists.insert(file);\n            }\n        }\n\n        debug!(\"Files passed as arguments: {}\", exists.len());\n        return Ok(exists.into_iter().collect());\n    }\n\n    if all_files {\n        let files = git::ls_files(git_root, workspace_root).await?;\n        debug!(\"All files in the workspace: {}\", files.len());\n        return Ok(files);\n    }\n\n    if git::is_in_merge_conflict().await? {\n        let files = git::get_conflicted_files(workspace_root).await?;\n        debug!(\"Conflicted files: {}\", files.len());\n        return Ok(files);\n    }\n\n    let files = git::get_staged_files(workspace_root).await?;\n    debug!(\"Staged files: {}\", files.len());\n\n    Ok(files)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::GlobPatterns;\n\n    fn glob_pattern(pattern: &str) -> FilePattern {\n        FilePattern::Glob(GlobPatterns::new(vec![pattern.to_string()]).unwrap())\n    }\n\n    #[test]\n    fn filename_filter_supports_glob_include_and_exclude() {\n        let include = glob_pattern(\"src/**/*.rs\");\n        let exclude = glob_pattern(\"src/**/ignored.rs\");\n        let filter = FilenameFilter::new(Some(&include), Some(&exclude));\n\n        assert!(filter.filter(Path::new(\"src/lib/main.rs\")));\n        assert!(!filter.filter(Path::new(\"src/lib/ignored.rs\")));\n        assert!(!filter.filter(Path::new(\"tests/main.rs\")));\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/run/keeper.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::process::Command;\nuse std::sync::Mutex;\n\nuse anstream::eprintln;\nuse anyhow::Result;\nuse owo_colors::OwoColorize;\nuse tracing::{debug, error, trace};\n\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::cleanup::add_cleanup;\nuse crate::fs::Simplified;\nuse crate::git::{self, GIT, git_cmd};\nuse crate::store::Store;\n\nstatic RESTORE_WORKTREE: Mutex<Option<WorkTreeKeeper>> = Mutex::new(None);\n\nstruct IntentToAddKeeper(Vec<PathBuf>);\nstruct WorkingTreeKeeper {\n    root: PathBuf,\n    patch: Option<PathBuf>,\n}\n\nimpl IntentToAddKeeper {\n    async fn clean(root: &Path) -> Result<Self> {\n        let files = git::intent_to_add_files(root).await?;\n        if files.is_empty() {\n            return Ok(Self(vec![]));\n        }\n\n        // TODO: xargs\n        git_cmd(\"git rm\")?\n            .arg(\"rm\")\n            .arg(\"--cached\")\n            .arg(\"--\")\n            .args(&files)\n            .check(true)\n            .stdout(std::process::Stdio::null())\n            .stderr(std::process::Stdio::null())\n            .status()\n            .await?;\n\n        Ok(Self(files))\n    }\n\n    fn restore(&self) -> Result<()> {\n        // Restore the intent-to-add changes.\n        if !self.0.is_empty() {\n            Command::new(GIT.as_ref()?)\n                .arg(\"add\")\n                .arg(\"--intent-to-add\")\n                .arg(\"--\")\n                // TODO: xargs\n                .args(&self.0)\n                .stdout(std::process::Stdio::null())\n                .stderr(std::process::Stdio::null())\n                .status()?;\n        }\n        Ok(())\n    }\n}\n\nimpl Drop for IntentToAddKeeper {\n    fn drop(&mut self) {\n        if let Err(err) = self.restore() {\n            eprintln!(\n                \"{}\",\n                format!(\"Failed to restore intent-to-add changes: {err}\").red()\n            );\n        }\n    }\n}\n\nimpl WorkingTreeKeeper {\n    async fn clean(root: &Path, patch_dir: &Path) -> Result<Self> {\n        let tree = git::write_tree().await?;\n\n        let mut cmd = git_cmd(\"git diff-index\")?;\n        let output = cmd\n            .arg(\"diff-index\")\n            .arg(\"--ignore-submodules\")\n            .arg(\"--binary\")\n            .arg(\"--exit-code\")\n            .arg(\"--no-color\")\n            .arg(\"--no-ext-diff\")\n            .arg(tree)\n            .arg(\"--\")\n            .arg(root)\n            .check(false)\n            .output()\n            .await?;\n\n        if output.status.success() {\n            debug!(\"Working tree is clean\");\n            // No non-staged changes\n            Ok(Self {\n                root: root.to_path_buf(),\n                patch: None,\n            })\n        } else if output.status.code() == Some(1) {\n            if output.stdout.trim_ascii().is_empty() {\n                trace!(\"diff-index status code 1 with empty stdout\");\n                // probably git auto crlf behavior quirks\n                Ok(Self {\n                    root: root.to_path_buf(),\n                    patch: None,\n                })\n            } else {\n                let now = std::time::SystemTime::now();\n                let pid = std::process::id();\n                let patch_name = format!(\n                    \"{}-{}.patch\",\n                    now.duration_since(std::time::UNIX_EPOCH)?.as_millis(),\n                    pid\n                );\n                let patch_path = patch_dir.join(&patch_name);\n\n                debug!(\"Unstaged changes detected\");\n                eprintln!(\n                    \"{}\",\n                    format!(\n                        \"Unstaged changes detected, stashing unstaged changes to `{}`\",\n                        patch_path.user_display()\n                    )\n                    .yellow()\n                    .bold()\n                );\n                fs_err::create_dir_all(patch_dir)?;\n                fs_err::write(&patch_path, output.stdout)?;\n\n                // Clean the working tree\n                debug!(\"Cleaning working tree\");\n                Self::checkout_working_tree(root)?;\n\n                Ok(Self {\n                    root: root.to_path_buf(),\n                    patch: Some(patch_path),\n                })\n            }\n        } else {\n            Err(cmd.check_status(output.status).unwrap_err().into())\n        }\n    }\n\n    fn checkout_working_tree(root: &Path) -> Result<()> {\n        let output = Command::new(GIT.as_ref()?)\n            .arg(\"-c\")\n            .arg(\"submodule.recurse=0\")\n            .arg(\"checkout\")\n            .arg(\"--\")\n            .arg(root)\n            // prevent recursive post-checkout hooks\n            .env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, \"1\")\n            .output()?;\n        if output.status.success() {\n            Ok(())\n        } else {\n            Err(anyhow::anyhow!(\n                \"Failed to checkout working tree: {output:?}\"\n            ))\n        }\n    }\n\n    fn git_apply(patch: &Path) -> Result<()> {\n        let output = Command::new(GIT.as_ref()?)\n            .arg(\"apply\")\n            .arg(\"--whitespace=nowarn\")\n            .arg(patch)\n            .output()?;\n        if output.status.success() {\n            Ok(())\n        } else {\n            Err(anyhow::anyhow!(\"Failed to apply the patch: {output:?}\"))\n        }\n    }\n\n    fn restore(&self) -> Result<()> {\n        let Some(patch) = self.patch.as_ref() else {\n            return Ok(());\n        };\n\n        // Try to apply the patch\n        if let Err(e) = Self::git_apply(patch) {\n            error!(\"{e}\");\n            eprintln!(\n                \"{}\",\n                \"Stashed changes conflicted with changes made by hook, rolling back the hook changes\".red().bold()\n            );\n\n            // Discard any changes made by hooks, and try applying the patch again.\n            Self::checkout_working_tree(&self.root)?;\n            Self::git_apply(patch)?;\n        }\n\n        eprintln!(\n            \"{}\",\n            format!(\n                \"Restored working tree changes from `{}`\",\n                patch.user_display()\n            )\n            .yellow()\n            .bold()\n        );\n\n        Ok(())\n    }\n}\n\nimpl Drop for WorkingTreeKeeper {\n    fn drop(&mut self) {\n        if let Err(err) = self.restore() {\n            eprintln!(\n                \"{}\",\n                format!(\"Failed to restore working tree changes: {err}\").red()\n            );\n        }\n    }\n}\n\n/// Clean Git intent-to-add files and working tree changes, and restore them when dropped.\npub struct WorkTreeKeeper {\n    intent_to_add: Option<IntentToAddKeeper>,\n    working_tree: Option<WorkingTreeKeeper>,\n}\n\n#[derive(Default)]\npub struct RestoreGuard {\n    _guard: (),\n}\n\nimpl Drop for RestoreGuard {\n    fn drop(&mut self) {\n        if let Some(mut keeper) = RESTORE_WORKTREE.lock().unwrap().take() {\n            keeper.restore();\n        }\n    }\n}\n\nimpl WorkTreeKeeper {\n    /// Clear intent-to-add changes from the index and clear the non-staged changes from the working directory.\n    /// Restore them when the instance is dropped.\n    pub async fn clean(store: &Store, root: &Path) -> Result<RestoreGuard> {\n        let cleaner = Self {\n            intent_to_add: Some(IntentToAddKeeper::clean(root).await?),\n            working_tree: Some(WorkingTreeKeeper::clean(root, &store.patches_dir()).await?),\n        };\n\n        // Set to the global for the cleanup hook.\n        *RESTORE_WORKTREE.lock().unwrap() = Some(cleaner);\n\n        // Make sure restoration when ctrl-c is pressed.\n        add_cleanup(|| {\n            if let Some(guard) = &mut *RESTORE_WORKTREE.lock().unwrap() {\n                guard.restore();\n            }\n        });\n\n        Ok(RestoreGuard::default())\n    }\n\n    /// Restore the intent-to-add changes and non-staged changes.\n    fn restore(&mut self) {\n        self.intent_to_add.take();\n        self.working_tree.take();\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/run/mod.rs",
    "content": "pub(crate) use filter::{CollectOptions, FileFilter, collect_files};\npub(crate) use run::{install_hooks, run};\npub(crate) use selector::{SelectorSource, Selectors};\n\nmod filter;\nmod keeper;\n#[allow(clippy::module_inception)]\nmod run;\nmod selector;\n"
  },
  {
    "path": "crates/prek/src/cli/run/run.rs",
    "content": "use std::fmt::Write as _;\nuse std::io::Write as _;\nuse std::path::PathBuf;\nuse std::rc::Rc;\nuse std::sync::{Arc, LazyLock};\n\nuse anyhow::{Context, Result};\nuse futures::stream::{FuturesUnordered, StreamExt};\nuse mea::once::OnceCell;\nuse mea::semaphore::Semaphore;\nuse owo_colors::OwoColorize;\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PREK_TOML};\nuse rand::SeedableRng;\nuse rand::prelude::{SliceRandom, StdRng};\nuse rustc_hash::{FxHashMap, FxHashSet};\nuse tracing::{debug, trace, warn};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::cli::reporter::{HookInitReporter, HookInstallReporter, HookRunReporter};\nuse crate::cli::run::keeper::WorkTreeKeeper;\nuse crate::cli::run::{CollectOptions, FileFilter, Selectors, collect_files};\nuse crate::cli::{ExitStatus, RunExtraArgs};\nuse crate::config::{Language, PassFilenames, Stage};\nuse crate::fs::CWD;\nuse crate::git::GIT_ROOT;\nuse crate::hook::{Hook, InstallInfo, InstalledHook, Repo};\nuse crate::printer::Printer;\nuse crate::run::{CONCURRENCY, USE_COLOR};\nuse crate::store::Store;\nuse crate::workspace::{Project, Workspace};\nuse crate::{git, warn_user};\n\n#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]\npub(crate) async fn run(\n    store: &Store,\n    config: Option<PathBuf>,\n    includes: Vec<String>,\n    skips: Vec<String>,\n    hook_stage: Option<Stage>,\n    from_ref: Option<String>,\n    to_ref: Option<String>,\n    all_files: bool,\n    files: Vec<String>,\n    directories: Vec<String>,\n    last_commit: bool,\n    show_diff_on_failure: bool,\n    fail_fast: bool,\n    dry_run: bool,\n    refresh: bool,\n    extra_args: RunExtraArgs,\n    verbose: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    // Convert `--last-commit` to `HEAD~1..HEAD`\n    let (from_ref, to_ref) = if last_commit {\n        (Some(\"HEAD~1\".to_string()), Some(\"HEAD\".to_string()))\n    } else {\n        (from_ref, to_ref)\n    };\n\n    // Prevent recursive post-checkout hooks.\n    if hook_stage == Some(Stage::PostCheckout)\n        && EnvVars::is_set(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT)\n    {\n        return Ok(ExitStatus::Success);\n    }\n\n    // Ensure we are in a git repository.\n    LazyLock::force(&GIT_ROOT).as_ref()?;\n\n    let should_stash = !all_files && files.is_empty() && directories.is_empty();\n\n    // Check if we have unresolved merge conflict files and fail fast.\n    if should_stash && git::has_unmerged_paths().await? {\n        anyhow::bail!(\"You have unmerged paths. Resolve them before running prek\");\n    }\n\n    let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;\n    let selectors = Selectors::load(&includes, &skips, &workspace_root)?;\n    let mut workspace =\n        Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?;\n\n    if should_stash {\n        workspace.check_configs_staged().await?;\n    }\n\n    let reporter = HookInitReporter::new(printer);\n    let lock = store.lock_async().await?;\n    store.track_configs(workspace.projects().iter().map(|p| p.config_file()))?;\n\n    let hooks = workspace\n        .init_hooks(store, Some(&reporter))\n        .await\n        .context(\"Failed to init hooks\")?;\n    let selected_hooks: Vec<_> = hooks\n        .into_iter()\n        .filter(|h| selectors.matches_hook(h))\n        .map(Arc::new)\n        .collect();\n\n    selectors.report_unused();\n\n    if selected_hooks.is_empty() {\n        writeln!(\n            printer.stderr(),\n            \"{}: No hooks found after filtering with the given selectors\",\n            \"error\".red().bold(),\n        )?;\n        if selectors.has_project_selectors() {\n            writeln!(\n                printer.stderr(),\n                \"\\n{} If you just added a new `{}` or `{}`, try rerunning your command with the `{}` flag to rescan the workspace.\",\n                \"hint:\".bold().yellow(),\n                PREK_TOML.cyan(),\n                PRE_COMMIT_CONFIG_YAML.cyan(),\n                \"--refresh\".cyan(),\n            )?;\n        }\n        return Ok(ExitStatus::Failure);\n    }\n\n    let (filtered_hooks, hook_stage) = if let Some(hook_stage) = hook_stage {\n        let hooks = selected_hooks\n            .iter()\n            .filter(|h| h.stages.contains(hook_stage))\n            .cloned()\n            .collect::<Vec<_>>();\n        (hooks, hook_stage)\n    } else {\n        // Try filtering by `pre-commit` stage first.\n        let mut hook_stage = Stage::PreCommit;\n        let mut hooks = selected_hooks\n            .iter()\n            .filter(|h| h.stages.contains(Stage::PreCommit))\n            .cloned()\n            .collect::<Vec<_>>();\n        if hooks.is_empty() && selectors.includes_only_hook_targets() {\n            // If no hooks found for `pre-commit` stage, try fallback to `manual` stage for hooks specified directly.\n            hook_stage = Stage::Manual;\n            hooks = selected_hooks\n                .iter()\n                .filter(|h| h.stages.contains(Stage::Manual))\n                .cloned()\n                .collect();\n        }\n        (hooks, hook_stage)\n    };\n\n    if filtered_hooks.is_empty() {\n        debug!(\n            stage = %hook_stage,\n            \"No hooks found for stage after filtering, exit early\"\n        );\n        return Ok(ExitStatus::Success);\n    }\n\n    debug!(\n        \"Hooks going to run: {:?}\",\n        filtered_hooks.iter().map(|h| &h.id).collect::<Vec<_>>()\n    );\n    let reporter = HookInstallReporter::new(printer);\n    let installed_hooks = install_hooks(filtered_hooks, store, &reporter).await?;\n\n    // Release the store lock.\n    drop(lock);\n\n    // Clear any unstaged changes from the git working directory.\n    let mut _guard = None;\n    if should_stash {\n        _guard = Some(\n            WorkTreeKeeper::clean(store, workspace.root())\n                .await\n                .context(\"Failed to clean work tree\")?,\n        );\n    }\n\n    set_env_vars(from_ref.as_ref(), to_ref.as_ref(), &extra_args);\n\n    let filenames = collect_files(\n        workspace.root(),\n        CollectOptions {\n            hook_stage,\n            from_ref,\n            to_ref,\n            all_files,\n            files,\n            directories,\n            commit_msg_filename: extra_args.commit_msg_filename,\n        },\n    )\n    .await\n    .context(\"Failed to collect files\")?;\n\n    // Change to the workspace root directory.\n    std::env::set_current_dir(workspace.root()).with_context(|| {\n        format!(\n            \"Failed to change directory to `{}`\",\n            workspace.root().display()\n        )\n    })?;\n\n    run_hooks(\n        &workspace,\n        &installed_hooks,\n        filenames,\n        store,\n        show_diff_on_failure,\n        fail_fast,\n        dry_run,\n        verbose,\n        printer,\n    )\n    .await\n}\n\n// `pre-commit` sets these environment variables for other git hooks.\nfn set_env_vars(from_ref: Option<&String>, to_ref: Option<&String>, args: &RunExtraArgs) {\n    unsafe {\n        std::env::set_var(\"PRE_COMMIT\", \"1\");\n\n        if let Some(source) = &args.prepare_commit_message_source {\n            std::env::set_var(\"PRE_COMMIT_COMMIT_MSG_SOURCE\", source);\n        }\n        if let Some(object) = &args.commit_object_name {\n            std::env::set_var(\"PRE_COMMIT_COMMIT_OBJECT_NAME\", object);\n        }\n        if let Some(from_ref) = from_ref {\n            std::env::set_var(\"PRE_COMMIT_ORIGIN\", from_ref);\n            std::env::set_var(\"PRE_COMMIT_FROM_REF\", from_ref);\n        }\n        if let Some(to_ref) = to_ref {\n            std::env::set_var(\"PRE_COMMIT_SOURCE\", to_ref);\n            std::env::set_var(\"PRE_COMMIT_TO_REF\", to_ref);\n        }\n        if let Some(upstream) = &args.pre_rebase_upstream {\n            std::env::set_var(\"PRE_COMMIT_PRE_REBASE_UPSTREAM\", upstream);\n        }\n        if let Some(branch) = &args.pre_rebase_branch {\n            std::env::set_var(\"PRE_COMMIT_PRE_REBASE_BRANCH\", branch);\n        }\n        if let Some(branch) = &args.local_branch {\n            std::env::set_var(\"PRE_COMMIT_LOCAL_BRANCH\", branch);\n        }\n        if let Some(branch) = &args.remote_branch {\n            std::env::set_var(\"PRE_COMMIT_REMOTE_BRANCH\", branch);\n        }\n        if let Some(name) = &args.remote_name {\n            std::env::set_var(\"PRE_COMMIT_REMOTE_NAME\", name);\n        }\n        if let Some(url) = &args.remote_url {\n            std::env::set_var(\"PRE_COMMIT_REMOTE_URL\", url);\n        }\n        if let Some(checkout) = &args.checkout_type {\n            std::env::set_var(\"PRE_COMMIT_CHECKOUT_TYPE\", checkout);\n        }\n        if args.is_squash_merge {\n            std::env::set_var(\"PRE_COMMIT_SQUASH_MERGE\", \"1\");\n        }\n        if let Some(command) = &args.rewrite_command {\n            std::env::set_var(\"PRE_COMMIT_REWRITE_COMMAND\", command);\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct LazyInstallInfo {\n    info: Arc<InstallInfo>,\n    health: OnceCell<bool>,\n}\n\nimpl LazyInstallInfo {\n    fn new(info: Arc<InstallInfo>) -> Self {\n        Self {\n            info,\n            health: OnceCell::new(),\n        }\n    }\n\n    fn matches(&self, hook: &Hook) -> bool {\n        self.info.matches(hook)\n    }\n\n    fn info(&self) -> Arc<InstallInfo> {\n        self.info.clone()\n    }\n\n    async fn ensure_healthy(&self) -> bool {\n        let info = self.info.clone();\n        *self\n            .health\n            .get_or_init(async move || match info.check_health().await {\n                Ok(()) => true,\n                Err(err) => {\n                    warn!(\n                        %err,\n                        path = %info.env_path.display(),\n                        \"Skipping unhealthy installed hook\"\n                    );\n                    false\n                }\n            })\n            .await\n    }\n}\n\npub async fn install_hooks(\n    hooks: Vec<Arc<Hook>>,\n    store: &Store,\n    reporter: &HookInstallReporter,\n) -> Result<Vec<InstalledHook>> {\n    let num_hooks = hooks.len();\n    let mut result = Vec::with_capacity(hooks.len());\n\n    let store_hooks = Rc::new(\n        store\n            .installed_hooks()\n            .await\n            .into_iter()\n            .map(LazyInstallInfo::new)\n            .collect::<Vec<_>>(),\n    );\n\n    // Group hooks by language to enable parallel installation across different languages.\n    let mut hooks_by_language = FxHashMap::default();\n    for hook in hooks {\n        let mut language = hook.language;\n        if hook.language == Language::Pygrep {\n            // Treat `pygrep` hooks as `python` hooks for installation purposes.\n            // They share the same installation logic.\n            language = Language::Python;\n        }\n        hooks_by_language\n            .entry(language)\n            .or_insert_with(Vec::new)\n            .push(hook);\n    }\n\n    let mut futures = FuturesUnordered::new();\n    let semaphore = Rc::new(Semaphore::new(*CONCURRENCY));\n\n    for (_, hooks) in hooks_by_language {\n        let partitions = partition_hooks(&hooks);\n\n        for hooks in partitions {\n            let semaphore = Rc::clone(&semaphore);\n            let store_hooks = Rc::clone(&store_hooks);\n\n            futures.push(async move {\n                let mut hook_envs = Vec::with_capacity(hooks.len());\n                let mut newly_installed = Vec::new();\n\n                for hook in hooks {\n                    if matches!(hook.repo(), Repo::Meta { .. } | Repo::Builtin { .. }) {\n                        debug!(\n                            \"Hook `{}` is a meta or builtin hook, no installation needed\",\n                            &hook\n                        );\n                        hook_envs.push(InstalledHook::NoNeedInstall(hook));\n                        continue;\n                    }\n\n                    let mut matched_info = None;\n\n                    for env in &newly_installed {\n                        if let InstalledHook::Installed { info, .. } = env {\n                            if info.matches(&hook) {\n                                matched_info = Some(info.clone());\n                                break;\n                            }\n                        }\n                    }\n\n                    if matched_info.is_none() {\n                        for env in store_hooks.iter() {\n                            if env.matches(&hook) {\n                                if env.ensure_healthy().await {\n                                    matched_info = Some(env.info());\n                                    break;\n                                }\n                            }\n                        }\n                    }\n\n                    if let Some(info) = matched_info {\n                        debug!(\n                            \"Found installed environment for hook `{hook}` at `{}`\",\n                            info.env_path.display()\n                        );\n                        hook_envs.push(InstalledHook::Installed { hook, info });\n                        continue;\n                    }\n\n                    let _permit = semaphore.acquire(1).await;\n\n                    let installed_hook = hook\n                        .language\n                        .install(hook.clone(), store, reporter)\n                        .await\n                        .with_context(|| format!(\"Failed to install hook `{hook}`\"))?;\n\n                    installed_hook\n                        .mark_as_installed(store)\n                        .await\n                        .with_context(|| format!(\"Failed to mark hook `{hook}` as installed\"))?;\n\n                    match &installed_hook {\n                        InstalledHook::Installed { info, .. } => {\n                            debug!(\"Installed hook `{hook}` in `{}`\", info.env_path.display());\n                        }\n                        InstalledHook::NoNeedInstall { .. } => {\n                            debug!(\"Hook `{hook}` does not need installation\");\n                        }\n                    }\n\n                    newly_installed.push(installed_hook);\n                }\n\n                // Add newly installed hooks to the list.\n                hook_envs.extend(newly_installed);\n                anyhow::Ok(hook_envs)\n            });\n        }\n    }\n\n    while let Some(hooks) = futures.next().await {\n        result.extend(hooks?);\n    }\n    reporter.on_complete();\n\n    debug_assert_eq!(\n        num_hooks,\n        result.len(),\n        \"Number of hooks installed should match the number of hooks provided\"\n    );\n\n    Ok(result)\n}\n\n/// Partition hooks into groups where hooks in the same group have same dependencies.\n/// Hooks in different groups can be installed in parallel.\nfn partition_hooks(hooks: &[Arc<Hook>]) -> Vec<Vec<Arc<Hook>>> {\n    if hooks.is_empty() {\n        return vec![];\n    }\n\n    let n = hooks.len();\n    let mut visited = vec![false; n];\n    let mut groups = Vec::new();\n\n    // DFS to find all connected sets\n    #[allow(clippy::items_after_statements)]\n    fn dfs(\n        index: usize,\n        hooks: &[Arc<Hook>],\n        visited: &mut [bool],\n        current_group: &mut Vec<usize>,\n    ) {\n        visited[index] = true;\n        current_group.push(index);\n\n        for i in 0..hooks.len() {\n            if !visited[i] && hooks[index].env_key_dependencies() == hooks[i].env_key_dependencies()\n            {\n                dfs(i, hooks, visited, current_group);\n            }\n        }\n    }\n\n    // Find all connected components\n    for i in 0..n {\n        if !visited[i] {\n            let mut current_group = Vec::new();\n            dfs(i, hooks, &mut visited, &mut current_group);\n\n            // Convert indices back to actual sets\n            let group_sets: Vec<Arc<Hook>> = current_group\n                .into_iter()\n                .map(|idx| hooks[idx].clone())\n                .collect();\n\n            groups.push(group_sets);\n        }\n    }\n\n    groups\n}\n\nstruct StatusPrinter {\n    printer: Printer,\n    columns: usize,\n}\n\nimpl StatusPrinter {\n    const PASSED: &'static str = \"Passed\";\n    const FAILED: &'static str = \"Failed\";\n    const SKIPPED: &'static str = \"Skipped\";\n    const DRY_RUN: &'static str = \"Dry Run\";\n    const NO_FILES: &'static str = \"(no files to check)\";\n    const UNIMPLEMENTED: &'static str = \"(unimplemented yet)\";\n\n    fn for_hooks(hooks: &[InstalledHook], printer: Printer) -> Self {\n        let name_len = hooks\n            .iter()\n            .map(|hook| hook.name.width())\n            .max()\n            .unwrap_or(0);\n        let columns = std::cmp::max(\n            79,\n            // Hook name...(no files to check)Skipped\n            name_len + 3 + Self::NO_FILES.len() + Self::SKIPPED.len(),\n        );\n        Self { printer, columns }\n    }\n\n    fn printer(&self) -> Printer {\n        self.printer\n    }\n\n    fn bar_len(&self) -> usize {\n        self.columns - Self::PASSED.len()\n    }\n\n    fn write(\n        &self,\n        hook_name: &str,\n        prefix: &str,\n        status: RunStatus,\n    ) -> Result<(), std::fmt::Error> {\n        let (suffix, status_line, status_width) = match status {\n            RunStatus::NoFiles => (\n                Self::NO_FILES,\n                Self::SKIPPED.black().on_cyan().to_string(),\n                Self::SKIPPED.width(),\n            ),\n            RunStatus::Unimplemented => (\n                Self::UNIMPLEMENTED,\n                Self::SKIPPED.black().on_yellow().to_string(),\n                Self::SKIPPED.width(),\n            ),\n            RunStatus::DryRun => (\n                \"\",\n                Self::DRY_RUN.on_yellow().to_string(),\n                Self::DRY_RUN.width(),\n            ),\n            RunStatus::Success => (\n                \"\",\n                Self::PASSED.on_green().to_string(),\n                Self::PASSED.width(),\n            ),\n            RunStatus::Failed => (\"\", Self::FAILED.on_red().to_string(), Self::FAILED.width()),\n        };\n        let (prefix, prefix_width) = if prefix.is_empty() {\n            (String::new(), 0)\n        } else {\n            (prefix.dimmed().to_string(), prefix.width())\n        };\n        let used_width = prefix_width + hook_name.width() + suffix.width() + status_width;\n        let dots = self.columns.saturating_sub(used_width);\n        let line = format!(\n            \"{prefix}{hook_name}{}{suffix}{status_line}\",\n            \".\".repeat(dots),\n        );\n        match status {\n            RunStatus::Failed => {\n                writeln!(self.printer.stdout_important(), \"{line}\")\n            }\n            _ => writeln!(self.printer.stdout(), \"{line}\"),\n        }\n    }\n}\n\n/// Run all hooks.\n#[allow(clippy::fn_params_excessive_bools)]\nasync fn run_hooks(\n    workspace: &Workspace,\n    hooks: &[InstalledHook],\n    filenames: Vec<PathBuf>,\n    store: &Store,\n    show_diff_on_failure: bool,\n    fail_fast: bool,\n    dry_run: bool,\n    verbose: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    debug_assert!(!hooks.is_empty(), \"No hooks to run\");\n\n    let status_printer = StatusPrinter::for_hooks(hooks, printer);\n    let reporter = HookRunReporter::new(printer, status_printer.bar_len());\n\n    let mut success = true;\n\n    // Group hooks by project to run them in order of their depth in the workspace.\n    #[allow(clippy::mutable_key_type)]\n    let mut project_to_hooks: FxHashMap<&Project, Vec<InstalledHook>> = FxHashMap::default();\n    for hook in hooks {\n        project_to_hooks\n            .entry(hook.project())\n            .or_default()\n            .push(hook.clone());\n    }\n\n    let projects_len = project_to_hooks.len();\n    let mut first = true;\n    let mut file_modified = false;\n    let mut has_unimplemented = false;\n\n    // Track files that have been consumed by orphan projects.\n    let mut consumed_files = FxHashSet::default();\n\n    'outer: for project in workspace.all_projects() {\n        let filter = FileFilter::for_project(filenames.iter(), project, Some(&mut consumed_files));\n\n        let Some(mut hooks) = project_to_hooks.remove(project) else {\n            continue;\n        };\n        trace!(\n            \"Files for project `{project}` after filtered: {}\",\n            filter.len()\n        );\n\n        // Sort hooks by priority (lower number means higher priority).\n        // If two hooks have the same priority, preserve their original order from the config.\n        hooks.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.idx.cmp(&b.idx)));\n\n        if projects_len > 1 || !project.is_root() {\n            reporter.suspend(|| {\n                writeln!(\n                    status_printer.printer().stdout(),\n                    \"{}{}\",\n                    if first { \"\" } else { \"\\n\" },\n                    format!(\"Running hooks for `{}`:\", project.to_string().cyan()).bold()\n                )\n            })?;\n            first = false;\n        }\n        let mut prev_diff = git::get_diff(project.path()).await?;\n\n        let project_fail_fast = fail_fast || project.config().fail_fast.unwrap_or(false);\n\n        for group_range in PriorityGroupRanges::new(&hooks) {\n            let group_hooks = hooks[group_range].to_vec();\n            let mut group_results =\n                run_priority_group(group_hooks, &filter, store, dry_run, &reporter).await?;\n\n            // Print results in a stable order (same order as config within the project).\n            group_results.sort_unstable_by(|a, b| a.hook.idx.cmp(&b.hook.idx));\n\n            // Check if any files were modified by this group of hooks.\n            let all_skipped = group_results.iter().all(|r| r.status.is_skipped());\n            let group_modified_files = if !all_skipped {\n                let curr_diff = git::get_diff(project.path()).await?;\n                let group_modified_files = curr_diff != prev_diff;\n                prev_diff = curr_diff;\n                group_modified_files\n            } else {\n                false\n            };\n\n            if group_modified_files {\n                file_modified = true;\n            }\n\n            reporter.suspend(|| {\n                render_priority_group(\n                    printer,\n                    &status_printer,\n                    &group_results,\n                    verbose,\n                    group_modified_files,\n                )\n            })?;\n\n            let hook_fail_fast = apply_group_outcome(\n                &group_results,\n                group_modified_files,\n                &mut success,\n                &mut has_unimplemented,\n            );\n\n            if !success && (project_fail_fast || hook_fail_fast) {\n                break 'outer;\n            }\n        }\n    }\n\n    reporter.on_complete();\n\n    if has_unimplemented {\n        warn_user!(\n            \"Some hooks were skipped because their languages are unimplemented.\\nWe're working hard to support more languages. Check out current support status at {}.\",\n            \"https://prek.j178.dev/languages/\".cyan().underline()\n        );\n    }\n\n    if !success && show_diff_on_failure && file_modified {\n        if EnvVars::is_under_ci() {\n            writeln!(\n                printer.stdout(),\n                \"{}\",\n                indoc::formatdoc! {\n                    \"\\n{}: Some hooks made changes to the files.\n                    If you are seeing this message in CI, reproduce locally with: `{}`\n                    To run prek as part of Git workflow, use `{}` to set up Git shims.\\n\",\n                    \"hint\".yellow().bold(),\n                    \"prek run --all-files\".cyan(),\n                    \"prek install\".cyan()\n                }\n            )?;\n        }\n\n        writeln!(printer.stdout_important(), \"All changes made by hooks:\")?;\n\n        let color = if *USE_COLOR {\n            \"--color=always\"\n        } else {\n            \"--color=never\"\n        };\n        git::git_cmd(\"git diff\")?\n            .arg(\"--no-pager\")\n            .arg(\"diff\")\n            .arg(\"--no-ext-diff\")\n            .arg(color)\n            .arg(\"--\")\n            .arg(workspace.root())\n            .check(true)\n            .spawn()?\n            .wait()\n            .await?;\n    }\n\n    if success {\n        Ok(ExitStatus::Success)\n    } else {\n        Ok(ExitStatus::Failure)\n    }\n}\n\nstruct PriorityGroupRanges<'a> {\n    hooks: &'a [InstalledHook],\n    idx: usize,\n}\n\nimpl<'a> PriorityGroupRanges<'a> {\n    fn new(hooks: &'a [InstalledHook]) -> Self {\n        Self { hooks, idx: 0 }\n    }\n}\n\nimpl Iterator for PriorityGroupRanges<'_> {\n    type Item = std::ops::Range<usize>;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        if self.idx >= self.hooks.len() {\n            return None;\n        }\n\n        let start = self.idx;\n        let priority = self.hooks[start].priority;\n        let mut end = start + 1;\n        while end < self.hooks.len() && self.hooks[end].priority == priority {\n            end += 1;\n        }\n        self.idx = end;\n        Some(start..end)\n    }\n}\n\nasync fn run_priority_group(\n    group_hooks: Vec<InstalledHook>,\n    filter: &FileFilter<'_>,\n    store: &Store,\n    dry_run: bool,\n    reporter: &HookRunReporter,\n) -> Result<Vec<RunResult>> {\n    debug!(\n        \"Running priority group with priority {} with concurrency {}: {:?}\",\n        group_hooks[0].priority,\n        *CONCURRENCY,\n        group_hooks.iter().map(|h| &h.id).collect::<Vec<_>>()\n    );\n\n    let mut results = futures::stream::iter(\n        group_hooks\n            .into_iter()\n            .map(|hook| run_hook(hook, filter, store, dry_run, reporter)),\n    )\n    .buffer_unordered(*CONCURRENCY);\n\n    let mut group_results = Vec::new();\n    while let Some(result) = results.next().await {\n        group_results.push(result?);\n    }\n    Ok(group_results)\n}\n\nfn render_priority_group(\n    printer: Printer,\n    status_printer: &StatusPrinter,\n    group_results: &[RunResult],\n    verbose: bool,\n    group_modified_files: bool,\n) -> Result<()> {\n    // Only show a special group UI when the group failed due to file modifications.\n    // Hooks in a priority group run in parallel, so we can't attribute modifications to a single hook.\n    let show_group_ui = group_modified_files && group_results.len() > 1;\n    let single_hook_modified_files = group_results.len() == 1 && group_modified_files;\n    let group_prefix = if show_group_ui {\n        format!(\"{}\", \"  │ \".dimmed())\n    } else {\n        String::new()\n    };\n\n    if show_group_ui {\n        status_printer.write(\n            \"Files were modified by following hooks\",\n            \"\",\n            RunStatus::Failed,\n        )?;\n    }\n\n    for (i, result) in group_results.iter().enumerate() {\n        let prefix = if show_group_ui {\n            if i == 0 {\n                \"  ┌ \"\n            } else if i + 1 == group_results.len() {\n                \"  └ \"\n            } else {\n                \"  │ \"\n            }\n        } else {\n            \"\"\n        };\n\n        // If a single hook modified files, treat it as failed.\n        let status = if single_hook_modified_files && result.status == RunStatus::Success {\n            RunStatus::Failed\n        } else {\n            result.status\n        };\n\n        status_printer.write(&result.hook.name, prefix, status)?;\n\n        if matches!(status, RunStatus::NoFiles | RunStatus::Unimplemented) {\n            continue;\n        }\n\n        let mut stdout = match status {\n            RunStatus::Failed => printer.stdout_important(),\n            _ => printer.stdout(),\n        };\n\n        if verbose || result.hook.verbose || status == RunStatus::Failed {\n            writeln!(\n                stdout,\n                \"{group_prefix}{}\",\n                format!(\"- hook id: {}\", result.hook.id).dimmed()\n            )?;\n            if verbose || result.hook.verbose {\n                writeln!(\n                    stdout,\n                    \"{group_prefix}{}\",\n                    format!(\"- duration: {:.2?}s\", result.duration.as_secs_f64()).dimmed()\n                )?;\n            }\n            if result.exit_status != 0 {\n                writeln!(\n                    stdout,\n                    \"{group_prefix}{}\",\n                    format!(\"- exit code: {}\", result.exit_status).dimmed()\n                )?;\n            }\n            if single_hook_modified_files {\n                writeln!(\n                    stdout,\n                    \"{group_prefix}{}\",\n                    \"- files were modified by this hook\".dimmed()\n                )?;\n            }\n\n            let output = result.output.trim_ascii();\n            if !output.is_empty() {\n                if let Some(file) = result.hook.log_file.as_deref() {\n                    let mut file = fs_err::OpenOptions::new()\n                        .create(true)\n                        .append(true)\n                        .open(file)?;\n                    file.write_all(output)?;\n                    file.flush()?;\n                } else {\n                    if show_group_ui {\n                        writeln!(stdout, \"{}\", \"  │\".dimmed())?;\n                    } else {\n                        writeln!(stdout)?;\n                    }\n                    let text = String::from_utf8_lossy(output);\n                    for line in text.lines() {\n                        if line.is_empty() {\n                            if show_group_ui {\n                                writeln!(stdout, \"{}\", \"  │\".dimmed())?;\n                            } else {\n                                writeln!(stdout)?;\n                            }\n                        } else {\n                            if show_group_ui {\n                                writeln!(stdout, \"{group_prefix}{line}\")?;\n                            } else {\n                                writeln!(stdout, \"  {line}\")?;\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n\nfn apply_group_outcome(\n    group_results: &[RunResult],\n    group_modified_files: bool,\n    success: &mut bool,\n    has_unimplemented: &mut bool,\n) -> bool {\n    let mut hook_fail_fast = false;\n\n    for RunResult { hook, status, .. } in group_results {\n        *has_unimplemented |= status.is_unimplemented();\n\n        let ok = if group_modified_files {\n            false\n        } else {\n            status.as_bool()\n        };\n        *success &= ok;\n\n        if !ok && hook.fail_fast {\n            hook_fail_fast = true;\n        }\n    }\n\n    hook_fail_fast\n}\n\n/// Shuffle the files so that they more evenly fill out the xargs\n/// partitions, but do it deterministically in case a hook cares about ordering.\nfn shuffle<T>(filenames: &mut [T]) {\n    const SEED: u64 = 1_542_676_187;\n    let mut rng = StdRng::seed_from_u64(SEED);\n    filenames.shuffle(&mut rng);\n}\n\n#[derive(Copy, Clone, Eq, PartialEq)]\nenum RunStatus {\n    Success,\n    Failed,\n    DryRun,\n    NoFiles,\n    Unimplemented,\n}\n\nimpl RunStatus {\n    fn as_bool(self) -> bool {\n        matches!(\n            self,\n            Self::Success | Self::NoFiles | Self::DryRun | Self::Unimplemented\n        )\n    }\n\n    fn is_unimplemented(self) -> bool {\n        matches!(self, Self::Unimplemented)\n    }\n\n    fn is_skipped(self) -> bool {\n        matches!(self, Self::DryRun | Self::NoFiles | Self::Unimplemented)\n    }\n}\n\nstruct RunResult {\n    hook: InstalledHook,\n    status: RunStatus,\n    duration: std::time::Duration,\n    exit_status: i32,\n    output: Vec<u8>,\n}\n\nimpl RunResult {\n    fn from_status(hook: InstalledHook, status: RunStatus) -> Self {\n        Self {\n            hook,\n            status,\n            duration: std::time::Duration::ZERO,\n            exit_status: 0,\n            output: Vec::new(),\n        }\n    }\n}\n\nasync fn run_hook(\n    hook: InstalledHook,\n    filter: &FileFilter<'_>,\n    store: &Store,\n    dry_run: bool,\n    reporter: &HookRunReporter,\n) -> Result<RunResult> {\n    let mut filenames = filter.for_hook(&hook);\n    trace!(\n        \"Files for hook `{}` after filtered: {}\",\n        hook.id,\n        filenames.len()\n    );\n\n    if filenames.is_empty() && !hook.always_run {\n        return Ok(RunResult::from_status(hook, RunStatus::NoFiles));\n    }\n    if !Language::supported(hook.language) {\n        return Ok(RunResult::from_status(hook, RunStatus::Unimplemented));\n    }\n    let start = std::time::Instant::now();\n\n    let filenames = match hook.pass_filenames {\n        PassFilenames::All | PassFilenames::Limited(_) => {\n            shuffle(&mut filenames);\n            filenames\n        }\n        PassFilenames::None => vec![],\n    };\n\n    let (exit_status, hook_output) = if dry_run {\n        let mut output = Vec::new();\n        if !filenames.is_empty() {\n            writeln!(\n                output,\n                \"`{}` would be run on {} files:\",\n                hook,\n                filenames.len()\n            )?;\n        }\n        for filename in filenames {\n            writeln!(output, \"- {}\", filename.display())?;\n        }\n        (0, output)\n    } else {\n        hook.language\n            .run(&hook, &filenames, store, reporter)\n            .await\n            .with_context(|| format!(\"Failed to run hook `{hook}`\"))?\n    };\n\n    let duration = start.elapsed();\n\n    let run_status = if dry_run {\n        RunStatus::DryRun\n    } else if exit_status == 0 {\n        RunStatus::Success\n    } else {\n        RunStatus::Failed\n    };\n\n    Ok(RunResult {\n        hook,\n        status: run_status,\n        duration,\n        exit_status,\n        output: hook_output,\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn status_printer_write_dots_saturates_instead_of_underflow() {\n        let status_printer = StatusPrinter {\n            printer: Printer::Silent,\n            columns: 10,\n        };\n\n        // This would underflow if computed with plain `-` on `usize`.\n        let long_name = \"this hook name is definitely longer than ten columns\";\n        status_printer\n            .write(long_name, \"\", RunStatus::Failed)\n            .expect(\"write should not fail\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/run/selector.rs",
    "content": "use std::borrow::Cow;\nuse std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::sync::{Arc, Mutex};\n\nuse crate::hook::Hook;\nuse crate::warn_user;\n\nuse anyhow::anyhow;\nuse itertools::Itertools;\nuse path_clean::PathClean;\nuse prek_consts::env_vars::EnvVars;\nuse rustc_hash::FxHashSet;\nuse tracing::trace;\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum Error {\n    #[error(\"Invalid selector: `{selector}`\")]\n    InvalidSelector {\n        selector: String,\n        #[source]\n        source: anyhow::Error,\n    },\n\n    #[error(\"Invalid project path: `{path}`\")]\n    InvalidPath {\n        path: String,\n        #[source]\n        source: anyhow::Error,\n    },\n}\n\n#[derive(Debug, Clone, Copy)]\npub(crate) enum SelectorSource {\n    CliArg,\n    CliFlag(&'static str),\n    EnvVar(&'static str),\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum SelectorExpr {\n    HookId(String),\n    ProjectPrefix(PathBuf),\n    ProjectHook {\n        project_path: PathBuf,\n        hook_id: String,\n    },\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct Selector {\n    source: SelectorSource,\n    original: String,\n    expr: SelectorExpr,\n}\n\nimpl Display for Selector {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match &self.expr {\n            SelectorExpr::HookId(hook_id) => write!(f, \"{hook_id}\"),\n            SelectorExpr::ProjectPrefix(project_path) => {\n                if project_path.as_os_str().is_empty() {\n                    write!(f, \"./\")\n                } else {\n                    write!(f, \"{}/\", project_path.display())\n                }\n            }\n            SelectorExpr::ProjectHook {\n                project_path,\n                hook_id,\n            } => {\n                if project_path.as_os_str().is_empty() {\n                    write!(f, \".:{hook_id}\")\n                } else {\n                    write!(f, \"{}:{hook_id}\", project_path.display())\n                }\n            }\n        }\n    }\n}\n\nimpl Selector {\n    pub(crate) fn as_flag(&self) -> Cow<'_, str> {\n        match &self.source {\n            SelectorSource::CliArg => Cow::Borrowed(&self.original),\n            SelectorSource::CliFlag(flag) => Cow::Owned(format!(\"{}={}\", flag, self.original)),\n            SelectorSource::EnvVar(var) => Cow::Owned(format!(\"{}={}\", var, self.original)),\n        }\n    }\n\n    pub(crate) fn as_normalized_flag(&self) -> String {\n        match &self.source {\n            SelectorSource::CliArg => self.to_string(),\n            SelectorSource::CliFlag(flag) => format!(\"{flag}={self}\"),\n            SelectorSource::EnvVar(var) => format!(\"{var}={self}\"),\n        }\n    }\n\n    pub(crate) fn source(&self) -> &SelectorSource {\n        &self.source\n    }\n\n    pub(crate) fn kind_str(&self) -> &'static str {\n        match &self.expr {\n            SelectorExpr::HookId(_) | SelectorExpr::ProjectHook { .. } => \"hooks\",\n            SelectorExpr::ProjectPrefix(_) => \"projects\",\n        }\n    }\n}\n\nimpl Selector {\n    pub(crate) fn matches_hook(&self, hook: &Hook) -> bool {\n        match &self.expr {\n            SelectorExpr::HookId(hook_id) => {\n                // For bare hook IDs, check if it matches the hook\n                &hook.id == hook_id || &hook.alias == hook_id\n            }\n            SelectorExpr::ProjectPrefix(project_path) => {\n                // For project paths, check if the hook belongs to that project.\n                hook.project().relative_path().starts_with(project_path)\n            }\n            SelectorExpr::ProjectHook {\n                project_path,\n                hook_id,\n            } => {\n                // For project:hook syntax, check both\n                (&hook.id == hook_id || &hook.alias == hook_id)\n                    && project_path == hook.project().relative_path()\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default)]\npub(crate) struct Selectors {\n    includes: Vec<Selector>,\n    skips: Vec<Selector>,\n    usage: Arc<Mutex<SelectorUsage>>,\n}\n\nimpl Selectors {\n    /// Load include and skip selectors from CLI args and environment variables.\n    pub(crate) fn load(\n        includes: &[String],\n        skips: &[String],\n        workspace_root: &Path,\n    ) -> Result<Selectors, Error> {\n        let includes = includes\n            .iter()\n            .unique()\n            .map(|selector| {\n                parse_single_selector(\n                    selector,\n                    workspace_root,\n                    SelectorSource::CliArg,\n                    RealFileSystem,\n                )\n            })\n            .collect::<Result<Vec<_>, _>>()?;\n\n        trace!(\n            \"Include selectors: `{}`\",\n            includes\n                .iter()\n                .map(ToString::to_string)\n                .collect::<Vec<_>>()\n                .join(\", \")\n        );\n\n        let skips = load_skips(skips, workspace_root, RealFileSystem)?;\n\n        trace!(\n            \"Skip selectors: `{}`\",\n            skips\n                .iter()\n                .map(ToString::to_string)\n                .collect::<Vec<_>>()\n                .join(\", \")\n        );\n\n        Ok(Self {\n            includes,\n            skips,\n            usage: Arc::default(),\n        })\n    }\n\n    pub(crate) fn includes(&self) -> &[Selector] {\n        &self.includes\n    }\n\n    pub(crate) fn skips(&self) -> &[Selector] {\n        &self.skips\n    }\n\n    pub(crate) fn has_project_selectors(&self) -> bool {\n        self.includes.iter().any(|include| {\n            matches!(\n                include.expr,\n                SelectorExpr::ProjectPrefix(_) | SelectorExpr::ProjectHook { .. }\n            )\n        })\n    }\n\n    pub(crate) fn includes_only_hook_targets(&self) -> bool {\n        !self.includes.is_empty()\n            && self.includes.iter().all(|s| {\n                matches!(\n                    s.expr,\n                    SelectorExpr::HookId(_) | SelectorExpr::ProjectHook { .. }\n                )\n            })\n    }\n\n    /// Check if a hook matches any of the selection criteria.\n    pub(crate) fn matches_hook(&self, hook: &Hook) -> bool {\n        let mut usage = self.usage.lock().unwrap();\n\n        // Always check every selector to track usage\n        let mut skipped = false;\n        for (idx, skip) in self.skips.iter().enumerate() {\n            if skip.matches_hook(hook) {\n                usage.use_skip(idx);\n                skipped = true;\n            }\n        }\n        if skipped {\n            return false;\n        }\n\n        if self.includes.is_empty() {\n            return true; // No `includes` mean all hooks are included\n        }\n\n        let mut included = false;\n        for (idx, include) in self.includes.iter().enumerate() {\n            if include.matches_hook(hook) {\n                usage.use_include(idx);\n                included = true;\n            }\n        }\n        included\n    }\n\n    pub(crate) fn matches_hook_id(&self, hook_id: &str) -> bool {\n        let mut usage = self.usage.lock().unwrap();\n\n        // Always check every selector to track usage\n        let mut skipped = false;\n        for (idx, skip) in self.skips.iter().enumerate() {\n            if let SelectorExpr::HookId(id) = &skip.expr {\n                if id == hook_id {\n                    usage.use_skip(idx);\n                    skipped = true;\n                }\n            }\n        }\n        if skipped {\n            return false;\n        }\n\n        if self.includes.is_empty() {\n            return true; // No `includes` mean all hooks are included\n        }\n\n        let mut included = false;\n        for (idx, include) in self.includes.iter().enumerate() {\n            if let SelectorExpr::HookId(id) = &include.expr {\n                if id == hook_id {\n                    usage.use_include(idx);\n                    included = true;\n                }\n            }\n        }\n        included\n    }\n\n    pub(crate) fn matches_path(&self, path: &Path) -> bool {\n        let mut usage = self.usage.lock().unwrap();\n\n        let mut skipped = false;\n        for (idx, skip) in self.skips.iter().enumerate() {\n            if let SelectorExpr::ProjectPrefix(project_path) = &skip.expr {\n                if path.starts_with(project_path) {\n                    usage.use_skip(idx);\n                    skipped = true;\n                }\n            }\n        }\n        if skipped {\n            return false;\n        }\n\n        // If no project prefix selectors are present, all paths are included\n        if !self\n            .includes\n            .iter()\n            .any(|include| matches!(include.expr, SelectorExpr::ProjectPrefix(_)))\n        {\n            return true;\n        }\n\n        let mut included = false;\n        for (idx, include) in self.includes.iter().enumerate() {\n            if let SelectorExpr::ProjectPrefix(project_path) = &include.expr {\n                if path.starts_with(project_path) {\n                    usage.use_include(idx);\n                    included = true;\n                }\n            }\n        }\n        included\n    }\n\n    pub(crate) fn report_unused(&self) {\n        let usage = self.usage.lock().unwrap();\n        usage.report_unused(self);\n    }\n}\n\n#[derive(Default, Debug)]\nstruct SelectorUsage {\n    used_includes: FxHashSet<usize>,\n    used_skips: FxHashSet<usize>,\n}\n\nimpl SelectorUsage {\n    fn use_include(&mut self, idx: usize) {\n        self.used_includes.insert(idx);\n    }\n\n    fn use_skip(&mut self, idx: usize) {\n        self.used_skips.insert(idx);\n    }\n\n    fn report_unused(&self, selectors: &Selectors) {\n        let unused = selectors\n            .includes\n            .iter()\n            .enumerate()\n            .filter(|(idx, _)| !self.used_includes.contains(idx))\n            .chain(\n                selectors\n                    .skips\n                    .iter()\n                    .enumerate()\n                    .filter(|(idx, _)| !self.used_skips.contains(idx)),\n            )\n            .collect::<Vec<_>>();\n\n        match unused.as_slice() {\n            [] => {}\n            [(_, selector)] => {\n                let flag = selector.as_flag();\n                let normalized = selector.as_normalized_flag();\n                if flag == normalized {\n                    warn_user!(\n                        \"selector `{flag}` did not match any {}\",\n                        selector.kind_str()\n                    );\n                } else {\n                    warn_user!(\n                        \"selector `{flag}` ({}) did not match any {}\",\n                        format!(\"normalized to `{normalized}`\").dimmed(),\n                        selector.kind_str()\n                    );\n                }\n            }\n            _ => {\n                let warning = unused\n                    .iter()\n                    .map(|(_, sel)| {\n                        let flag = sel.as_flag();\n                        let normalized = sel.as_normalized_flag();\n                        if flag == normalized {\n                            format!(\"  - `{flag}`\")\n                        } else {\n                            format!(\n                                \"  - `{flag}` ({})\",\n                                format!(\"normalized to `{normalized}`\").dimmed()\n                            )\n                        }\n                    })\n                    .collect::<Vec<_>>()\n                    .join(\"\\n\");\n\n                warn_user!(\"the following selectors did not match any hooks or projects:\");\n                anstream::eprintln!(\"{warning}\");\n            }\n        }\n    }\n}\n\n/// Parse a single selector string into a Selection enum.\nfn parse_single_selector<FS: FileSystem>(\n    input: &str,\n    workspace_root: &Path,\n    source: SelectorSource,\n    fs: FS,\n) -> Result<Selector, Error> {\n    // Handle `project:hook` syntax\n    if let Some((project_path, hook_id)) = input.split_once(':') {\n        if hook_id.is_empty() {\n            return Err(Error::InvalidSelector {\n                selector: input.to_string(),\n                source: anyhow!(\"hook ID part is empty\"),\n            });\n        }\n        if project_path.is_empty() {\n            return Ok(Selector {\n                source,\n                original: input.to_string(),\n                expr: SelectorExpr::HookId(hook_id.to_string()),\n            });\n        }\n\n        let project_path = normalize_path(project_path, workspace_root, fs).map_err(|e| {\n            Error::InvalidSelector {\n                selector: input.to_string(),\n                source: anyhow!(e),\n            }\n        })?;\n\n        return Ok(Selector {\n            source,\n            original: input.to_string(),\n            expr: SelectorExpr::ProjectHook {\n                project_path,\n                hook_id: hook_id.to_string(),\n            },\n        });\n    }\n\n    // Handle project paths\n    if input == \".\" || input.contains('/') {\n        let project_path =\n            normalize_path(input, workspace_root, fs).map_err(|e| Error::InvalidSelector {\n                selector: input.to_string(),\n                source: anyhow!(e),\n            })?;\n\n        return Ok(Selector {\n            source,\n            original: input.to_string(),\n            expr: SelectorExpr::ProjectPrefix(project_path),\n        });\n    }\n\n    // Ambiguous case: treat as hook ID for backward compatibility\n    if input.is_empty() {\n        return Err(Error::InvalidSelector {\n            selector: input.to_string(),\n            source: anyhow!(\"cannot be empty\"),\n        });\n    }\n    Ok(Selector {\n        source,\n        original: input.to_string(),\n        expr: SelectorExpr::HookId(input.to_string()),\n    })\n}\n\n/// Trait to abstract filesystem operations for easier testing.\npub trait FileSystem: Copy {\n    fn absolute<P: AsRef<Path>>(&self, path: P) -> std::io::Result<PathBuf>;\n}\n\n#[derive(Copy, Clone)]\npub struct RealFileSystem;\n\nimpl FileSystem for RealFileSystem {\n    fn absolute<P: AsRef<Path>>(&self, path: P) -> std::io::Result<PathBuf> {\n        Ok(std::path::absolute(path)?.clean())\n    }\n}\n\n/// Normalize a project path to the relative path from the workspace root.\n/// In workspace root:\n/// './project/' -> 'project'\n/// 'project/sub/' -> 'project/sub'\n/// '.' -> ''\n/// './' -> ''\n/// '..' -> Error\n/// '../project/' -> Error\n/// '/absolute/path/' -> if inside workspace, relative path; else Error\n/// In subdirectory of workspace (e.g., 'workspace/subdir'):\n/// './project/' -> 'subdir/project'\n/// 'project/' -> 'subdir/project'\n/// '../project/' -> 'project'\n/// '..' -> ''\nfn normalize_path<FS: FileSystem>(\n    path: &str,\n    workspace_root: &Path,\n    fs: FS,\n) -> Result<PathBuf, Error> {\n    let absolute_path = fs.absolute(path).map_err(|e| Error::InvalidPath {\n        path: path.to_string(),\n        source: anyhow!(e),\n    })?;\n    let absolute_path = absolute_path.clean();\n\n    let rel_path = absolute_path\n        .strip_prefix(workspace_root)\n        .map_err(|_| Error::InvalidPath {\n            path: path.to_string(),\n            source: anyhow!(\"path is outside the workspace root\"),\n        })?;\n\n    Ok(rel_path.to_path_buf())\n}\n\n/// Parse skip selectors from CLI args and environment variables\npub(crate) fn load_skips<FS: FileSystem>(\n    cli_skips: &[String],\n    workspace_root: &Path,\n    fs: FS,\n) -> Result<Vec<Selector>, Error> {\n    let prek_skip = EnvVars::var(EnvVars::PREK_SKIP);\n    let skip = EnvVars::var(EnvVars::SKIP);\n\n    let (skips, source) = if !cli_skips.is_empty() {\n        (\n            cli_skips.iter().map(String::as_str).collect::<Vec<_>>(),\n            SelectorSource::CliFlag(\"--skip\"),\n        )\n    } else if let Ok(s) = &prek_skip {\n        (\n            parse_comma_separated(s).collect(),\n            SelectorSource::EnvVar(EnvVars::PREK_SKIP),\n        )\n    } else if let Ok(s) = &skip {\n        (\n            parse_comma_separated(s).collect(),\n            SelectorSource::EnvVar(EnvVars::SKIP),\n        )\n    } else {\n        return Ok(vec![]);\n    };\n\n    skips\n        .into_iter()\n        .unique()\n        .map(|skip| parse_single_selector(skip, workspace_root, source, fs))\n        .collect()\n}\n\n/// Parse comma-separated values, trimming whitespace and filtering empty strings\nfn parse_comma_separated(input: &str) -> impl Iterator<Item = &str> {\n    input.split(',').map(str::trim).filter(|s| !s.is_empty())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::TempDir;\n\n    struct MockFileSystem {\n        current_dir: TempDir,\n    }\n\n    impl FileSystem for &MockFileSystem {\n        fn absolute<P: AsRef<Path>>(&self, path: P) -> std::io::Result<PathBuf> {\n            let p = path.as_ref();\n            if p.is_absolute() {\n                Ok(p.to_path_buf())\n            } else {\n                Ok(self.current_dir.path().join(p))\n            }\n        }\n    }\n\n    impl MockFileSystem {\n        fn root(&self) -> &Path {\n            self.current_dir.path()\n        }\n    }\n\n    fn create_test_workspace() -> anyhow::Result<MockFileSystem> {\n        let temp_dir = TempDir::new()?;\n\n        std::fs::create_dir_all(temp_dir.path().join(\"src\"))?;\n        std::fs::create_dir_all(temp_dir.path().join(\"src/backend\"))?;\n\n        Ok(MockFileSystem {\n            current_dir: temp_dir,\n        })\n    }\n\n    #[test]\n    fn test_parse_single_selector_hook_id() -> anyhow::Result<()> {\n        let fs = create_test_workspace()?;\n\n        // Test explicit hook ID with colon prefix\n        let selector = parse_single_selector(\":black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert!(matches!(selector.expr, SelectorExpr::HookId(ref id) if id == \"black\"));\n\n        let selector = parse_single_selector(\":lint:ruff\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert!(matches!(selector.expr, SelectorExpr::HookId(ref id) if id == \"lint:ruff\"));\n\n        // Test bare hook ID (backward compatibility)\n        let selector = parse_single_selector(\"black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert!(matches!(selector.expr, SelectorExpr::HookId(ref id) if id == \"black\"));\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_parse_single_selector_project_prefix() -> anyhow::Result<()> {\n        let fs = create_test_workspace()?;\n\n        // Test project path with slash\n        let selector = parse_single_selector(\"src/\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert!(\n            matches!(selector.expr, SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from(\"src\"))\n        );\n\n        // Test current directory\n        let selector = parse_single_selector(\".\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert!(\n            matches!(selector.expr, SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from(\"\"))\n        );\n        let selector = parse_single_selector(\"./\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert!(\n            matches!(selector.expr, SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from(\"\"))\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_parse_single_selector_project_hook() -> anyhow::Result<()> {\n        let fs = create_test_workspace()?;\n\n        let selector = parse_single_selector(\"src:black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        match selector.expr {\n            SelectorExpr::ProjectHook {\n                project_path,\n                hook_id,\n            } => {\n                assert_eq!(project_path, PathBuf::from(\"src\"));\n                assert_eq!(hook_id, \"black\");\n            }\n            _ => panic!(\"Expected ProjectHook\"),\n        }\n\n        let selector =\n            parse_single_selector(\"src:lint:ruff\", fs.root(), SelectorSource::CliArg, &fs)?;\n        match selector.expr {\n            SelectorExpr::ProjectHook {\n                project_path,\n                hook_id,\n            } => {\n                assert_eq!(project_path, PathBuf::from(\"src\"));\n                assert_eq!(hook_id, \"lint:ruff\");\n            }\n            _ => panic!(\"Expected ProjectHook\"),\n        }\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_parse_single_selector_invalid() -> anyhow::Result<()> {\n        let fs = create_test_workspace()?;\n\n        // Test empty hook ID\n        let result = parse_single_selector(\":\", fs.root(), SelectorSource::CliArg, &fs);\n        assert!(result.is_err());\n\n        // Test empty hook ID in project:hook\n        let result = parse_single_selector(\"src:\", fs.root(), SelectorSource::CliArg, &fs);\n        assert!(result.is_err());\n\n        // Test empty string\n        let result = parse_single_selector(\"\", fs.root(), SelectorSource::CliArg, &fs);\n        assert!(result.is_err());\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_normalize_path() -> anyhow::Result<()> {\n        let fs = create_test_workspace()?;\n\n        // Test relative path\n        let result = normalize_path(\"src\", fs.root(), &fs)?;\n        assert_eq!(result, PathBuf::from(\"src\"));\n\n        // Test nested path\n        let result = normalize_path(\"src/backend\", fs.root(), &fs)?;\n        assert_eq!(result, PathBuf::from(\"src/backend\"));\n\n        // Test current directory\n        let result = normalize_path(\".\", fs.root(), &fs)?;\n        assert_eq!(result, PathBuf::from(\"\"));\n\n        // Test path outside workspace - create a temp dir outside workspace\n        let outside_dir = TempDir::new()?;\n        let outside_path = outside_dir.path().to_string_lossy();\n        let result = normalize_path(&outside_path, fs.root(), &fs);\n        assert!(result.is_err());\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_selector_display() -> anyhow::Result<()> {\n        let fs = create_test_workspace()?;\n\n        let selector = parse_single_selector(\"black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"black\");\n\n        let selector = parse_single_selector(\":black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"black\");\n\n        let selector = parse_single_selector(\":lint:ruff\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"lint:ruff\");\n\n        let selector = parse_single_selector(\"src/\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src/\");\n\n        let selector = parse_single_selector(\"./src/\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src/\");\n\n        let selector = parse_single_selector(\"src/\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src/\");\n\n        let selector = parse_single_selector(\".\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"./\");\n\n        let selector = parse_single_selector(\"./\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"./\");\n\n        let selector = parse_single_selector(\"src:black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src:black\");\n\n        let selector =\n            parse_single_selector(\"./src:black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src:black\");\n\n        let selector =\n            parse_single_selector(\"./src/:black\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src:black\");\n\n        let selector =\n            parse_single_selector(\"src:lint:ruff\", fs.root(), SelectorSource::CliArg, &fs)?;\n        assert_eq!(selector.to_string(), \"src:lint:ruff\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_selector_as_flag() {\n        let selector = Selector {\n            source: SelectorSource::CliArg,\n            original: \"black\".to_string(),\n            expr: SelectorExpr::HookId(\"black\".to_string()),\n        };\n        assert_eq!(selector.as_flag(), \"black\");\n\n        let selector = Selector {\n            source: SelectorSource::CliFlag(\"--skip\"),\n            original: \"black\".to_string(),\n            expr: SelectorExpr::HookId(\"black\".to_string()),\n        };\n        assert_eq!(selector.as_flag(), \"--skip=black\");\n\n        let selector = Selector {\n            source: SelectorSource::EnvVar(\"SKIP\"),\n            original: \"black\".to_string(),\n            expr: SelectorExpr::HookId(\"black\".to_string()),\n        };\n        assert_eq!(selector.as_flag(), \"SKIP=black\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/cli/sample_config.rs",
    "content": "use std::fmt::Write as _;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::Result;\nuse owo_colors::OwoColorize;\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PREK_TOML};\n\nuse crate::cli::{ExitStatus, SampleConfigFormat, SampleConfigTarget};\nuse crate::fs::Simplified;\nuse crate::printer::Printer;\n\nstatic SAMPLE_CONFIG_YAML: &str = indoc::indoc! {\"\n# See https://pre-commit.com for more information\n# See https://pre-commit.com/hooks.html for more hooks\nrepos:\n  - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n    rev: v6.0.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-yaml\n      - id: check-added-large-files\n\"};\n\nstatic SAMPLE_CONFIG_TOML: &str = indoc::indoc! {r#\"\n# Configuration file for `prek`, a git hook framework written in Rust.\n# See https://prek.j178.dev for more information.\n#:schema https://www.schemastore.org/prek.json\n\n[[repos]]\nrepo = \"builtin\"\nhooks = [\n    { id = \"trailing-whitespace\" },\n    { id = \"end-of-file-fixer\" },\n    { id = \"check-added-large-files\" },\n]\n\"#};\n\npub(crate) fn sample_config(\n    target: SampleConfigTarget,\n    format: Option<SampleConfigFormat>,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let (path, format) = match (target, format) {\n        (SampleConfigTarget::Path(path), Some(format)) => (Some(path), format),\n        (SampleConfigTarget::Path(path), None) => match path.extension() {\n            Some(ext) if ext.eq_ignore_ascii_case(\"toml\") => (Some(path), SampleConfigFormat::Toml),\n            _ => (Some(path), SampleConfigFormat::Yaml),\n        },\n        (SampleConfigTarget::DefaultFile, Some(format)) => match format {\n            SampleConfigFormat::Toml => (Some(PathBuf::from(PREK_TOML)), format),\n            SampleConfigFormat::Yaml => (Some(PathBuf::from(PRE_COMMIT_CONFIG_YAML)), format),\n        },\n        (SampleConfigTarget::DefaultFile, None) => (\n            Some(PathBuf::from(PRE_COMMIT_CONFIG_YAML)),\n            SampleConfigFormat::Yaml,\n        ),\n        (SampleConfigTarget::Stdout, Some(format)) => (None, format),\n        (SampleConfigTarget::Stdout, None) => (None, SampleConfigFormat::Yaml),\n    };\n\n    if let Some(path) = path {\n        fs_err::create_dir_all(path.parent().unwrap_or(Path::new(\".\")))?;\n        let mut file = match fs_err::OpenOptions::new()\n            .write(true)\n            .create_new(true)\n            .open(&path)\n        {\n            Ok(f) => f,\n            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {\n                anyhow::bail!(\"File `{}` already exists\", path.simplified_display().cyan());\n            }\n            Err(err) => return Err(err.into()),\n        };\n\n        match format {\n            SampleConfigFormat::Yaml => write!(file, \"{SAMPLE_CONFIG_YAML}\")?,\n            SampleConfigFormat::Toml => write!(file, \"{SAMPLE_CONFIG_TOML}\")?,\n        }\n\n        writeln!(\n            printer.stdout(),\n            \"Written to `{}`\",\n            path.simplified_display().cyan()\n        )?;\n\n        return Ok(ExitStatus::Success);\n    }\n\n    // TODO: default to prek.toml in the future?\n    match format {\n        SampleConfigFormat::Yaml => {\n            write!(printer.stdout_important(), \"{SAMPLE_CONFIG_YAML}\")?;\n        }\n        SampleConfigFormat::Toml => {\n            write!(printer.stdout_important(), \"{SAMPLE_CONFIG_TOML}\")?;\n        }\n    }\n    Ok(ExitStatus::Success)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/self_update.rs",
    "content": "// MIT License\n//\n// Copyright (c) 2023 Astral Software Inc.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nuse std::env;\nuse std::fmt::Write;\n\nuse anyhow::Result;\nuse axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest};\nuse owo_colors::OwoColorize;\nuse tracing::{debug, enabled};\n\nuse crate::cli::ExitStatus;\nuse crate::install_source::InstallSource;\nuse crate::printer::Printer;\n\nfn format_install_hint() -> String {\n    match InstallSource::detect() {\n        Some(s) => format!(\n            \"{}{} You installed prek via {}. To update, run `{}`\",\n            \"hint\".cyan().bold(),\n            \":\".bold(),\n            s.description(),\n            s.update_instructions()\n        ),\n        None => format!(\n            \"{}{} If you installed prek with pip, brew, or another package manager, update prek with `pip install --upgrade`, `brew upgrade`, or similar.\",\n            \"hint\".cyan().bold(),\n            \":\".bold()\n        ),\n    }\n}\n\n/// Attempt to update the prek binary.\npub(crate) async fn self_update(\n    version: Option<String>,\n    token: Option<String>,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let mut updater = AxoUpdater::new_for(\"prek\");\n    if enabled!(tracing::Level::DEBUG) {\n        unsafe { env::set_var(\"INSTALLER_PRINT_VERBOSE\", \"1\") };\n        updater.enable_installer_output();\n    } else {\n        updater.disable_installer_output();\n    }\n\n    if let Some(ref token) = token {\n        updater.set_github_token(token);\n    }\n\n    // Load the \"install receipt\" for the current binary. If the receipt is not found, then\n    // prek was likely installed via a package manager.\n    let Ok(updater) = updater.load_receipt() else {\n        debug!(\"no receipt found; assuming prek was installed via a package manager\");\n        writeln!(\n            printer.stderr(),\n            \"{}{} Self-update is only available for prek binaries installed via the standalone installation scripts.\",\n            \"error\".red().bold(),\n            \":\".bold(),\n        )?;\n        writeln!(printer.stderr(), \"{}\", format_install_hint())?;\n        return Ok(ExitStatus::Error);\n    };\n\n    // Ensure the receipt is for the current binary. If it's not, then the user likely has multiple\n    // prek binaries installed, and the current binary was _not_ installed via the standalone\n    // installation scripts.\n    if !updater.check_receipt_is_for_this_executable()? {\n        debug!(\n            \"receipt is not for this executable; assuming prek was installed via a package manager\"\n        );\n        writeln!(\n            printer.stderr(),\n            \"{}{} Self-update is only available for prek binaries installed via the standalone installation scripts.\",\n            \"error\".red().bold(),\n            \":\".bold(),\n        )?;\n        writeln!(printer.stderr(), \"{}\", format_install_hint())?;\n        return Ok(ExitStatus::Error);\n    }\n\n    writeln!(\n        printer.stderr(),\n        \"{}\",\n        format_args!(\n            \"{}{} Checking for updates...\",\n            \"info\".cyan().bold(),\n            \":\".bold()\n        )\n    )?;\n\n    let update_request = if let Some(version) = version {\n        UpdateRequest::SpecificTag(version)\n    } else {\n        UpdateRequest::Latest\n    };\n\n    updater.configure_version_specifier(update_request);\n\n    // Run the updater. This involves a network request, since we need to determine the latest\n    // available version of prek.\n    match updater.run().await {\n        Ok(Some(result)) => {\n            let version_information = if let Some(old_version) = result.old_version {\n                format!(\n                    \"from {} to {}\",\n                    format!(\"v{old_version}\").bold().white(),\n                    format!(\"v{}\", result.new_version).bold().white(),\n                )\n            } else {\n                format!(\"to {}\", format!(\"v{}\", result.new_version).bold().white())\n            };\n\n            writeln!(\n                printer.stderr(),\n                \"{}\",\n                format_args!(\n                    \"{}{} Upgraded prek {}! {}\",\n                    \"success\".green().bold(),\n                    \":\".bold(),\n                    version_information,\n                    format!(\n                        \"https://github.com/j178/prek/releases/tag/{}\",\n                        result.new_version_tag\n                    )\n                    .cyan()\n                )\n            )?;\n        }\n        Ok(None) => {\n            writeln!(\n                printer.stderr(),\n                \"{}\",\n                format_args!(\n                    \"{}{} You're on the latest version of prek ({})\",\n                    \"success\".green().bold(),\n                    \":\".bold(),\n                    format!(\"v{}\", env!(\"CARGO_PKG_VERSION\")).bold().white()\n                )\n            )?;\n        }\n        Err(err) => {\n            return if let AxoupdateError::Reqwest(err) = err {\n                if err.status() == Some(http::StatusCode::FORBIDDEN) && token.is_none() {\n                    writeln!(\n                        printer.stderr(),\n                        \"{}\",\n                        format_args!(\n                            \"{}{} GitHub API rate limit exceeded. Please provide a GitHub token via the {} option.\",\n                            \"error\".red().bold(),\n                            \":\".bold(),\n                            \"`--token`\".green().bold()\n                        )\n                    )?;\n                    Ok(ExitStatus::Error)\n                } else {\n                    Err(err.into())\n                }\n            } else {\n                Err(err.into())\n            };\n        }\n    }\n\n    Ok(ExitStatus::Success)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/try_repo.rs",
    "content": "use std::borrow::Cow;\nuse std::fmt::Write;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse owo_colors::OwoColorize;\nuse prek_consts::PREK_TOML;\nuse tempfile::TempDir;\nuse toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Value};\n\nuse crate::cli::ExitStatus;\nuse crate::cli::run::Selectors;\nuse crate::config;\nuse crate::git;\nuse crate::git::GIT_ROOT;\nuse crate::printer::Printer;\nuse crate::store::Store;\nuse crate::warn_user;\n\nasync fn get_head_rev(repo: &Path) -> Result<String> {\n    let head_rev = git::git_cmd(\"get head rev\")?\n        .arg(\"rev-parse\")\n        .arg(\"HEAD\")\n        .current_dir(repo)\n        .output()\n        .await?\n        .stdout;\n    let head_rev = String::from_utf8_lossy(&head_rev).trim().to_string();\n    Ok(head_rev)\n}\n\nasync fn clone_and_commit(repo_path: &Path, head_rev: &str, tmp_dir: &Path) -> Result<PathBuf> {\n    let shadow = tmp_dir.join(\"shadow-repo\");\n    git::git_cmd(\"clone shadow repo\")?\n        .arg(\"clone\")\n        .arg(repo_path)\n        .arg(&shadow)\n        .output()\n        .await?;\n    git::git_cmd(\"checkout shadow repo\")?\n        .arg(\"checkout\")\n        .arg(head_rev)\n        .arg(\"-b\")\n        .arg(\"_prek_tmp\")\n        .current_dir(&shadow)\n        .output()\n        .await?;\n\n    let index_path = shadow.join(\".git/index\");\n    let objects_path = shadow.join(\".git/objects\");\n\n    let staged_files = git::get_staged_files(repo_path).await?;\n    if !staged_files.is_empty() {\n        git::git_cmd(\"add staged files to shadow\")?\n            .arg(\"add\")\n            .arg(\"--\")\n            .args(&staged_files)\n            .current_dir(repo_path)\n            .env(\"GIT_INDEX_FILE\", &index_path)\n            .env(\"GIT_OBJECT_DIRECTORY\", &objects_path)\n            .output()\n            .await?;\n    }\n\n    let mut add_u_cmd = git::git_cmd(\"add unstaged to shadow\")?;\n    add_u_cmd\n        .arg(\"add\")\n        .arg(\"--update\") // Update tracked files\n        .current_dir(repo_path)\n        .env(\"GIT_INDEX_FILE\", &index_path)\n        .env(\"GIT_OBJECT_DIRECTORY\", &objects_path)\n        .output()\n        .await?;\n\n    git::git_cmd(\"git commit\")?\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Temporary commit by prek try-repo\")\n        .arg(\"--no-gpg-sign\")\n        .arg(\"--no-edit\")\n        .arg(\"--no-verify\")\n        .current_dir(&shadow)\n        .env(\"GIT_AUTHOR_NAME\", \"prek test\")\n        .env(\"GIT_AUTHOR_EMAIL\", \"test@example.com\")\n        .env(\"GIT_COMMITTER_NAME\", \"prek test\")\n        .env(\"GIT_COMMITTER_EMAIL\", \"test@example.com\")\n        .output()\n        .await?;\n\n    Ok(shadow)\n}\n\nasync fn prepare_repo_and_rev<'a>(\n    repo: &'a str,\n    rev: Option<&'a str>,\n    tmp_dir: &'a Path,\n) -> Result<(Cow<'a, str>, String)> {\n    let repo_path = Path::new(repo);\n    let is_local = repo_path.is_dir();\n\n    // If rev is provided, use it directly.\n    if let Some(rev) = rev {\n        return Ok((Cow::Borrowed(repo), rev.to_string()));\n    }\n\n    // Get HEAD revision\n    let head_rev = if is_local {\n        get_head_rev(repo_path).await?\n    } else {\n        // For remote repositories, use ls-remote\n        let head_rev = git::git_cmd(\"get head rev\")?\n            .arg(\"ls-remote\")\n            .arg(\"--exit-code\")\n            .arg(repo)\n            .arg(\"HEAD\")\n            .output()\n            .await?\n            .stdout;\n        String::from_utf8_lossy(&head_rev)\n            .split_ascii_whitespace()\n            .next()\n            .context(\"Failed to parse HEAD revision from git ls-remote output\")?\n            .to_string()\n    };\n\n    // If repo is a local repo with uncommitted changes, create a shadow repo to commit the changes.\n    if is_local && git::has_diff(\"HEAD\", repo_path).await? {\n        warn_user!(\"Creating temporary repo with uncommitted changes...\");\n        let shadow = clone_and_commit(repo_path, &head_rev, tmp_dir).await?;\n        let head_rev = get_head_rev(&shadow).await?;\n        Ok((Cow::Owned(shadow.to_string_lossy().to_string()), head_rev))\n    } else {\n        Ok((Cow::Borrowed(repo), head_rev))\n    }\n}\n\nfn render_repo_config_toml(repo_path: &str, rev: &str, hooks: Vec<String>) -> String {\n    let mut doc = DocumentMut::new();\n    let mut repo_table = toml_edit::Table::new();\n    repo_table[\"repo\"] = toml_edit::value(repo_path);\n    repo_table[\"rev\"] = toml_edit::value(rev);\n\n    let mut hooks_array = Array::new();\n    hooks_array.set_trailing_comma(true);\n    hooks_array.set_trailing(\"\\n\");\n    for hook_id in hooks {\n        let mut hook_table = InlineTable::new();\n        hook_table.insert(\"id\", hook_id.into());\n        let mut value = Value::InlineTable(hook_table);\n        value.decor_mut().set_prefix(\"\\n  \");\n        hooks_array.push(value);\n    }\n    repo_table.insert(\"hooks\", Item::Value(Value::Array(hooks_array)));\n\n    let mut repos = ArrayOfTables::new();\n    repos.push(repo_table);\n    doc[\"repos\"] = Item::ArrayOfTables(repos);\n\n    doc.to_string()\n}\n\npub(crate) async fn try_repo(\n    config: Option<PathBuf>,\n    repo: String,\n    rev: Option<String>,\n    run_args: crate::cli::RunArgs,\n    refresh: bool,\n    verbose: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    if config.is_some() {\n        warn_user!(\"`--config` option is ignored when using `try-repo`\");\n    }\n\n    let store = Store::from_settings()?;\n    let tmp_dir = TempDir::with_prefix_in(\"try-repo-\", store.scratch_path())?;\n\n    let (repo_path, rev) = prepare_repo_and_rev(&repo, rev.as_deref(), tmp_dir.path())\n        .await\n        .context(\"Failed to determine repository and revision\")?;\n\n    let store = Store::from_path(tmp_dir.path()).init()?;\n    let repo_config = config::RemoteRepo::new(repo_path.to_string(), rev.clone(), vec![]);\n    let repo_clone_path = store.clone_repo(&repo_config, None).await?;\n\n    let selectors = Selectors::load(&run_args.includes, &run_args.skips, GIT_ROOT.as_ref()?)?;\n\n    let manifest =\n        config::read_manifest(&repo_clone_path.join(prek_consts::PRE_COMMIT_HOOKS_YAML))?;\n\n    let hooks = manifest\n        .hooks\n        .into_iter()\n        .filter(|hook| selectors.matches_hook_id(&hook.id))\n        .map(|hook| hook.id)\n        .collect::<Vec<_>>();\n\n    let config_str = render_repo_config_toml(&repo_path, &rev, hooks);\n    let config_file = tmp_dir.path().join(PREK_TOML);\n    fs_err::tokio::write(&config_file, &config_str).await?;\n\n    writeln!(\n        printer.stdout(),\n        \"{}\",\n        format!(\"Using generated `{PREK_TOML}`:\").cyan().bold()\n    )?;\n    writeln!(printer.stdout(), \"{}\", config_str.dimmed())?;\n\n    crate::cli::run(\n        &store,\n        Some(config_file),\n        vec![],\n        vec![],\n        run_args.stage,\n        run_args.from_ref,\n        run_args.to_ref,\n        run_args.all_files,\n        run_args.files,\n        run_args.directory,\n        run_args.last_commit,\n        run_args.show_diff_on_failure,\n        run_args.fail_fast,\n        run_args.dry_run,\n        refresh,\n        run_args.extra,\n        verbose,\n        printer,\n    )\n    .await\n}\n"
  },
  {
    "path": "crates/prek/src/cli/validate.rs",
    "content": "use std::error::Error;\nuse std::fmt::Write;\nuse std::iter;\nuse std::path::PathBuf;\n\nuse anyhow::Result;\nuse owo_colors::OwoColorize;\n\nuse crate::cli::ExitStatus;\nuse crate::config::{read_config, read_manifest};\nuse crate::printer::Printer;\nuse crate::warn_user;\n\npub(crate) fn validate_configs(configs: Vec<PathBuf>, printer: Printer) -> Result<ExitStatus> {\n    let mut status = ExitStatus::Success;\n\n    if configs.is_empty() {\n        warn_user!(\"No configs to check\");\n        return Ok(ExitStatus::Success);\n    }\n\n    for config in configs {\n        if let Err(err) = read_config(&config) {\n            writeln!(printer.stderr(), \"{}: {}\", \"error\".red().bold(), err)?;\n            for source in iter::successors(err.source(), |&err| err.source()) {\n                writeln!(\n                    printer.stderr(),\n                    \"  {}: {}\",\n                    \"caused by\".red().bold(),\n                    source\n                )?;\n            }\n            status = ExitStatus::Failure;\n        }\n    }\n\n    if status == ExitStatus::Success {\n        writeln!(\n            printer.stderr(),\n            \"{}: All configs are valid\",\n            \"success\".green().bold()\n        )?;\n    }\n\n    Ok(status)\n}\n\npub(crate) fn validate_manifest(manifests: Vec<PathBuf>, printer: Printer) -> Result<ExitStatus> {\n    let mut status = ExitStatus::Success;\n\n    if manifests.is_empty() {\n        warn_user!(\"No manifests to check\");\n        return Ok(ExitStatus::Success);\n    }\n\n    for manifest in manifests {\n        if let Err(err) = read_manifest(&manifest) {\n            writeln!(printer.stderr(), \"{}: {}\", \"error\".red().bold(), err)?;\n            for source in iter::successors(err.source(), |&err| err.source()) {\n                writeln!(\n                    printer.stderr(),\n                    \"  {}: {}\",\n                    \"caused by\".red().bold(),\n                    source\n                )?;\n            }\n            status = ExitStatus::Failure;\n        }\n    }\n\n    if status == ExitStatus::Success {\n        writeln!(\n            printer.stderr(),\n            \"{}: All manifests are valid\",\n            \"success\".green().bold()\n        )?;\n    }\n\n    Ok(status)\n}\n"
  },
  {
    "path": "crates/prek/src/cli/yaml_to_toml.rs",
    "content": "use std::fmt::Write as _;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse owo_colors::OwoColorize;\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML};\nuse toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Table, Value};\n\nuse crate::cli::ExitStatus;\nuse crate::config;\nuse crate::fs::Simplified;\nuse crate::printer::Printer;\n\n/// Resolve the input config path, falling back to `.pre-commit-config.yaml` or\n/// `.pre-commit-config.yml` in the current directory.\nfn resolve_input(input: Option<PathBuf>) -> Result<PathBuf> {\n    if let Some(path) = input {\n        return Ok(path);\n    }\n\n    let yaml = Path::new(PRE_COMMIT_CONFIG_YAML);\n    if yaml.is_file() {\n        return Ok(yaml.to_path_buf());\n    }\n\n    let yml = Path::new(PRE_COMMIT_CONFIG_YML);\n    if yml.is_file() {\n        return Ok(yml.to_path_buf());\n    }\n\n    anyhow::bail!(\n        \"No `{}` or `{}` found in the current directory\\n\\n\\\n         {} Provide a path explicitly: {}\",\n        PRE_COMMIT_CONFIG_YAML.cyan(),\n        PRE_COMMIT_CONFIG_YML.cyan(),\n        \"hint:\".yellow().bold(),\n        \"prek util yaml-to-toml <CONFIG>\".cyan()\n    );\n}\n\npub(crate) fn yaml_to_toml(\n    input: Option<PathBuf>,\n    output: Option<PathBuf>,\n    force: bool,\n    printer: Printer,\n) -> Result<ExitStatus> {\n    let input = resolve_input(input)?;\n\n    // Validate the input file first.\n    let _ = config::load_config(&input)?;\n\n    let content = fs_err::read_to_string(&input)?;\n    let value: serde_json::Value = serde_saphyr::from_str(&content)?;\n\n    let output = output.unwrap_or_else(|| input.parent().unwrap_or(Path::new(\".\")).join(PREK_TOML));\n\n    if output == input {\n        anyhow::bail!(\n            \"Output path `{}` matches input; choose a different output path\",\n            output.simplified_display().cyan()\n        );\n    }\n\n    let mut rendered = json_to_toml(&value)?;\n    if !rendered.ends_with('\\n') {\n        rendered.push('\\n');\n    }\n\n    if let Some(parent) = output.parent() {\n        fs_err::create_dir_all(parent)?;\n    }\n\n    let mut options = fs_err::OpenOptions::new();\n    options.write(true);\n    if force {\n        options.create(true).truncate(true);\n    } else {\n        options.create_new(true);\n    }\n\n    let mut file = match options.open(&output) {\n        Ok(file) => file,\n        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {\n            anyhow::bail!(\n                \"File `{}` already exists (use `--force` to overwrite)\",\n                output.simplified_display().cyan()\n            );\n        }\n        Err(err) => return Err(err.into()),\n    };\n\n    file.write_all(rendered.as_bytes())?;\n\n    writeln!(\n        printer.stdout(),\n        \"Converted `{}` → `{}`\",\n        input.simplified_display().cyan(),\n        output.simplified_display().cyan()\n    )?;\n\n    Ok(ExitStatus::Success)\n}\n\nfn json_to_toml(value: &serde_json::Value) -> Result<String> {\n    let map = value\n        .as_object()\n        .context(\"Expected a top-level mapping in the config file\")?;\n\n    let mut doc = DocumentMut::new();\n    doc.decor_mut().set_prefix(indoc::indoc! {r\"\n        # Configuration file for `prek`, a git hook framework written in Rust.\n        # See https://prek.j178.dev for more information.\n        #:schema https://www.schemastore.org/prek.json\n\n        \"});\n\n    for (key, value) in map {\n        if key == \"repos\" {\n            let repos = value.as_array().context(\"`repos` must be an array\")?;\n            doc[\"repos\"] = repos_to_array_of_tables(repos)?.into();\n            continue;\n        }\n        doc[key] = json_to_toml_value(value).into();\n    }\n\n    Ok(doc.to_string())\n}\n\nfn json_to_toml_value(value: &serde_json::Value) -> Value {\n    match value {\n        serde_json::Value::Null => Value::from(\"\"),\n        serde_json::Value::Bool(value) => Value::from(*value),\n        serde_json::Value::Number(value) => {\n            if let Some(value) = value.as_i64() {\n                Value::from(value)\n            } else if let Some(value) = value.as_f64() {\n                Value::from(value)\n            } else {\n                Value::from(0.0)\n            }\n        }\n        serde_json::Value::String(value) => Value::from(value.as_str()),\n        serde_json::Value::Array(values) => {\n            json_array_to_value_with_indent(values, \"  \", \"  \", false)\n        }\n        serde_json::Value::Object(values) => Value::InlineTable(json_object_to_inline(values)),\n    }\n}\n\nfn json_array_to_value_with_indent(\n    values: &[serde_json::Value],\n    item_indent: &str,\n    closing_indent: &str,\n    force_multiline: bool,\n) -> Value {\n    let mut array = Array::new();\n    if values.len() == 1 && !force_multiline {\n        let value = match &values[0] {\n            serde_json::Value::Object(map) => Value::InlineTable(json_object_to_inline(map)),\n            _ => json_to_toml_value(&values[0]),\n        };\n        array.push(value);\n        array.set_trailing(\"\");\n        return Value::Array(array);\n    }\n\n    for value in values {\n        let mut value = match value {\n            serde_json::Value::Object(map) => Value::InlineTable(json_object_to_inline(map)),\n            _ => json_to_toml_value(value),\n        };\n        value.decor_mut().set_prefix(format!(\"\\n{item_indent}\"));\n        array.push(value);\n    }\n    array.set_trailing(format!(\"\\n{closing_indent}\"));\n    Value::Array(array)\n}\n\nfn json_object_to_inline(values: &serde_json::Map<String, serde_json::Value>) -> InlineTable {\n    let mut table = InlineTable::new();\n    for (key, value) in values {\n        let value = match value {\n            serde_json::Value::Array(values) => {\n                json_array_to_value_with_indent(values, \"      \", \"    \", false)\n            }\n            _ => json_to_toml_value(value),\n        };\n        table.insert(key.as_str(), value);\n    }\n    format_inline_table_multiline(&mut table, \"    \", \"  \");\n    table\n}\n\nfn format_inline_table_multiline(table: &mut InlineTable, base_indent: &str, closing_indent: &str) {\n    let len = table.len();\n    if len <= 1 {\n        return;\n    }\n    for (idx, (mut key, value)) in table.iter_mut().enumerate() {\n        key.leaf_decor_mut().set_prefix(format!(\"\\n{base_indent}\"));\n        key.leaf_decor_mut().set_suffix(\" \");\n\n        let suffix = if idx + 1 == len {\n            format!(\"\\n{closing_indent}\")\n        } else {\n            String::new()\n        };\n        value.decor_mut().set_prefix(\" \");\n        value.decor_mut().set_suffix(suffix);\n\n        if let Value::InlineTable(inner) = value {\n            let nested_base = format!(\"{base_indent}  \");\n            let nested_closing = format!(\"{closing_indent}  \");\n            format_inline_table_multiline(inner, &nested_base, &nested_closing);\n        }\n    }\n}\n\nfn repos_to_array_of_tables(values: &[serde_json::Value]) -> Result<ArrayOfTables> {\n    let mut array = ArrayOfTables::new();\n    for value in values {\n        let map = value\n            .as_object()\n            .context(\"Each repo entry must be a mapping\")?;\n        let mut table = Table::new();\n        for (key, value) in map {\n            if key == \"hooks\" {\n                let hooks = value.as_array().context(\"`hooks` must be an array\")?;\n                table[key] = json_array_to_value_with_indent(hooks, \"  \", \"\", true).into();\n                continue;\n            }\n            table[key] = json_to_toml_value(value).into();\n        }\n        array.push(table);\n    }\n    Ok(array)\n}\n"
  },
  {
    "path": "crates/prek/src/config.rs",
    "content": "use std::collections::{BTreeMap, BTreeSet};\nuse std::error::Error as _;\nuse std::fmt::Display;\nuse std::ops::RangeInclusive;\nuse std::path::Path;\n\nuse anyhow::Result;\nuse clap::ValueEnum;\nuse fancy_regex::Regex;\nuse globset::{Glob, GlobSet, GlobSetBuilder};\nuse itertools::Itertools;\nuse prek_identify::TagSet;\nuse rustc_hash::FxHashMap;\nuse serde::de::{DeserializeSeed, Error as DeError, MapAccess, Visitor};\nuse serde::{Deserialize, Deserializer, Serialize};\n\nuse crate::fs::Simplified;\nuse crate::install_source::InstallSource;\n#[cfg(feature = \"schemars\")]\nuse crate::schema::{schema_repo_builtin, schema_repo_local, schema_repo_meta, schema_repo_remote};\nuse crate::version;\nuse crate::warn_user;\nuse crate::warn_user_once;\n\n#[derive(Clone)]\npub(crate) struct GlobPatterns {\n    patterns: Vec<String>,\n    set: GlobSet,\n}\n\nimpl GlobPatterns {\n    pub(crate) fn new(patterns: Vec<String>) -> Result<Self, globset::Error> {\n        let mut builder = GlobSetBuilder::new();\n        for pattern in &patterns {\n            builder.add(Glob::new(pattern)?);\n        }\n        let set = builder.build()?;\n        Ok(Self { patterns, set })\n    }\n\n    fn is_match(&self, value: &str) -> bool {\n        self.set.is_match(Path::new(value))\n    }\n}\n\nimpl std::fmt::Debug for GlobPatterns {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"GlobPatterns\")\n            .field(\"patterns\", &self.patterns)\n            .finish_non_exhaustive()\n    }\n}\n\nenum FilePatternWire {\n    Glob { glob: String },\n    GlobList { glob: Vec<String> },\n    Regex(String),\n}\n\nimpl<'de> Deserialize<'de> for FilePatternWire {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        struct FilePatternVisitor;\n        struct GlobFieldVisitor;\n\n        impl<'de> DeserializeSeed<'de> for GlobFieldVisitor {\n            type Value = FilePatternWire;\n\n            fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>\n            where\n                D: Deserializer<'de>,\n            {\n                deserializer.deserialize_any(self)\n            }\n        }\n\n        impl<'de> Visitor<'de> for GlobFieldVisitor {\n            type Value = FilePatternWire;\n\n            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n                formatter.write_str(\"a string or a list of strings\")\n            }\n\n            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>\n            where\n                E: DeError,\n            {\n                Ok(FilePatternWire::Glob {\n                    glob: value.to_owned(),\n                })\n            }\n\n            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>\n            where\n                E: DeError,\n            {\n                Ok(FilePatternWire::Glob { glob: value })\n            }\n\n            fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>\n            where\n                A: serde::de::SeqAccess<'de>,\n            {\n                Ok(FilePatternWire::GlobList {\n                    glob: Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(\n                        seq,\n                    ))?,\n                })\n            }\n        }\n\n        impl<'de> Visitor<'de> for FilePatternVisitor {\n            type Value = FilePatternWire;\n\n            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n                formatter.write_str(\n                    \"a regex string or a mapping with `glob` set to a string or list of strings\",\n                )\n            }\n\n            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>\n            where\n                E: DeError,\n            {\n                Ok(FilePatternWire::Regex(value.to_owned()))\n            }\n\n            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>\n            where\n                E: DeError,\n            {\n                Ok(FilePatternWire::Regex(value))\n            }\n\n            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>\n            where\n                M: MapAccess<'de>,\n            {\n                let mut glob = None;\n\n                while let Some(key) = map.next_key::<String>()? {\n                    match key.as_str() {\n                        \"glob\" => {\n                            if glob.is_some() {\n                                return Err(M::Error::duplicate_field(\"glob\"));\n                            }\n                            glob = Some(map.next_value_seed(GlobFieldVisitor)?);\n                        }\n                        _ => {\n                            return Err(M::Error::unknown_field(&key, &[\"glob\"]));\n                        }\n                    }\n                }\n\n                glob.ok_or_else(|| M::Error::missing_field(\"glob\"))\n            }\n        }\n\n        deserializer.deserialize_any(FilePatternVisitor)\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\nenum FilePatternWireError {\n    #[error(transparent)]\n    Glob(#[from] globset::Error),\n\n    #[error(transparent)]\n    Regex(#[from] fancy_regex::Error),\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(try_from = \"FilePatternWire\")]\npub(crate) enum FilePattern {\n    Regex(Regex),\n    Glob(GlobPatterns),\n}\n\nimpl FilePattern {\n    pub(crate) fn new_glob(patterns: Vec<String>) -> Result<Self, globset::Error> {\n        Ok(Self::Glob(GlobPatterns::new(patterns)?))\n    }\n\n    pub(crate) fn new_regex(pattern: &str) -> Result<Self, fancy_regex::Error> {\n        Ok(Self::Regex(Regex::new(pattern)?))\n    }\n\n    pub(crate) fn is_match(&self, str: &str) -> bool {\n        match self {\n            FilePattern::Regex(regex) => regex.is_match(str).unwrap_or(false),\n            FilePattern::Glob(globs) => globs.is_match(str),\n        }\n    }\n}\n\nimpl Display for FilePattern {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            FilePattern::Regex(regex) => write!(f, \"regex: {}\", regex.as_str()),\n            FilePattern::Glob(globs) => {\n                let patterns = globs.patterns.iter().join(\", \");\n                write!(f, \"glob: [{patterns}]\")\n            }\n        }\n    }\n}\n\nimpl TryFrom<FilePatternWire> for FilePattern {\n    type Error = FilePatternWireError;\n\n    fn try_from(value: FilePatternWire) -> Result<Self, Self::Error> {\n        match value {\n            FilePatternWire::Glob { glob } => Ok(Self::Glob(GlobPatterns::new(vec![glob])?)),\n            FilePatternWire::GlobList { glob } => Ok(Self::Glob(GlobPatterns::new(glob)?)),\n            FilePatternWire::Regex(pattern) => Ok(Self::Regex(Regex::new(&pattern)?)),\n        }\n    }\n}\n\n#[derive(\n    Debug,\n    Copy,\n    Clone,\n    PartialEq,\n    Eq,\n    Hash,\n    Deserialize,\n    Serialize,\n    clap::ValueEnum,\n    strum::AsRefStr,\n    strum::Display,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[strum(serialize_all = \"snake_case\")]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\n#[non_exhaustive]\npub enum Language {\n    Bun,\n    Conda,\n    Coursier,\n    Dart,\n    Deno,\n    Docker,\n    DockerImage,\n    Dotnet,\n    Fail,\n    Golang,\n    Haskell,\n    Julia,\n    Lua,\n    Node,\n    Perl,\n    Pygrep,\n    Python,\n    R,\n    Ruby,\n    Rust,\n    #[serde(alias = \"unsupported_script\")]\n    Script,\n    Swift,\n    #[serde(alias = \"unsupported\")]\n    System,\n}\n\n#[derive(\n    Debug, Clone, Copy, Default, Deserialize, clap::ValueEnum, strum::AsRefStr, strum::Display,\n)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) enum HookType {\n    CommitMsg,\n    PostCheckout,\n    PostCommit,\n    PostMerge,\n    PostRewrite,\n    #[default]\n    PreCommit,\n    PreMergeCommit,\n    PrePush,\n    PreRebase,\n    PrepareCommitMsg,\n}\n\nimpl HookType {\n    /// Return the number of arguments this hook type expects.\n    pub fn num_args(self) -> RangeInclusive<usize> {\n        match self {\n            Self::CommitMsg => 1..=1,\n            Self::PostCheckout => 3..=3,\n            Self::PreCommit => 0..=0,\n            Self::PostCommit => 0..=0,\n            Self::PreMergeCommit => 0..=0,\n            Self::PostMerge => 1..=1,\n            Self::PostRewrite => 1..=1,\n            Self::PrePush => 2..=2,\n            Self::PreRebase => 1..=2,\n            Self::PrepareCommitMsg => 1..=3,\n        }\n    }\n}\n\n#[derive(\n    Debug,\n    Clone,\n    Copy,\n    PartialEq,\n    Eq,\n    PartialOrd,\n    Ord,\n    Default,\n    Hash,\n    Deserialize,\n    Serialize,\n    clap::ValueEnum,\n    strum::AsRefStr,\n    strum::Display,\n)]\n#[serde(rename_all = \"kebab-case\")]\n#[strum(serialize_all = \"kebab-case\")]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) enum Stage {\n    Manual,\n    CommitMsg,\n    PostCheckout,\n    PostCommit,\n    PostMerge,\n    PostRewrite,\n    #[default]\n    #[serde(alias = \"commit\")]\n    PreCommit,\n    #[serde(alias = \"merge-commit\")]\n    PreMergeCommit,\n    #[serde(alias = \"push\")]\n    PrePush,\n    PreRebase,\n    PrepareCommitMsg,\n}\n\nimpl From<HookType> for Stage {\n    fn from(value: HookType) -> Self {\n        match value {\n            HookType::CommitMsg => Self::CommitMsg,\n            HookType::PostCheckout => Self::PostCheckout,\n            HookType::PostCommit => Self::PostCommit,\n            HookType::PostMerge => Self::PostMerge,\n            HookType::PostRewrite => Self::PostRewrite,\n            HookType::PreCommit => Self::PreCommit,\n            HookType::PreMergeCommit => Self::PreMergeCommit,\n            HookType::PrePush => Self::PrePush,\n            HookType::PreRebase => Self::PreRebase,\n            HookType::PrepareCommitMsg => Self::PrepareCommitMsg,\n        }\n    }\n}\n\nimpl Stage {\n    pub fn operate_on_files(self) -> bool {\n        matches!(\n            self,\n            Stage::Manual\n                | Stage::CommitMsg\n                | Stage::PreCommit\n                | Stage::PreMergeCommit\n                | Stage::PrePush\n                | Stage::PrepareCommitMsg\n        )\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Hash)]\npub(crate) enum Stages {\n    All,\n    Some(BTreeSet<Stage>),\n}\n\nimpl Stages {\n    pub(crate) fn is_empty(&self) -> bool {\n        matches!(self, Self::Some(stages) if stages.is_empty())\n    }\n\n    pub(crate) fn contains(&self, stage: Stage) -> bool {\n        match self {\n            Self::All => true,\n            Self::Some(stages) => stages.contains(&stage),\n        }\n    }\n\n    pub(crate) fn to_vec(&self) -> Vec<Stage> {\n        match self {\n            Self::All => Stage::value_variants().to_vec(),\n            Self::Some(stages) => stages.iter().copied().collect(),\n        }\n    }\n}\n\nimpl Display for Stages {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::All => write!(f, \"all\"),\n            Self::Some(stages) => {\n                if stages.is_empty() {\n                    write!(f, \"none\")\n                } else {\n                    let stages_str = stages.iter().map(ToString::to_string).join(\", \");\n                    write!(f, \"{stages_str}\")\n                }\n            }\n        }\n    }\n}\n\nimpl From<Vec<Stage>> for Stages {\n    fn from(value: Vec<Stage>) -> Self {\n        let stages: BTreeSet<_> = value.into_iter().collect();\n        if stages.len() == Stage::value_variants().len() {\n            Self::All\n        } else {\n            Self::Some(stages)\n        }\n    }\n}\n\nimpl<const N: usize> From<[Stage; N]> for Stages {\n    fn from(value: [Stage; N]) -> Self {\n        Self::from(Vec::from(value))\n    }\n}\n\nimpl<'de> Deserialize<'de> for Stages {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        let stages = Vec::<Stage>::deserialize(deserializer)?;\n        Ok(Self::from(stages))\n    }\n}\n\n/// Controls whether filenames are appended to a hook's command line.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum PassFilenames {\n    /// Pass all matching filenames (default). Corresponds to `pass_filenames:\n    /// true`.\n    All,\n    /// Pass no filenames. Corresponds to `pass_filenames: false`.\n    None,\n    /// Pass at most `n` filenames per invocation. Corresponds to\n    /// `pass_filenames: n`.\n    Limited(std::num::NonZeroUsize),\n}\n\nimpl<'de> Deserialize<'de> for PassFilenames {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        struct PassFilenamesVisitor;\n\n        impl serde::de::Visitor<'_> for PassFilenamesVisitor {\n            type Value = PassFilenames;\n\n            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n                f.write_str(\"a boolean or a positive integer\")\n            }\n\n            fn visit_bool<E: DeError>(self, v: bool) -> Result<PassFilenames, E> {\n                Ok(if v {\n                    PassFilenames::All\n                } else {\n                    PassFilenames::None\n                })\n            }\n\n            fn visit_u64<E: DeError>(self, v: u64) -> Result<PassFilenames, E> {\n                let n = usize::try_from(v)\n                    .ok()\n                    .and_then(std::num::NonZeroUsize::new)\n                    .ok_or_else(|| {\n                        E::custom(\n                            \"pass_filenames must be a positive integer; use `false` to pass no filenames\",\n                        )\n                    })?;\n                Ok(PassFilenames::Limited(n))\n            }\n\n            fn visit_i64<E: DeError>(self, v: i64) -> Result<PassFilenames, E> {\n                if v <= 0 {\n                    return Err(E::custom(\n                        \"pass_filenames must be a positive integer; use `false` to pass no filenames\",\n                    ));\n                }\n                #[allow(clippy::cast_sign_loss)]\n                self.visit_u64(v as u64)\n            }\n        }\n\n        deserializer.deserialize_any(PassFilenamesVisitor)\n    }\n}\n\n/// Common hook options.\n#[derive(Debug, Clone, Default, Deserialize)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct HookOptions {\n    /// Not documented in the official docs.\n    pub alias: Option<String>,\n    /// The pattern of files to run on.\n    pub files: Option<FilePattern>,\n    /// Exclude files that were matched by `files`.\n    /// Default is `$^`, which matches nothing.\n    pub exclude: Option<FilePattern>,\n    /// List of file types to run on (AND).\n    /// Default is `[file]`, which matches all files.\n    pub types: Option<TagSet>,\n    /// List of file types to run on (OR).\n    /// Default is `[]`.\n    pub types_or: Option<TagSet>,\n    /// List of file types to exclude.\n    /// Default is `[]`.\n    pub exclude_types: Option<TagSet>,\n    /// Not documented in the official docs.\n    pub additional_dependencies: Option<Vec<String>>,\n    /// Additional arguments to pass to the hook.\n    pub args: Option<Vec<String>>,\n    /// Environment variables to set for the hook.\n    pub env: Option<FxHashMap<String, String>>,\n    /// This hook will run even if there are no matching files.\n    /// Default is false.\n    pub always_run: Option<bool>,\n    /// If this hook fails, don't run any more hooks.\n    /// Default is false.\n    pub fail_fast: Option<bool>,\n    /// Append filenames that would be checked to the hook entry as arguments.\n    /// Default is true.\n    pub pass_filenames: Option<PassFilenames>,\n    /// A description of the hook. For metadata only.\n    pub description: Option<String>,\n    /// Run the hook on a specific version of the language.\n    /// Default is `default`.\n    /// See <https://pre-commit.com/#overriding-language-version>.\n    pub language_version: Option<String>,\n    /// Write the output of the hook to a file when the hook fails or verbose is enabled.\n    pub log_file: Option<String>,\n    /// This hook will execute using a single process instead of in parallel.\n    /// Default is false.\n    pub require_serial: Option<bool>,\n    /// Select which Git hook stages this hook runs for.\n    /// Default all stages are selected.\n    /// See <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.\n    pub stages: Option<Stages>,\n    /// Print the output of the hook even if it passes.\n    /// Default is false.\n    pub verbose: Option<bool>,\n    /// The minimum version of prek required to run this hook.\n    #[serde(deserialize_with = \"deserialize_and_validate_minimum_version\", default)]\n    pub minimum_prek_version: Option<String>,\n\n    #[serde(skip_serializing, flatten)]\n    pub _unused_keys: BTreeMap<String, serde_json::Value>,\n}\n\nimpl HookOptions {\n    pub fn update(&mut self, other: &Self) {\n        macro_rules! update_if_some {\n            ($($field:ident),* $(,)?) => {\n                $(\n                if other.$field.is_some() {\n                    self.$field.clone_from(&other.$field);\n                }\n                )*\n            };\n        }\n\n        update_if_some!(\n            alias,\n            files,\n            exclude,\n            types,\n            types_or,\n            exclude_types,\n            additional_dependencies,\n            args,\n            always_run,\n            fail_fast,\n            pass_filenames,\n            description,\n            language_version,\n            log_file,\n            require_serial,\n            stages,\n            verbose,\n            minimum_prek_version,\n        );\n\n        // Merge environment variables.\n        if let Some(other_env) = &other.env {\n            if let Some(self_env) = &mut self.env {\n                self_env.extend(other_env.clone());\n            } else {\n                self.env.clone_from(&other.env);\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct ManifestHook {\n    /// The id of the hook.\n    pub id: String,\n    /// The name of the hook.\n    pub name: String,\n    /// The command to run. It can contain arguments that will not be overridden.\n    pub entry: String,\n    /// The language of the hook. Tells prek how to install and run the hook.\n    pub language: Language,\n    #[serde(flatten)]\n    pub options: HookOptions,\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n#[serde(transparent)]\npub(crate) struct Manifest {\n    pub hooks: Vec<ManifestHook>,\n}\n\n/// A remote hook in the configuration file.\n///\n/// All keys in manifest hook dict are valid in a config hook dict, but are optional.\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct RemoteHook {\n    /// The id of the hook.\n    pub id: String,\n    /// Override the name of the hook.\n    pub name: Option<String>,\n    /// Override the entrypoint. Not documented in the official docs but works.\n    pub entry: Option<String>,\n    /// Override the language. Not documented in the official docs but works.\n    pub language: Option<Language>,\n    /// Priority used by the scheduler to determine ordering and concurrency.\n    /// Hooks with the same priority can run in parallel.\n    ///\n    /// This is only allowed in project config files (e.g. `.pre-commit-config.yaml`).\n    /// It is not allowed in manifests (e.g. `.pre-commit-hooks.yaml`).\n    pub priority: Option<u32>,\n    #[serde(flatten)]\n    pub options: HookOptions,\n}\n\n/// A local hook in the configuration file.\n///\n/// This is similar to `ManifestHook`, but includes config-only fields (like `priority`).\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct LocalHook {\n    /// The id of the hook.\n    pub id: String,\n    /// The name of the hook.\n    pub name: String,\n    /// The command to run. It can contain arguments that will not be overridden.\n    pub entry: String,\n    /// The language of the hook. Tells prek how to install and run the hook.\n    pub language: Language,\n    /// Priority used by the scheduler to determine ordering and concurrency.\n    /// Hooks with the same priority can run in parallel.\n    pub priority: Option<u32>,\n    #[serde(flatten)]\n    pub options: HookOptions,\n}\n\n/// A meta hook predefined in pre-commit.\n///\n/// It's the same as the manifest hook definition but with only a few predefined id allowed.\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n#[serde(try_from = \"RemoteHook\")]\npub(crate) struct MetaHook {\n    /// The id of the hook.\n    pub id: String,\n    /// The name of the hook.\n    pub name: String,\n    /// Priority used by the scheduler to determine ordering and concurrency.\n    /// Hooks with the same priority can run in parallel.\n    pub priority: Option<u32>,\n    #[serde(flatten)]\n    pub options: HookOptions,\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum PredefinedHookWireError {\n    #[error(\"unknown {kind} hook id `{id}`\")]\n    UnknownId {\n        kind: PredefinedHookKind,\n        id: String,\n    },\n\n    #[error(\"language must be `system` for {kind} hooks\")]\n    InvalidLanguage { kind: PredefinedHookKind },\n\n    #[error(\"`entry` is not allowed for {kind} hooks\")]\n    EntryNotAllowed { kind: PredefinedHookKind },\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum PredefinedHookKind {\n    Meta,\n    Builtin,\n}\n\nimpl Display for PredefinedHookKind {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::Meta => f.write_str(\"meta\"),\n            Self::Builtin => f.write_str(\"builtin\"),\n        }\n    }\n}\n\nimpl TryFrom<RemoteHook> for MetaHook {\n    type Error = PredefinedHookWireError;\n\n    fn try_from(hook_options: RemoteHook) -> Result<Self, Self::Error> {\n        let mut meta_hook = MetaHook::from_id(&hook_options.id).map_err(|()| {\n            PredefinedHookWireError::UnknownId {\n                kind: PredefinedHookKind::Meta,\n                id: hook_options.id.clone(),\n            }\n        })?;\n\n        if hook_options.language.is_some_and(|l| l != Language::System) {\n            return Err(PredefinedHookWireError::InvalidLanguage {\n                kind: PredefinedHookKind::Meta,\n            });\n        }\n        if hook_options.entry.is_some() {\n            return Err(PredefinedHookWireError::EntryNotAllowed {\n                kind: PredefinedHookKind::Meta,\n            });\n        }\n\n        if let Some(name) = &hook_options.name {\n            meta_hook.name.clone_from(name);\n        }\n        if hook_options.priority.is_some() {\n            meta_hook.priority = hook_options.priority;\n        }\n        meta_hook.options.update(&hook_options.options);\n\n        Ok(meta_hook)\n    }\n}\n\n/// A builtin hook predefined in prek.\n/// Basically the same as meta hooks, but defined under `builtin` repo, and do other non-meta checks.\n#[derive(Debug, Clone, Deserialize)]\n#[serde(try_from = \"RemoteHook\")]\npub(crate) struct BuiltinHook {\n    /// The id of the hook.\n    pub id: String,\n    /// The name of the hook.\n    ///\n    /// This is populated from the predefined builtin hook definition.\n    pub name: String,\n    /// The command to run. It can contain arguments that will not be overridden.\n    pub entry: String,\n    /// Priority used by the scheduler to determine ordering and concurrency.\n    /// Hooks with the same priority can run in parallel.\n    pub priority: Option<u32>,\n    /// Common hook options.\n    ///\n    /// Builtin hooks allow the same set of options overrides as other hooks.\n    #[serde(flatten)]\n    pub options: HookOptions,\n}\n\nimpl TryFrom<RemoteHook> for BuiltinHook {\n    type Error = PredefinedHookWireError;\n\n    fn try_from(hook_options: RemoteHook) -> Result<Self, Self::Error> {\n        let mut builtin_hook = BuiltinHook::from_id(&hook_options.id).map_err(|()| {\n            PredefinedHookWireError::UnknownId {\n                kind: PredefinedHookKind::Builtin,\n                id: hook_options.id.clone(),\n            }\n        })?;\n\n        if hook_options.language.is_some_and(|l| l != Language::System) {\n            return Err(PredefinedHookWireError::InvalidLanguage {\n                kind: PredefinedHookKind::Builtin,\n            });\n        }\n        if hook_options.entry.is_some() {\n            return Err(PredefinedHookWireError::EntryNotAllowed {\n                kind: PredefinedHookKind::Builtin,\n            });\n        }\n\n        if let Some(name) = &hook_options.name {\n            builtin_hook.name.clone_from(name);\n        }\n        if hook_options.priority.is_some() {\n            builtin_hook.priority = hook_options.priority;\n        }\n        builtin_hook.options.update(&hook_options.options);\n\n        Ok(builtin_hook)\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct RemoteRepo {\n    #[cfg_attr(feature = \"schemars\", schemars(schema_with = \"schema_repo_remote\"))]\n    pub repo: String,\n    pub rev: String,\n    #[serde(skip_serializing)]\n    pub hooks: Vec<RemoteHook>,\n\n    #[serde(skip_serializing, flatten)]\n    _unused_keys: BTreeMap<String, serde_json::Value>,\n}\n\nimpl RemoteRepo {\n    pub fn new(repo: String, rev: String, hooks: Vec<RemoteHook>) -> Self {\n        Self {\n            repo,\n            rev,\n            hooks,\n            _unused_keys: BTreeMap::new(),\n        }\n    }\n}\n\nimpl PartialEq for RemoteRepo {\n    fn eq(&self, other: &Self) -> bool {\n        self.repo == other.repo && self.rev == other.rev\n    }\n}\n\nimpl Eq for RemoteRepo {}\n\nimpl std::hash::Hash for RemoteRepo {\n    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {\n        self.repo.hash(state);\n        self.rev.hash(state);\n    }\n}\n\nimpl Display for RemoteRepo {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}@{}\", self.repo, self.rev)\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct LocalRepo {\n    #[cfg_attr(feature = \"schemars\", schemars(schema_with = \"schema_repo_local\"))]\n    pub repo: String,\n    pub hooks: Vec<LocalHook>,\n\n    #[serde(skip_serializing, flatten)]\n    _unused_keys: BTreeMap<String, serde_json::Value>,\n}\n\nimpl Display for LocalRepo {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.write_str(\"local\")\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct MetaRepo {\n    #[cfg_attr(feature = \"schemars\", schemars(schema_with = \"schema_repo_meta\"))]\n    pub repo: String,\n    pub hooks: Vec<MetaHook>,\n\n    #[serde(skip_serializing, flatten)]\n    _unused_keys: BTreeMap<String, serde_json::Value>,\n}\n\nimpl Display for MetaRepo {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.write_str(\"meta\")\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\npub(crate) struct BuiltinRepo {\n    #[cfg_attr(feature = \"schemars\", schemars(schema_with = \"schema_repo_builtin\"))]\n    pub repo: String,\n    pub hooks: Vec<BuiltinHook>,\n\n    #[serde(skip_serializing, flatten)]\n    _unused_keys: BTreeMap<String, serde_json::Value>,\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum Repo {\n    Remote(RemoteRepo),\n    Local(LocalRepo),\n    Meta(MetaRepo),\n    Builtin(BuiltinRepo),\n}\n\nimpl<'de> Deserialize<'de> for Repo {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        struct RepoVisitor;\n\n        impl<'de> Visitor<'de> for RepoVisitor {\n            type Value = Repo;\n\n            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n                formatter.write_str(\"a repo mapping\")\n            }\n\n            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>\n            where\n                M: MapAccess<'de>,\n            {\n                enum HooksValue {\n                    Remote(Vec<RemoteHook>),\n                    Local(Vec<LocalHook>),\n                    Meta(Vec<MetaHook>),\n                    Builtin(Vec<BuiltinHook>),\n                }\n\n                let mut repo: Option<String> = None;\n                let mut rev: Option<String> = None;\n                let mut hooks: Option<HooksValue> = None;\n                let mut unused = BTreeMap::new();\n\n                while let Some(key) = map.next_key::<String>()? {\n                    match key.as_str() {\n                        \"repo\" => {\n                            let repo_value: String = map.next_value()?;\n                            repo = Some(repo_value);\n                        }\n                        \"rev\" => {\n                            rev = Some(map.next_value()?);\n                        }\n                        \"hooks\" => {\n                            hooks = Some(match repo.as_deref() {\n                                Some(\"local\") => HooksValue::Local(map.next_value()?),\n                                Some(\"meta\") => HooksValue::Meta(map.next_value()?),\n                                Some(\"builtin\") => HooksValue::Builtin(map.next_value()?),\n                                // Not seen `repo` yet, assume remote.\n                                _ => HooksValue::Remote(map.next_value()?),\n                            });\n                        }\n                        _ => {\n                            let value = map.next_value::<serde_json::Value>()?;\n                            unused.insert(key, value);\n                        }\n                    }\n                }\n\n                let repo_value = repo.ok_or_else(|| M::Error::missing_field(\"repo\"))?;\n                match repo_value.as_str() {\n                    \"local\" => {\n                        if rev.is_some() {\n                            return Err(M::Error::custom(\"`rev` is not allowed for local repos\"));\n                        }\n                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field(\"hooks\"))? {\n                            HooksValue::Local(hooks) => hooks,\n                            HooksValue::Remote(hooks) => hooks\n                                .into_iter()\n                                .map(remote_hook_to_local::<M::Error>)\n                                .collect::<Result<Vec<_>, _>>()?,\n                            HooksValue::Meta(_) | HooksValue::Builtin(_) => {\n                                return Err(M::Error::custom(\"invalid hooks for local repo\"));\n                            }\n                        };\n                        Ok(Repo::Local(LocalRepo {\n                            repo: \"local\".to_string(),\n                            hooks,\n                            _unused_keys: unused,\n                        }))\n                    }\n                    \"meta\" => {\n                        if rev.is_some() {\n                            return Err(M::Error::custom(\"`rev` is not allowed for meta repos\"));\n                        }\n                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field(\"hooks\"))? {\n                            HooksValue::Meta(hooks) => hooks,\n                            HooksValue::Remote(hooks) => hooks\n                                .into_iter()\n                                .map(|hook| MetaHook::try_from(hook).map_err(M::Error::custom))\n                                .collect::<Result<Vec<_>, _>>()?,\n                            HooksValue::Local(_) | HooksValue::Builtin(_) => {\n                                return Err(M::Error::custom(\"invalid hooks for meta repo\"));\n                            }\n                        };\n                        Ok(Repo::Meta(MetaRepo {\n                            repo: \"meta\".to_string(),\n                            hooks,\n                            _unused_keys: unused,\n                        }))\n                    }\n                    \"builtin\" => {\n                        if rev.is_some() {\n                            return Err(M::Error::custom(\"`rev` is not allowed for builtin repos\"));\n                        }\n                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field(\"hooks\"))? {\n                            HooksValue::Builtin(hooks) => hooks,\n                            HooksValue::Remote(hooks) => hooks\n                                .into_iter()\n                                .map(|hook| BuiltinHook::try_from(hook).map_err(M::Error::custom))\n                                .collect::<Result<Vec<_>, _>>()?,\n                            HooksValue::Local(_) | HooksValue::Meta(_) => {\n                                return Err(M::Error::custom(\"invalid hooks for builtin repo\"));\n                            }\n                        };\n                        Ok(Repo::Builtin(BuiltinRepo {\n                            repo: \"builtin\".to_string(),\n                            hooks,\n                            _unused_keys: unused,\n                        }))\n                    }\n                    _ => {\n                        let rev = rev.ok_or_else(|| M::Error::missing_field(\"rev\"))?;\n                        let hooks = match hooks.ok_or_else(|| M::Error::missing_field(\"hooks\"))? {\n                            HooksValue::Remote(hooks) => hooks,\n                            HooksValue::Local(_) | HooksValue::Meta(_) | HooksValue::Builtin(_) => {\n                                return Err(M::Error::custom(\"invalid hooks for remote repo\"));\n                            }\n                        };\n                        Ok(Repo::Remote(RemoteRepo {\n                            repo: repo_value,\n                            rev,\n                            hooks,\n                            _unused_keys: unused,\n                        }))\n                    }\n                }\n            }\n        }\n\n        deserializer.deserialize_map(RepoVisitor)\n    }\n}\n\nfn remote_hook_to_local<E>(hook: RemoteHook) -> Result<LocalHook, E>\nwhere\n    E: DeError,\n{\n    Ok(LocalHook {\n        id: hook.id,\n        name: hook.name.ok_or_else(|| E::missing_field(\"name\"))?,\n        entry: hook.entry.ok_or_else(|| E::missing_field(\"entry\"))?,\n        language: hook.language.ok_or_else(|| E::missing_field(\"language\"))?,\n        priority: hook.priority,\n        options: hook.options,\n    })\n}\n\n// TODO: warn sensible regex\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(\n    feature = \"schemars\",\n    derive(schemars::JsonSchema),\n    schemars(title = \"prek.toml\"),\n    schemars(description = \"The configuration file for prek, a git hook manager written in Rust.\"),\n    schemars(extend(\"$id\" = \"https://www.schemastore.org/prek.json\")),\n    schemars(extend(\"x-tombi-toml-version\" = \"v1.1.0\")),\n)]\npub(crate) struct Config {\n    pub repos: Vec<Repo>,\n    /// A list of `--hook-types` which will be used by default when running `prek install`.\n    /// Default is `[pre-commit]`.\n    pub default_install_hook_types: Option<Vec<HookType>>,\n    /// A mapping from language to the default `language_version`.\n    pub default_language_version: Option<FxHashMap<Language, String>>,\n    /// A configuration-wide default for the stages property of hooks.\n    /// Default to all stages.\n    pub default_stages: Option<Stages>,\n    /// Global file include pattern.\n    pub files: Option<FilePattern>,\n    /// Global file exclude pattern.\n    pub exclude: Option<FilePattern>,\n    /// Set to true to have prek stop running hooks after the first failure.\n    /// Default is false.\n    pub fail_fast: Option<bool>,\n    /// The minimum version of prek required to run this configuration.\n    #[serde(deserialize_with = \"deserialize_and_validate_minimum_version\", default)]\n    pub minimum_prek_version: Option<String>,\n    /// Set to true to isolate this project from parent configurations in workspace mode.\n    /// When true, files in this project are \"consumed\" by this project and will not be processed\n    /// by parent projects.\n    /// When false (default), files in subprojects are processed by both the subproject and\n    /// any parent projects that contain them.\n    pub orphan: Option<bool>,\n\n    #[serde(skip_serializing, flatten)]\n    _unused_keys: BTreeMap<String, serde_json::Value>,\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum Error {\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    #[error(\"Failed to parse `{0}`\")]\n    Yaml(String, #[source] Box<serde_saphyr::Error>),\n\n    #[error(\"Failed to parse `{0}`\")]\n    Toml(String, #[source] Box<toml::de::Error>),\n}\n\nimpl Error {\n    /// Warn the user if the config error is a parse error (not \"file not found\").\n    pub(crate) fn warn_parse_error(&self) {\n        // Skip file not found errors.\n        if matches!(self, Self::Io(e) if e.kind() == std::io::ErrorKind::NotFound) {\n            return;\n        }\n        if let Some(cause) = self.source() {\n            warn_user_once!(\"{self}: {cause}\");\n        } else {\n            warn_user_once!(\"{self}\");\n        }\n    }\n}\n\n/// Keys that prek does not use.\nconst EXPECTED_UNUSED: &[&str] = &[\"minimum_pre_commit_version\", \"ci\"];\n\nfn push_unused_paths<'a, I>(acc: &mut Vec<String>, prefix: &str, keys: I)\nwhere\n    I: Iterator<Item = &'a str>,\n{\n    for key in keys {\n        let path = if prefix.is_empty() {\n            key.to_string()\n        } else {\n            format!(\"{prefix}.{key}\")\n        };\n        acc.push(path);\n    }\n}\n\nfn collect_unused_paths(config: &Config) -> Vec<String> {\n    let mut paths = Vec::new();\n\n    push_unused_paths(\n        &mut paths,\n        \"\",\n        config._unused_keys.keys().filter_map(|key| {\n            let key = key.as_str();\n            (!EXPECTED_UNUSED.contains(&key)).then_some(key)\n        }),\n    );\n\n    for (repo_idx, repo) in config.repos.iter().enumerate() {\n        let repo_prefix = format!(\"repos[{repo_idx}]\");\n        let (repo_unused_keys, hooks_options): (_, Box<dyn Iterator<Item = &HookOptions>>) =\n            match repo {\n                Repo::Remote(remote) => (\n                    &remote._unused_keys,\n                    Box::new(remote.hooks.iter().map(|h| &h.options)),\n                ),\n                Repo::Local(local) => (\n                    &local._unused_keys,\n                    Box::new(local.hooks.iter().map(|h| &h.options)),\n                ),\n                Repo::Meta(meta) => (\n                    &meta._unused_keys,\n                    Box::new(meta.hooks.iter().map(|h| &h.options)),\n                ),\n                Repo::Builtin(builtin) => (\n                    &builtin._unused_keys,\n                    Box::new(builtin.hooks.iter().map(|h| &h.options)),\n                ),\n            };\n\n        push_unused_paths(\n            &mut paths,\n            &repo_prefix,\n            repo_unused_keys.keys().map(String::as_str),\n        );\n        for (hook_idx, options) in hooks_options.enumerate() {\n            let hook_prefix = format!(\"{repo_prefix}.hooks[{hook_idx}]\");\n            push_unused_paths(\n                &mut paths,\n                &hook_prefix,\n                options._unused_keys.keys().map(String::as_str),\n            );\n        }\n    }\n\n    paths\n}\n\nfn warn_unused_paths(path: &Path, entries: &[String]) {\n    if entries.is_empty() {\n        return;\n    }\n\n    if entries.len() < 4 {\n        let inline = entries\n            .iter()\n            .map(|entry| format!(\"`{}`\", entry.yellow()))\n            .join(\", \");\n        warn_user!(\n            \"Ignored unexpected keys in `{}`: {inline}\",\n            path.user_display().cyan()\n        );\n    } else {\n        let list = entries\n            .iter()\n            .map(|entry| format!(\"  - `{}`\", entry.yellow()))\n            .join(\"\\n\");\n        warn_user!(\n            \"Ignored unexpected keys in `{}`:\\n{list}\",\n            path.user_display().cyan()\n        );\n    }\n}\n\n/// Read the configuration file from the given path.\npub(crate) fn load_config(path: &Path) -> Result<Config, Error> {\n    let content = fs_err::read_to_string(path)?;\n\n    let config = match path.extension() {\n        Some(ext) if ext.eq_ignore_ascii_case(\"toml\") => toml::from_str(&content)\n            .map_err(|e| Error::Toml(path.user_display().to_string(), Box::new(e)))?,\n        _ => serde_saphyr::from_str(&content)\n            .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?,\n    };\n\n    Ok(config)\n}\n\n/// Read the configuration file from the given path, and warn about certain issues.\npub(crate) fn read_config(path: &Path) -> Result<Config, Error> {\n    let config = load_config(path)?;\n\n    let unused_paths = collect_unused_paths(&config);\n    warn_unused_paths(path, &unused_paths);\n\n    // Check for mutable revs and warn the user.\n    let repos_has_mutable_rev = config\n        .repos\n        .iter()\n        .filter_map(|repo| {\n            if let Repo::Remote(repo) = repo {\n                let rev = &repo.rev;\n                // A rev is considered mutable if it doesn't contain a '.' (like a version)\n                // and is not a hexadecimal string (like a commit SHA).\n                if !rev.contains('.') && !looks_like_sha(rev) {\n                    return Some(repo);\n                }\n            }\n            None\n        })\n        .collect::<Vec<_>>();\n    if !repos_has_mutable_rev.is_empty() {\n        let msg = repos_has_mutable_rev\n            .iter()\n            .map(|repo| format!(\"{}: {}\", repo.repo.cyan(), repo.rev.yellow()))\n            .join(\"\\n\");\n\n        warn_user!(\n            \"{}\",\n            indoc::formatdoc! { r#\"\n            The following repos have mutable `rev` fields (moving tag / branch):\n            {}\n            Mutable references are never updated after first install and are not supported.\n            See https://pre-commit.com/#using-the-latest-version-for-a-repository for more details.\n            hint: `prek auto-update` often fixes this\",\n            \"#,\n            msg\n            }\n        );\n    }\n\n    Ok(config)\n}\n\n/// Read the manifest file from the given path.\npub(crate) fn read_manifest(path: &Path) -> Result<Manifest, Error> {\n    let content = fs_err::read_to_string(path)?;\n    let manifest: Manifest = serde_saphyr::from_str(&content)\n        .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?;\n\n    Ok(manifest)\n}\n\n/// Check if a string looks like a git SHA\nfn looks_like_sha(s: &str) -> bool {\n    !s.is_empty() && s.as_bytes().iter().all(u8::is_ascii_hexdigit)\n}\n\nfn deserialize_and_validate_minimum_version<'de, D>(\n    deserializer: D,\n) -> Result<Option<String>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let s = String::deserialize(deserializer)?;\n    if s.is_empty() {\n        return Ok(None);\n    }\n\n    let version = s\n        .parse::<semver::Version>()\n        .map_err(serde::de::Error::custom)?;\n    let cur_version = version::version()\n        .version\n        .parse::<semver::Version>()\n        .expect(\"Invalid prek version\");\n    if version > cur_version {\n        let hint = InstallSource::detect()\n            .map(|s| format!(\"To update, run `{}`.\", s.update_instructions()))\n            .unwrap_or(\"Please consider updating prek\".to_string());\n\n        return Err(serde::de::Error::custom(format!(\n            \"Required minimum prek version `{version}` is greater than current version `{cur_version}`; {hint}\",\n        )));\n    }\n\n    Ok(Some(s))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::io::Write as _;\n\n    /// Filter to replace dynamic version in snapshots\n    const VERSION_FILTER: (&str, &str) = (\n        r\"current version `\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z]+(?:\\.[0-9A-Za-z]+)*)?`\",\n        \"current version `[CURRENT_VERSION]`\",\n    );\n\n    #[test]\n    fn stages_deserialize_empty_as_empty() {\n        #[derive(Debug, Deserialize)]\n        struct Wrapper {\n            stages: Stages,\n        }\n\n        let parsed: Wrapper = serde_saphyr::from_str(\"stages: []\\n\").expect(\"stages should parse\");\n        assert_eq!(parsed.stages, Stages::Some(BTreeSet::new()));\n        assert!(!parsed.stages.contains(Stage::Manual));\n        assert!(!parsed.stages.contains(Stage::PreCommit));\n    }\n\n    #[test]\n    fn config_default_stages_deserialize_empty_as_empty() {\n        let parsed: Config =\n            serde_saphyr::from_str(\"repos: []\\ndefault_stages: []\\n\").expect(\"config should parse\");\n\n        assert_eq!(parsed.default_stages, Some(Stages::Some(BTreeSet::new())));\n    }\n\n    #[test]\n    fn config_default_stages_omitted_keeps_none() {\n        let parsed: Config = serde_saphyr::from_str(\"repos: []\\n\").expect(\"config should parse\");\n\n        assert_eq!(parsed.default_stages, None);\n    }\n\n    #[test]\n    fn stages_deserialize_to_subset() {\n        #[derive(Debug, Deserialize)]\n        struct Wrapper {\n            stages: Stages,\n        }\n\n        let parsed: Wrapper =\n            serde_saphyr::from_str(\"stages: [pre-commit, manual]\\n\").expect(\"stages should parse\");\n        assert!(parsed.stages.contains(Stage::PreCommit));\n        assert!(parsed.stages.contains(Stage::Manual));\n        assert!(!parsed.stages.contains(Stage::PrePush));\n    }\n\n    #[test]\n    fn parse_file_patterns_regex_and_glob() {\n        #[derive(Debug, Deserialize)]\n        struct Wrapper {\n            files: FilePattern,\n            exclude: FilePattern,\n        }\n\n        let regex_yaml = indoc::indoc! {r\"\n            files: ^src/\n            exclude: ^target/\n        \"};\n        let parsed: Wrapper =\n            serde_saphyr::from_str(regex_yaml).expect(\"regex patterns should parse\");\n        assert!(\n            matches!(parsed.files, FilePattern::Regex(_)),\n            \"expected regex pattern\"\n        );\n        assert!(parsed.files.is_match(\"src/main.rs\"));\n        assert!(!parsed.files.is_match(\"other/main.rs\"));\n        assert!(parsed.exclude.is_match(\"target/debug/app\"));\n\n        let glob_yaml = indoc::indoc! {r\"\n            files:\n              glob: src/**/*.rs\n            exclude:\n              glob: target/**\n        \"};\n        let parsed: Wrapper =\n            serde_saphyr::from_str(glob_yaml).expect(\"glob patterns should parse\");\n        assert!(\n            matches!(parsed.files, FilePattern::Glob(_)),\n            \"expected glob pattern\"\n        );\n        assert!(parsed.files.is_match(\"src/lib/main.rs\"));\n        assert!(!parsed.files.is_match(\"src/lib/main.py\"));\n        assert!(parsed.exclude.is_match(\"target/debug/app\"));\n        assert!(!parsed.exclude.is_match(\"src/lib/main.rs\"));\n\n        let glob_list_yaml = indoc::indoc! {r\"\n            files:\n              glob:\n                - src/**/*.rs\n                - crates/**/src/**/*.rs\n            exclude:\n              glob:\n                - target/**\n                - dist/**\n        \"};\n        let parsed: Wrapper =\n            serde_saphyr::from_str(glob_list_yaml).expect(\"glob list patterns should parse\");\n        assert!(parsed.files.is_match(\"src/lib/main.rs\"));\n        assert!(parsed.files.is_match(\"crates/foo/src/lib.rs\"));\n        assert!(!parsed.files.is_match(\"tests/main.rs\"));\n        assert!(parsed.exclude.is_match(\"target/debug/app\"));\n        assert!(parsed.exclude.is_match(\"dist/app\"));\n    }\n\n    #[test]\n    fn file_patterns_expose_sources_and_display() {\n        let pattern: FilePattern = serde_saphyr::from_str(indoc::indoc! {r\"\n            glob:\n              - src/**/*.rs\n              - crates/**/src/**/*.rs\n        \"})\n        .expect(\"glob list should parse\");\n        assert_eq!(\n            pattern.to_string(),\n            \"glob: [src/**/*.rs, crates/**/src/**/*.rs]\"\n        );\n        assert!(pattern.is_match(\"src/main.rs\"));\n        assert!(pattern.is_match(\"crates/foo/src/lib.rs\"));\n        assert!(!pattern.is_match(\"tests/main.rs\"));\n    }\n\n    #[test]\n    fn empty_glob_list_matches_nothing() {\n        let pattern = serde_saphyr::from_str::<FilePattern>(\"glob: []\").unwrap();\n        assert!(!pattern.is_match(\"any/file.rs\"));\n        assert!(!pattern.is_match(\"\"));\n    }\n\n    #[test]\n    fn invalid_glob_pattern_errors() {\n        let err = serde_saphyr::from_str::<FilePattern>(\"glob: \\\"[\\\"\")\n            .expect_err(\"invalid glob should fail\");\n        let msg = err.to_string().to_lowercase();\n        assert!(\n            msg.contains(\"glob\"),\n            \"error should mention glob issues: {msg}\"\n        );\n    }\n\n    #[test]\n    fn parse_repos() {\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: cargo-fmt\n                    name: cargo fmt\n                    entry: cargo fmt --\n                    language: system\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(result);\n\n        // Local hook should not have `rev`\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                rev: v1.0.0\n                hooks:\n                  - id: cargo-fmt\n                    name: cargo fmt\n                    language: system\n                    entry: cargo fmt\n                    types:\n                      - rust\n        \"};\n        // Error on extra `rev` field, but not other fields\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 2 column 5: `rev` is not allowed for local repos\n         --> <input>:2:5\n          |\n        1 | repos:\n        2 |   - repo: local\n          |     ^ `rev` is not allowed for local repos\n        3 |     rev: v1.0.0\n        4 |     hooks:\n          |\n        \");\n\n        // Allow but warn on extra fields (other than `rev`)\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                unknown_field: some_value\n                hooks:\n                  - id: cargo-fmt\n                    name: cargo fmt\n                    entry: cargo fmt\n                    language: system\n                    types:\n                      - rust\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(result);\n\n        // Remote hook should have `rev`.\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: https://github.com/crate-ci/typos\n                rev: v1.0.0\n                hooks:\n                  - id: typos\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(result);\n\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: https://github.com/crate-ci/typos\n                hooks:\n                  - id: typos\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 3 column 5: missing field `rev`\n         --> <input>:3:5\n          |\n        1 | repos:\n        2 |   - repo: https://github.com/crate-ci/typos\n        3 |     hooks:\n          |     ^ missing field `rev`\n        4 |       - id: typos\n          |\n        \");\n\n        // Allow `rev` before `repo`\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - rev: v1.0.0\n                repo: https://github.com/crate-ci/typos\n                hooks:\n                  - id: typos\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(result);\n\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - rev: v1.0.0\n                repo: local\n                hooks:\n                  - id: typos\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 5 column 9: missing field `name`\n         --> <input>:5:9\n          |\n        3 |     repo: local\n        4 |     hooks:\n        5 |       - id: typos\n          |         ^ missing field `name`\n        \");\n\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - rev: v1.0.0\n                repo: meta\n                hooks:\n                  - id: typos\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 5 column 9: unknown meta hook id `typos`\n         --> <input>:5:9\n          |\n        3 |     repo: meta\n        4 |     hooks:\n        5 |       - id: typos\n          |         ^ unknown meta hook id `typos`\n        \");\n\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - rev: v1.0.0\n                repo: builtin\n                hooks:\n                  - id: typos\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 5 column 9: unknown builtin hook id `typos`\n         --> <input>:5:9\n          |\n        3 |     repo: builtin\n        4 |     hooks:\n        5 |       - id: typos\n          |         ^ unknown builtin hook id `typos`\n        \");\n    }\n\n    #[test]\n    fn parse_hooks() {\n        // Remote hook only `id` is required.\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: https://github.com/crate-ci/typos\n                rev: v1.0.0\n                hooks:\n                  - name: typos\n                    alias: typo\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 6 column 9: missing field `id`\n         --> <input>:6:9\n          |\n        4 |     hooks:\n        5 |       - name: typos\n        6 |         alias: typo\n          |         ^ missing field `id`\n        \");\n\n        // Local hook should have `id`, `name`, and `entry` and `language`.\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: cargo-fmt\n                    name: cargo fmt\n                    entry: cargo fmt\n                    types:\n                      - rust\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 7 column 9: missing field `language`\n         --> <input>:7:9\n          |\n        5 |         name: cargo fmt\n        6 |         entry: cargo fmt\n        7 |         types:\n          |         ^ missing field `language`\n        8 |           - rust\n          |\n        \");\n\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: cargo-fmt\n                    name: cargo fmt\n                    entry: cargo fmt\n                    language: rust\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(result);\n    }\n\n    #[test]\n    fn meta_hooks() {\n        // Invalid rev\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: meta\n                rev: v1.0.0\n                hooks:\n                  - name: typos\n                    alias: typo\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 6 column 9: missing field `id`\n         --> <input>:6:9\n          |\n        4 |     hooks:\n        5 |       - name: typos\n        6 |         alias: typo\n          |         ^ missing field `id`\n        \");\n\n        // Invalid meta hook id\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: meta\n                hooks:\n                  - id: hello\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 4 column 9: unknown meta hook id `hello`\n         --> <input>:4:9\n          |\n        2 |   - repo: meta\n        3 |     hooks:\n        4 |       - id: hello\n          |         ^ unknown meta hook id `hello`\n        \");\n\n        // Invalid language\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: meta\n                hooks:\n                  - id: check-hooks-apply\n                    language: python\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 4 column 9: language must be `system` for meta hooks\n         --> <input>:4:9\n          |\n        2 |   - repo: meta\n        3 |     hooks:\n        4 |       - id: check-hooks-apply\n          |         ^ language must be `system` for meta hooks\n        5 |         language: python\n          |\n        \");\n\n        // Invalid entry\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: meta\n                hooks:\n                  - id: check-hooks-apply\n                    entry: echo hell world\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 4 column 9: `entry` is not allowed for meta hooks\n         --> <input>:4:9\n          |\n        2 |   - repo: meta\n        3 |     hooks:\n        4 |       - id: check-hooks-apply\n          |         ^ `entry` is not allowed for meta hooks\n        5 |         entry: echo hell world\n          |\n        \");\n\n        // Valid meta hook\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: meta\n                hooks:\n                  - id: check-hooks-apply\n                  - id: check-useless-excludes\n                  - id: identity\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(result);\n    }\n\n    #[test]\n    fn language_version() {\n        let yaml = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: hook-1\n                    name: hook 1\n                    entry: echo hello world\n                    language: system\n                    language_version: default\n                  - id: hook-2\n                    name: hook 2\n                    entry: echo hello world\n                    language: system\n                    language_version: system\n                  - id: hook-3\n                    name: hook 3\n                    entry: echo hello world\n                    language: system\n                    language_version: '3.8'\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml);\n        insta::assert_debug_snapshot!(result);\n    }\n\n    #[test]\n    fn test_read_yaml_config() -> Result<()> {\n        let config = read_config(Path::new(\"tests/fixtures/uv-pre-commit-config.yaml\"))?;\n        insta::assert_debug_snapshot!(config);\n        Ok(())\n    }\n\n    #[test]\n    fn test_read_toml_config() -> Result<()> {\n        let dir = tempfile::tempdir()?;\n        let toml_path = dir.path().join(\"prek.toml\");\n        fs_err::write(\n            &toml_path,\n            indoc::indoc! {r#\"\n            fail_fast = true\n\n            [[repos]]\n            repo = \"local\"\n\n            [[repos.hooks]]\n            id = \"cargo-fmt\"\n            name = \"cargo fmt\"\n            entry = \"cargo fmt --\"\n            language = \"system\"\n\n            [[repos]]\n            repo = \"https://github.com/pre-commit/pre-commit-hooks\"\n            rev = \"v6.0.0\"\n            hooks = [\n            { id = \"trailing-whitespace\" },\n            {\n                id = \"end-of-file-fixer\",\n                args = [\"--fix\", \"crlf\"]\n            }\n            ]\n        \"#},\n        )?;\n\n        let config = read_config(&toml_path)?;\n        insta::assert_debug_snapshot!(config);\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_read_invalid_toml_config() {\n        let raw = indoc::indoc! {r#\"\n            fail_fast = true\n\n            [[repos]]\n            repo = \"local\"\n\n            [[repos.hooks]]\n            id = \"cargo-fmt\"\n            name = \"cargo fmt\"\n            entry = \"cargo fmt --\"\n            language = \"system\"\n\n            [[repos]]\n            repo = \"https://github.com/pre-commit/pre-commit-hooks\"\n            hooks = [\n            { id = \"trailing-whitespace\" },\n            {\n                id = \"end-of-file-fixer\",\n                args = [\"--fix\", \"crlf\"]\n            }\n            ]\n        \"#};\n\n        let err = toml::from_str::<Config>(raw).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        TOML parse error at line 12, column 1\n           |\n        12 | [[repos]]\n           | ^^^^^^^^^\n        missing field `rev`\n        \");\n\n        let raw = indoc::indoc! {r#\"\n            fail_fast = true\n\n            [[repos]]\n            repo = \"local\"\n            rev = \"v1.0.0\"\n\n            [[repos.hooks]]\n            id = \"cargo-fmt\"\n            name = \"cargo fmt\"\n            entry = \"cargo fmt --\"\n            language = \"system\"\n\n            [[repos]]\n            repo = \"https://github.com/pre-commit/pre-commit-hooks\"\n            rev = \"v6.0.0\"\n            hooks = [\n            { id = \"trailing-whitespace\" },\n            {\n                id = \"end-of-file-fixer\",\n                args = [\"--fix\", \"crlf\"]\n            }\n            ]\n        \"#};\n\n        let err = toml::from_str::<Config>(raw).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        TOML parse error at line 3, column 1\n          |\n        3 | [[repos]]\n          | ^^^^^^^^^\n        `rev` is not allowed for local repos\n        \");\n    }\n\n    #[test]\n    fn test_read_manifest() -> Result<()> {\n        let manifest = read_manifest(Path::new(\"tests/fixtures/uv-pre-commit-hooks.yaml\"))?;\n        insta::assert_debug_snapshot!(manifest);\n        Ok(())\n    }\n\n    #[test]\n    fn test_minimum_prek_version() {\n        // Test that missing minimum_prek_version field doesn't cause an error\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: test-hook\n                    name: Test Hook\n                    entry: echo test\n                    language: system\n        \"};\n        let config = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        assert!(config.minimum_prek_version.is_none());\n\n        // Test that empty minimum_prek_version field is treated as None\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: test-hook\n                    name: Test Hook\n                    entry: echo test\n                    language: system\n            minimum_prek_version: ''\n        \"};\n        let config = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        assert!(config.minimum_prek_version.is_none());\n\n        // Test that valid minimum_prek_version field works in top-level config\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: test-hook\n                    name: Test Hook\n                    entry: echo test\n                    language: system\n            minimum_prek_version: '10.0.0'\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();\n        insta::with_settings!({ filters => vec![VERSION_FILTER] }, {\n            insta::assert_snapshot!(err, @\"\n            error: line 8 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek\n             --> <input>:8:23\n              |\n            6 |         entry: echo test\n            7 |         language: system\n            8 | minimum_prek_version: '10.0.0'\n              |                       ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek\n            \");\n        });\n\n        // Test that valid minimum_prek_version field works in hook config\n        let yaml = indoc::indoc! {r\"\n          - id: test-hook\n            name: Test Hook\n            entry: echo test\n            language: system\n            minimum_prek_version: '10.0.0'\n        \"};\n        let err = serde_saphyr::from_str::<Manifest>(yaml).unwrap_err();\n        insta::with_settings!({ filters => vec![VERSION_FILTER] }, {\n            insta::assert_snapshot!(err, @\"\n            error: line 1 column 3: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek\n             --> <input>:1:3\n              |\n            1 | - id: test-hook\n              |   ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek\n            2 |   name: Test Hook\n            3 |   entry: echo test\n              |\n            \");\n        });\n    }\n\n    #[test]\n    fn test_validate_type_tags() {\n        // Valid tags should parse successfully\n        let yaml_valid = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: my-hook\n                    name: My Hook\n                    entry: echo\n                    language: system\n                    types: [python, file]\n                    types_or: [text, binary]\n                    exclude_types: [symlink]\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml_valid);\n        assert!(result.is_ok(), \"Should parse valid tags successfully\");\n\n        // Empty lists and missing keys should also be fine\n        let yaml_empty = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: my-hook\n                    name: My Hook\n                    entry: echo\n                    language: system\n                    types: []\n                    exclude_types: []\n                    # types_or is missing, which is also valid\n        \"};\n        let result_empty = serde_saphyr::from_str::<Config>(yaml_empty);\n        assert!(\n            result_empty.is_ok(),\n            \"Should parse empty/missing tags successfully\"\n        );\n\n        // Invalid tag in 'types' should fail\n        let yaml_invalid_types = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: my-hook\n                    name: My Hook\n                    entry: echo\n                    language: system\n                    types: [pythoon] # Deliberate typo\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml_invalid_types).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 4 column 9: Type tag `pythoon` is not recognized. Check for typos or upgrade prek to get new tags.\n         --> <input>:4:9\n          |\n        2 |   - repo: local\n        3 |     hooks:\n        4 |       - id: my-hook\n          |         ^ Type tag `pythoon` is not recognized. Check for typos or upgrade prek to get new tags.\n        5 |         name: My Hook\n        6 |         entry: echo\n          |\n        \");\n\n        // Invalid tag in 'types_or' should fail\n        let yaml_invalid_types_or = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: my-hook\n                    name: My Hook\n                    entry: echo\n                    language: system\n                    types_or: [invalidtag]\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml_invalid_types_or).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 4 column 9: Type tag `invalidtag` is not recognized. Check for typos or upgrade prek to get new tags.\n         --> <input>:4:9\n          |\n        2 |   - repo: local\n        3 |     hooks:\n        4 |       - id: my-hook\n          |         ^ Type tag `invalidtag` is not recognized. Check for typos or upgrade prek to get new tags.\n        5 |         name: My Hook\n        6 |         entry: echo\n          |\n        \");\n\n        // Invalid tag in 'exclude_types' should fail\n        let yaml_invalid_exclude_types = indoc::indoc! { r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: my-hook\n                    name: My Hook\n                    entry: echo\n                    language: system\n                    exclude_types: [not-a-real-tag]\n        \"};\n        let err = serde_saphyr::from_str::<Config>(yaml_invalid_exclude_types).unwrap_err();\n        insta::assert_snapshot!(err, @\"\n        error: line 4 column 9: Type tag `not-a-real-tag` is not recognized. Check for typos or upgrade prek to get new tags.\n         --> <input>:4:9\n          |\n        2 |   - repo: local\n        3 |     hooks:\n        4 |       - id: my-hook\n          |         ^ Type tag `not-a-real-tag` is not recognized. Check for typos or upgrade prek to get new tags.\n        5 |         name: My Hook\n        6 |         entry: echo\n          |\n        \");\n    }\n\n    #[test]\n    fn read_config_with_merge_keys() -> Result<()> {\n        let yaml = indoc::indoc! {r#\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: mypy-local\n                    name: Local mypy\n                    entry: python tools/pre_commit/mypy.py 0 \"local\"\n                    <<: &mypy_common\n                      language: python\n                      types_or: [python, pyi]\n                  - id: mypy-3.10\n                    name: Mypy 3.10\n                    entry: python tools/pre_commit/mypy.py 1 \"3.10\"\n                    <<: *mypy_common\n        \"#};\n\n        let mut file = tempfile::NamedTempFile::new()?;\n        file.write_all(yaml.as_bytes())?;\n\n        let config = read_config(file.path())?;\n        insta::assert_debug_snapshot!(config);\n\n        Ok(())\n    }\n\n    #[test]\n    fn read_config_with_nested_merge_keys() -> Result<()> {\n        let yaml = indoc::indoc! {r\"\n            local: &local\n              language: system\n              pass_filenames: false\n              require_serial: true\n\n            local-commit: &local-commit\n              <<: *local\n              stages: [pre-commit]\n\n            repos:\n            - repo: local\n              hooks:\n              - id: test-yaml\n                name: Test YAML compatibility\n                entry: prek --help\n                <<: *local-commit\n        \"};\n\n        let mut file = tempfile::NamedTempFile::new()?;\n        file.write_all(yaml.as_bytes())?;\n\n        let config = read_config(file.path())?;\n        insta::assert_debug_snapshot!(config);\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_list_with_unindented_square() {\n        let yaml = indoc::indoc! {r#\"\n        repos:\n          - repo: https://github.com/pre-commit/mirrors-mypy\n            rev: v1.18.2\n            hooks:\n              - id: mypy\n                exclude: tests/data\n                args: [ \"--pretty\", \"--show-error-codes\" ]\n                additional_dependencies: [\n                  'keyring==24.2.0',\n                  'nox==2024.03.02',\n                  'pytest',\n                  'types-docutils==0.20.0.3',\n                  'types-setuptools==68.2.0.0',\n                  'types-freezegun==1.1.10',\n                  'types-pyyaml==6.0.12.12',\n                  'typing-extensions',\n                ]\n        \"#};\n        let result = serde_saphyr::from_str::<Config>(yaml);\n        assert!(result.is_ok());\n    }\n\n    #[test]\n    fn test_numeric_rev_is_parsed_as_string() {\n        // Because we define `rev` as a String, `serde-saphyr` can automatically parse numeric\n        // revs as strings.\n        let yaml = indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/mirrors-mypy\n            rev: 1.0\n            hooks:\n              - id: mypy\n        \"};\n        let config = serde_saphyr::from_str::<Config>(yaml).unwrap();\n        insta::assert_debug_snapshot!(config);\n    }\n\n    #[test]\n    fn pass_filenames_zero_is_rejected() {\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: invalid-pass-filenames-zero\n                    name: invalid pass_filenames zero\n                    entry: echo\n                    language: system\n                    pass_filenames: 0\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn pass_filenames_negative_is_rejected() {\n        let yaml = indoc::indoc! {r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: invalid-pass-filenames-negative\n                    name: invalid pass_filenames negative\n                    entry: echo\n                    language: system\n                    pass_filenames: -1\n        \"};\n        let result = serde_saphyr::from_str::<Config>(yaml);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn pass_filenames_string_is_rejected() {\n        let yaml = indoc::indoc! {r#\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: invalid-pass-filenames-string\n                    name: invalid pass_filenames string\n                    entry: echo\n                    language: system\n                    pass_filenames: \"foo\"\n        \"#};\n        let result = serde_saphyr::from_str::<Config>(yaml);\n        assert!(result.is_err());\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/fs.rs",
    "content": "// MIT License\n//\n// Copyright (c) 2023 Astral Software Inc.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nuse std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::sync::LazyLock;\nuse std::sync::Mutex;\nuse std::time::Duration;\n\nuse rustc_hash::FxHashMap;\n#[cfg(test)]\nuse rustc_hash::FxHashSet;\nuse tracing::{debug, error, info, trace};\n\nuse crate::cli::reporter;\n\npub static CWD: LazyLock<PathBuf> =\n    LazyLock::new(|| std::env::current_dir().expect(\"The current directory must be exist\"));\n\nstatic IN_PROCESS_LOCK_HELD_COUNTS: LazyLock<Mutex<FxHashMap<PathBuf, usize>>> =\n    LazyLock::new(Default::default);\n\n#[cfg(test)]\nstatic LOCK_WARNING_PATHS: LazyLock<Mutex<FxHashSet<PathBuf>>> = LazyLock::new(Default::default);\n\n// Test-only override: treat contention for specific lock paths as cross-process so we emit the\n// warning even if the lock is held by the current process. This lets unit tests exercise the\n// warning logic without spawning another process, and avoids affecting unrelated locks/tests.\n#[cfg(test)]\nstatic FORCE_CROSS_PROCESS_LOCK_WARNING_FOR: LazyLock<Mutex<FxHashSet<PathBuf>>> =\n    LazyLock::new(Default::default);\n\n/// A file lock that is automatically released when dropped.\n#[derive(Debug)]\npub struct LockedFile {\n    file: fs_err::File,\n    path: PathBuf,\n}\n\nimpl LockedFile {\n    /// Inner implementation for [`LockedFile::acquire_blocking`] and [`LockedFile::acquire`].\n    fn lock_file_blocking(\n        file: fs_err::File,\n        resource: &str,\n    ) -> Result<fs_err::File, std::io::Error> {\n        trace!(\n            resource,\n            path = %file.path().display(),\n            \"Checking lock\",\n        );\n        match file.try_lock() {\n            Ok(()) => {\n                debug!(resource, \"Acquired lock\");\n                Ok(file)\n            }\n            Err(err) => {\n                // Log error code and enum kind to help debugging more exotic failures\n                if !matches!(err, std::fs::TryLockError::WouldBlock) {\n                    trace!(error = ?err, \"Try lock error\");\n                }\n                info!(\n                    resource,\n                    path = %file.path().display(),\n                    \"Waiting to acquire lock\",\n                );\n                file.lock().map_err(|err| {\n                    // Not a fs_err method, we need to build our own path context\n                    std::io::Error::other(format!(\n                        \"Could not acquire lock for `{resource}` at `{}`: {}\",\n                        file.path().display(),\n                        err\n                    ))\n                })?;\n                trace!(resource, \"Acquired lock\");\n                Ok(file)\n            }\n        }\n    }\n\n    /// Acquire a cross-process lock for a resource using a file at the provided path.\n    pub async fn acquire(\n        path: impl AsRef<Path>,\n        resource: impl Display,\n    ) -> Result<Self, std::io::Error> {\n        let path = path.as_ref().to_path_buf();\n\n        let file = fs_err::File::create(&path)?;\n\n        let resource = resource.to_string();\n        let mut task =\n            tokio::task::spawn_blocking(move || Self::lock_file_blocking(file, &resource));\n\n        let warning_path = path.clone();\n\n        let file = tokio::select! {\n            result = &mut task => result??,\n            () = tokio::time::sleep(Duration::from_secs(1)) => {\n                let held_by_this_process = {\n                    let held_by_this_process = IN_PROCESS_LOCK_HELD_COUNTS\n                                .lock()\n                                .unwrap()\n                                .get(&warning_path)\n                                .is_some_and(|count| *count > 0);\n\n                    #[cfg(test)]\n                    {\n                        let forced_cross_process = FORCE_CROSS_PROCESS_LOCK_WARNING_FOR\n                            .lock()\n                            .unwrap()\n                            .contains(&warning_path);\n\n                        if forced_cross_process {\n                            false\n                        } else {\n                            held_by_this_process\n                        }\n                    }\n\n                    #[cfg(not(test))]\n                    {\n                        held_by_this_process\n                    }\n                };\n\n                if !held_by_this_process {\n                    reporter::suspend(move || {\n                        #[cfg(test)]\n                        {\n                            LOCK_WARNING_PATHS.lock().unwrap().insert(warning_path);\n                        }\n\n                        #[cfg(not(test))]\n                        {\n                            crate::warn_user!(\n                                \"Waiting to acquire lock at `{}`. Another prek process may still be running\",\n                                warning_path.display()\n                            );\n                        }\n                    });\n                }\n\n                task.await??\n            }\n        };\n\n        {\n            let mut held = IN_PROCESS_LOCK_HELD_COUNTS.lock().unwrap();\n            *held.entry(path.clone()).or_insert(0) += 1;\n        }\n\n        Ok(Self { file, path })\n    }\n}\n\nimpl Drop for LockedFile {\n    fn drop(&mut self) {\n        if let Err(err) = self.file.file().unlock() {\n            error!(\n                \"Failed to unlock {}; program may be stuck: {}\",\n                self.file.path().display(),\n                err\n            );\n        } else {\n            let mut held = IN_PROCESS_LOCK_HELD_COUNTS.lock().unwrap();\n            if let Some(count) = held.get_mut(&self.path) {\n                *count = count.saturating_sub(1);\n                if *count == 0 {\n                    held.remove(&self.path);\n                }\n            }\n            trace!(path = %self.file.path().display(), \"Released lock\");\n        }\n    }\n}\n\n/// Normalizes a path to use `/` as a separator everywhere, even on platforms\n/// that recognize other characters as separators.\n#[cfg(unix)]\npub(crate) fn normalize_path(path: PathBuf) -> PathBuf {\n    // UNIX only uses /, so we're good.\n    path\n}\n\n/// Normalizes a path to use `/` as a separator everywhere, even on platforms\n/// that recognize other characters as separators.\n#[cfg(not(unix))]\npub(crate) fn normalize_path(path: PathBuf) -> PathBuf {\n    use std::ffi::OsString;\n    use std::path::is_separator;\n\n    let mut path = path.into_os_string().into_encoded_bytes();\n    for c in &mut path {\n        if *c == b'/' || !is_separator(char::from(*c)) {\n            continue;\n        }\n        *c = b'/';\n    }\n\n    match String::from_utf8(path) {\n        Ok(s) => PathBuf::from(s),\n        Err(e) => {\n            let path = e.into_bytes();\n            PathBuf::from(OsString::from(String::from_utf8_lossy(&path).as_ref()))\n        }\n    }\n}\n\n/// Compute a path describing `path` relative to `base`.\n///\n/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`\n/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`\n/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`\n///\n/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths\n/// are on different drives on Windows).\npub fn relative_to(\n    path: impl AsRef<Path>,\n    base: impl AsRef<Path>,\n) -> Result<PathBuf, std::io::Error> {\n    // Find the longest common prefix, and also return the path stripped from that prefix\n    let (stripped, common_prefix) = base\n        .as_ref()\n        .ancestors()\n        .find_map(|ancestor| {\n            // Simplifying removes the UNC path prefix on windows.\n            dunce::simplified(path.as_ref())\n                .strip_prefix(dunce::simplified(ancestor))\n                .ok()\n                .map(|stripped| (stripped, ancestor))\n        })\n        .ok_or_else(|| {\n            std::io::Error::other(format!(\n                \"Trivial strip failed: {} vs. {}\",\n                path.as_ref().display(),\n                base.as_ref().display()\n            ))\n        })?;\n\n    // go as many levels up as required\n    let levels_up = base.as_ref().components().count() - common_prefix.components().count();\n    let up = std::iter::repeat_n(\"..\", levels_up).collect::<PathBuf>();\n\n    Ok(up.join(stripped))\n}\n\npub trait Simplified {\n    /// Simplify a [`Path`].\n    ///\n    /// On Windows, this will strip the `\\\\?\\` prefix from paths. On other platforms, it's a no-op.\n    fn simplified(&self) -> &Path;\n\n    /// Render a [`Path`] for display.\n    ///\n    /// On Windows, this will strip the `\\\\?\\` prefix from paths. On other platforms, it's\n    /// equivalent to [`std::path::Display`].\n    fn simplified_display(&self) -> impl Display;\n\n    /// Render a [`Path`] for user-facing display.\n    ///\n    /// Like [`simplified_display`], but relativizes the path against the current working directory.\n    fn user_display(&self) -> impl Display;\n}\n\nimpl<T: AsRef<Path>> Simplified for T {\n    fn simplified(&self) -> &Path {\n        dunce::simplified(self.as_ref())\n    }\n\n    fn simplified_display(&self) -> impl Display {\n        dunce::simplified(self.as_ref()).display()\n    }\n\n    fn user_display(&self) -> impl Display {\n        let path = dunce::simplified(self.as_ref());\n\n        // If current working directory is root, display the path as-is.\n        if CWD.ancestors().nth(1).is_none() {\n            return path.display();\n        }\n\n        // Attempt to strip the current working directory, then the canonicalized current working\n        // directory, in case they differ.\n        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);\n\n        path.display()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::time::Duration;\n\n    #[tokio::test]\n    async fn lock_warning_suppressed_for_in_process_contention() {\n        let tmp = tempfile::tempdir().expect(\"tempdir\");\n        let lock_path = tmp.path().join(\".lock\");\n\n        // First acquire should succeed immediately.\n        let lock1 = super::LockedFile::acquire(&lock_path, \"test-lock\")\n            .await\n            .expect(\"acquire lock1\");\n\n        let held_count = super::IN_PROCESS_LOCK_HELD_COUNTS\n            .lock()\n            .unwrap()\n            .get(&lock_path)\n            .copied();\n        assert_eq!(\n            held_count,\n            Some(1),\n            \"expected held-count to be set after first acquire\"\n        );\n\n        // Second acquire should block, but since the lock is held by this process, it should NOT\n        // trigger the \"Another prek process\" warning.\n        let lock_path2 = lock_path.clone();\n        let task =\n            tokio::spawn(async move { super::LockedFile::acquire(lock_path2, \"test-lock\").await });\n\n        tokio::time::sleep(Duration::from_millis(1100)).await;\n\n        let warning = super::LOCK_WARNING_PATHS\n            .lock()\n            .unwrap()\n            .contains(&lock_path);\n        assert!(\n            !warning,\n            \"expected no warning for in-process contention, got: {warning:?}\"\n        );\n\n        drop(lock1);\n        task.await.expect(\"join task\").expect(\"acquire lock2\");\n    }\n\n    #[tokio::test]\n    async fn lock_warning_emitted_when_forced_cross_process() {\n        let tmp = tempfile::tempdir().expect(\"tempdir\");\n        let lock_path = tmp.path().join(\".lock\");\n\n        super::FORCE_CROSS_PROCESS_LOCK_WARNING_FOR\n            .lock()\n            .unwrap()\n            .insert(lock_path.clone());\n\n        // First acquire should succeed immediately.\n        let lock1 = super::LockedFile::acquire(&lock_path, \"test-lock\")\n            .await\n            .expect(\"acquire lock1\");\n\n        // Second acquire should block and emit the warning due to the forced override.\n        let lock_path2 = lock_path.clone();\n        let task =\n            tokio::spawn(async move { super::LockedFile::acquire(lock_path2, \"test-lock\").await });\n\n        tokio::time::sleep(Duration::from_millis(1100)).await;\n\n        let warning = super::LOCK_WARNING_PATHS\n            .lock()\n            .unwrap()\n            .contains(&lock_path);\n        assert!(\n            warning,\n            \"expected warning when forced cross-process mode is enabled\"\n        );\n\n        // Cleanup.\n        super::FORCE_CROSS_PROCESS_LOCK_WARNING_FOR\n            .lock()\n            .unwrap()\n            .remove(&lock_path);\n        drop(lock1);\n        task.await.expect(\"join task\").expect(\"acquire lock2\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/git.rs",
    "content": "use std::borrow::Cow;\nuse std::collections::HashSet;\nuse std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::str::Utf8Error;\nuse std::sync::LazyLock;\n\nuse anyhow::Result;\nuse path_clean::PathClean;\nuse prek_consts::env_vars::EnvVars;\nuse rustc_hash::FxHashSet;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tracing::{debug, instrument, warn};\n\nuse crate::process;\nuse crate::process::{Cmd, StatusError};\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum Error {\n    #[error(transparent)]\n    Command(#[from] process::Error),\n\n    #[error(\"Failed to find git: {0}\")]\n    GitNotFound(#[from] which::Error),\n\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    #[error(transparent)]\n    UTF8(#[from] Utf8Error),\n}\n\npub(crate) static GIT: LazyLock<Result<PathBuf, which::Error>> =\n    LazyLock::new(|| which::which(\"git\"));\n\npub(crate) static GIT_ROOT: LazyLock<Result<PathBuf, Error>> = LazyLock::new(|| {\n    get_root().inspect(|root| {\n        debug!(\"Git root: {}\", root.display());\n    })\n});\n\n/// Remove some `GIT_` environment variables exposed by `git`.\n///\n/// For some commands, like `git commit -a` or `git commit -p`, git creates a `.git/index.lock` file\n/// and set `GIT_INDEX_FILE` to point to it.\n/// We need to keep the `GIT_INDEX_FILE` env var to make sure `git write-tree` works correctly.\n/// <https://stackoverflow.com/questions/65639403/git-pre-commit-hook-how-can-i-get-added-modified-files-when-commit-with-a-flag/65647202#65647202>\npub(crate) static GIT_ENV_TO_REMOVE: LazyLock<Vec<(String, String)>> = LazyLock::new(|| {\n    let keep = &[\n        \"GIT_EXEC_PATH\",\n        \"GIT_SSH\",\n        \"GIT_SSH_COMMAND\",\n        \"GIT_SSL_CAINFO\",\n        \"GIT_SSL_NO_VERIFY\",\n        \"GIT_CONFIG_COUNT\",\n        \"GIT_CONFIG_PARAMETERS\",\n        \"GIT_HTTP_PROXY_AUTHMETHOD\",\n        \"GIT_ALLOW_PROTOCOL\",\n        \"GIT_ASKPASS\",\n    ];\n\n    std::env::vars()\n        .filter(|(k, _)| {\n            k.starts_with(\"GIT_\")\n                && !k.starts_with(\"GIT_CONFIG_KEY_\")\n                && !k.starts_with(\"GIT_CONFIG_VALUE_\")\n                && !keep.contains(&k.as_str())\n        })\n        .collect()\n});\n\npub(crate) fn git_cmd(summary: &str) -> Result<Cmd, Error> {\n    let mut cmd = Cmd::new(GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?, summary);\n    cmd.arg(\"-c\").arg(\"core.useBuiltinFSMonitor=false\");\n\n    Ok(cmd)\n}\n\nfn zsplit(s: &[u8]) -> Result<Vec<PathBuf>, Utf8Error> {\n    s.split(|&b| b == b'\\0')\n        .filter(|slice| !slice.is_empty())\n        .map(|slice| str::from_utf8(slice).map(PathBuf::from))\n        .collect()\n}\n\npub(crate) async fn intent_to_add_files(root: &Path) -> Result<Vec<PathBuf>, Error> {\n    let output = git_cmd(\"get intent to add files\")?\n        .arg(\"diff\")\n        .arg(\"--no-ext-diff\")\n        .arg(\"--ignore-submodules\")\n        .arg(\"--diff-filter=A\")\n        .arg(\"--name-only\")\n        .arg(\"-z\")\n        .arg(\"--\")\n        .arg(root)\n        .check(true)\n        .output()\n        .await?;\n    Ok(zsplit(&output.stdout)?)\n}\n\npub(crate) async fn get_added_files(root: &Path) -> Result<Vec<PathBuf>, Error> {\n    let output = git_cmd(\"get added files\")?\n        .current_dir(root)\n        .arg(\"diff\")\n        .arg(\"--staged\")\n        .arg(\"--name-only\")\n        .arg(\"--diff-filter=A\")\n        .arg(\"-z\") // Use NUL as line terminator\n        .check(true)\n        .output()\n        .await?;\n    Ok(zsplit(&output.stdout)?)\n}\n\npub(crate) async fn get_changed_files(\n    old: &str,\n    new: &str,\n    root: &Path,\n) -> Result<Vec<PathBuf>, Error> {\n    let build_cmd = |range: String| -> Result<Cmd, Error> {\n        let mut cmd = git_cmd(\"get changed files\")?;\n        cmd.arg(\"diff\")\n            .arg(\"--name-only\")\n            .arg(\"--diff-filter=ACMRT\")\n            .arg(\"--no-ext-diff\") // Disable external diff drivers\n            .arg(\"-z\") // Use NUL as line terminator\n            .arg(range)\n            .arg(\"--\")\n            .arg(root);\n        Ok(cmd)\n    };\n\n    // Try three-dot syntax first (merge-base diff), which works for commits\n    let output = build_cmd(format!(\"{old}...{new}\"))?\n        .check(false)\n        .output()\n        .await?;\n\n    if output.status.success() {\n        return Ok(zsplit(&output.stdout)?);\n    }\n\n    // Fall back to two-dot syntax, which works with both commits and trees\n    let output = build_cmd(format!(\"{old}..{new}\"))?\n        .check(true)\n        .output()\n        .await?;\n    Ok(zsplit(&output.stdout)?)\n}\n\n#[instrument(level = \"trace\")]\npub(crate) async fn ls_files(cwd: &Path, path: &Path) -> Result<Vec<PathBuf>, Error> {\n    let output = git_cmd(\"git ls-files\")?\n        .current_dir(cwd)\n        .arg(\"ls-files\")\n        .arg(\"-z\")\n        .arg(\"--\")\n        .arg(path)\n        .check(true)\n        .output()\n        .await?;\n\n    Ok(zsplit(&output.stdout)?)\n}\n\npub(crate) async fn get_git_dir() -> Result<PathBuf, Error> {\n    let output = git_cmd(\"get git dir\")?\n        .arg(\"rev-parse\")\n        .arg(\"--git-dir\")\n        .check(true)\n        .output()\n        .await?;\n    Ok(PathBuf::from(\n        String::from_utf8_lossy(&output.stdout).trim_ascii(),\n    ))\n}\n\npub(crate) async fn get_git_common_dir() -> Result<PathBuf, Error> {\n    let output = git_cmd(\"get git common dir\")?\n        .arg(\"rev-parse\")\n        .arg(\"--git-common-dir\")\n        .check(true)\n        .output()\n        .await?;\n    if output.stdout.trim_ascii().is_empty() {\n        Ok(get_git_dir().await?)\n    } else {\n        Ok(PathBuf::from(\n            String::from_utf8_lossy(&output.stdout).trim_ascii(),\n        ))\n    }\n}\n\npub(crate) async fn get_staged_files(root: &Path) -> Result<Vec<PathBuf>, Error> {\n    let output = git_cmd(\"get staged files\")?\n        .current_dir(root)\n        .arg(\"diff\")\n        .arg(\"--cached\")\n        .arg(\"--name-only\")\n        .arg(\"--diff-filter=ACMRTUXB\") // Everything except for D\n        .arg(\"--no-ext-diff\") // Disable external diff drivers\n        .arg(\"-z\") // Use NUL as line terminator\n        .check(true)\n        .output()\n        .await?;\n    Ok(zsplit(&output.stdout)?)\n}\n\npub(crate) async fn files_not_staged(files: &[&Path]) -> Result<Vec<PathBuf>> {\n    let output = git_cmd(\"git diff\")?\n        .arg(\"diff\")\n        .arg(\"--exit-code\")\n        .arg(\"--name-only\")\n        .arg(\"--no-ext-diff\")\n        .arg(\"-z\") // Use NUL as line terminator\n        .args(files)\n        .check(false)\n        .output()\n        .await?;\n\n    if output.status.code().is_some_and(|code| code == 1) {\n        return Ok(zsplit(&output.stdout)?);\n    }\n\n    Ok(vec![])\n}\n\npub(crate) async fn has_unmerged_paths() -> Result<bool, Error> {\n    let output = git_cmd(\"check has unmerged paths\")?\n        .arg(\"ls-files\")\n        .arg(\"--unmerged\")\n        .check(true)\n        .output()\n        .await?;\n    Ok(!output.stdout.trim_ascii().is_empty())\n}\n\npub(crate) async fn has_diff(rev: &str, path: &Path) -> Result<bool> {\n    let status = git_cmd(\"check diff\")?\n        .arg(\"diff\")\n        .arg(\"--quiet\")\n        .arg(rev)\n        .current_dir(path)\n        .check(false)\n        .status()\n        .await?;\n    Ok(status.code() == Some(1))\n}\n\npub(crate) async fn is_in_merge_conflict() -> Result<bool, Error> {\n    let git_dir = get_git_dir().await?;\n    Ok(git_dir.join(\"MERGE_HEAD\").try_exists()? && git_dir.join(\"MERGE_MSG\").try_exists()?)\n}\n\npub(crate) async fn get_conflicted_files(root: &Path) -> Result<Vec<PathBuf>, Error> {\n    let tree = git_cmd(\"git write-tree\")?\n        .arg(\"write-tree\")\n        .check(true)\n        .output()\n        .await?;\n\n    let output = git_cmd(\"get conflicted files\")?\n        .arg(\"diff\")\n        .arg(\"--name-only\")\n        .arg(\"--no-ext-diff\") // Disable external diff drivers\n        .arg(\"-z\") // Use NUL as line terminator\n        .arg(\"-m\") // Show diffs for merge commits in the default format.\n        .arg(String::from_utf8_lossy(&tree.stdout).trim_ascii())\n        .arg(\"HEAD\")\n        .arg(\"MERGE_HEAD\")\n        .arg(\"--\")\n        .arg(root)\n        .check(true)\n        .output()\n        .await?;\n\n    Ok(zsplit(&output.stdout)?\n        .into_iter()\n        .chain(parse_merge_msg_for_conflicts().await?)\n        .collect::<HashSet<PathBuf>>()\n        .into_iter()\n        .collect())\n}\n\nasync fn parse_merge_msg_for_conflicts() -> Result<Vec<PathBuf>, Error> {\n    let git_dir = get_git_dir().await?;\n    let merge_msg = git_dir.join(\"MERGE_MSG\");\n    let content = fs_err::tokio::read_to_string(&merge_msg).await?;\n    let conflicts = content\n        .lines()\n        // Conflicted files start with tabs\n        .filter(|line| line.starts_with('\\t') || line.starts_with(\"#\\t\"))\n        .map(|line| line.trim_start_matches('#').trim().to_string())\n        .map(PathBuf::from)\n        .collect();\n\n    Ok(conflicts)\n}\n\n#[instrument(level = \"trace\")]\npub(crate) async fn get_diff(path: &Path) -> Result<Vec<u8>, Error> {\n    let output = git_cmd(\"git diff\")?\n        .arg(\"diff\")\n        .arg(\"--no-ext-diff\") // Disable external diff drivers\n        .arg(\"--no-textconv\")\n        .arg(\"--ignore-submodules\")\n        .arg(\"--\")\n        .arg(path)\n        .check(true)\n        .output()\n        .await?;\n    Ok(output.stdout)\n}\n\n/// Create a tree object from the current index.\n///\n/// The name of the new tree object is printed to standard output.\n/// The index must be in a fully merged state.\npub(crate) async fn write_tree() -> Result<String, Error> {\n    let output = git_cmd(\"git write-tree\")?\n        .arg(\"write-tree\")\n        .check(true)\n        .output()\n        .await?;\n    Ok(String::from_utf8_lossy(&output.stdout)\n        .trim_ascii()\n        .to_string())\n}\n\n/// Get the path of the top-level directory of the working tree.\n#[instrument(level = \"trace\")]\npub(crate) fn get_root() -> Result<PathBuf, Error> {\n    let git = GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?;\n    let output = std::process::Command::new(git)\n        .arg(\"rev-parse\")\n        .arg(\"--show-toplevel\")\n        .output()?;\n    if !output.status.success() {\n        return Err(Error::Command(process::Error::Status {\n            summary: \"get git root\".to_string(),\n            error: StatusError {\n                status: output.status,\n                output: Some(output),\n            },\n        }));\n    }\n\n    Ok(PathBuf::from(\n        String::from_utf8_lossy(&output.stdout).trim_ascii(),\n    ))\n}\n\npub(crate) async fn init_repo(url: &str, path: &Path) -> Result<(), Error> {\n    let url = if Path::new(url).is_dir() {\n        // If the URL is a local path, convert it to an absolute path\n        Cow::Owned(\n            std::path::absolute(url)?\n                .clean()\n                .to_string_lossy()\n                .to_string(),\n        )\n    } else {\n        Cow::Borrowed(url)\n    };\n\n    git_cmd(\"init git repo\")?\n        // Unset `extensions.objectFormat` if set, just follow what hash the remote uses.\n        .arg(\"-c\")\n        .arg(\"init.defaultObjectFormat=\")\n        .arg(\"init\")\n        .arg(\"--template=\")\n        .arg(path)\n        .remove_git_envs()\n        .check(true)\n        .output()\n        .await?;\n\n    git_cmd(\"add git remote\")?\n        .current_dir(path)\n        .arg(\"remote\")\n        .arg(\"add\")\n        .arg(\"origin\")\n        .arg(&*url)\n        .remove_git_envs()\n        .check(true)\n        .output()\n        .await?;\n\n    Ok(())\n}\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub(crate) enum TerminalPrompt {\n    Disabled,\n    Enabled,\n}\n\nimpl TerminalPrompt {\n    fn env_value(self) -> &'static str {\n        match self {\n            Self::Disabled => \"0\",\n            Self::Enabled => \"1\",\n        }\n    }\n}\n\n/// Return whether a git clone failure looks like an authentication error.\npub(crate) fn is_auth_error(err: &Error) -> bool {\n    let Error::Command(process::Error::Status {\n        error: StatusError {\n            output: Some(output),\n            ..\n        },\n        ..\n    }) = err\n    else {\n        return false;\n    };\n\n    let error = String::from_utf8_lossy(&output.stderr).to_lowercase();\n\n    [\n        \"terminal prompts disabled\",\n        \"could not read username\",\n        \"could not read password\",\n        \"authentication failed\",\n        \"http basic: access denied\",\n        \"missing or invalid credentials\",\n        \"could not authenticate to server\",\n    ]\n    .iter()\n    .any(|needle| error.contains(needle))\n}\n\nasync fn shallow_clone(\n    rev: &str,\n    path: &Path,\n    terminal_prompt: TerminalPrompt,\n) -> Result<(), Error> {\n    git_cmd(\"git shallow clone\")?\n        .current_dir(path)\n        .arg(\"-c\")\n        .arg(\"protocol.version=2\")\n        .arg(\"fetch\")\n        .arg(\"origin\")\n        .arg(rev)\n        .arg(\"--depth=1\")\n        .remove_git_envs()\n        .env(EnvVars::LC_ALL, \"C\")\n        .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value())\n        .check(true)\n        .output()\n        .await?;\n\n    git_cmd(\"git checkout\")?\n        .current_dir(path)\n        .arg(\"checkout\")\n        .arg(\"FETCH_HEAD\")\n        .remove_git_envs()\n        .env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, \"1\")\n        .env(EnvVars::LC_ALL, \"C\")\n        .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value())\n        .check(true)\n        .output()\n        .await?;\n\n    git_cmd(\"update git submodules\")?\n        .current_dir(path)\n        .arg(\"-c\")\n        .arg(\"protocol.version=2\")\n        .arg(\"submodule\")\n        .arg(\"update\")\n        .arg(\"--init\")\n        .arg(\"--recursive\")\n        .arg(\"--depth=1\")\n        .remove_git_envs()\n        .env(EnvVars::LC_ALL, \"C\")\n        .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value())\n        .check(true)\n        .output()\n        .await?;\n\n    Ok(())\n}\n\nasync fn full_clone(rev: &str, path: &Path, terminal_prompt: TerminalPrompt) -> Result<(), Error> {\n    git_cmd(\"git full clone\")?\n        .current_dir(path)\n        .arg(\"fetch\")\n        .arg(\"origin\")\n        .arg(\"--tags\")\n        .remove_git_envs()\n        .env(EnvVars::LC_ALL, \"C\")\n        .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value())\n        .check(true)\n        .output()\n        .await?;\n\n    git_cmd(\"git checkout\")?\n        .current_dir(path)\n        .arg(\"checkout\")\n        .arg(rev)\n        .remove_git_envs()\n        .env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, \"1\")\n        .env(EnvVars::LC_ALL, \"C\")\n        .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value())\n        .check(true)\n        .output()\n        .await?;\n\n    git_cmd(\"update git submodules\")?\n        .current_dir(path)\n        .arg(\"submodule\")\n        .arg(\"update\")\n        .arg(\"--init\")\n        .arg(\"--recursive\")\n        .remove_git_envs()\n        .env(EnvVars::LC_ALL, \"C\")\n        .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value())\n        .check(true)\n        .output()\n        .await?;\n\n    Ok(())\n}\n\nasync fn clone_repo_attempt(\n    rev: &str,\n    path: &Path,\n    terminal_prompt: TerminalPrompt,\n) -> Result<(), Error> {\n    if let Err(err) = shallow_clone(rev, path, terminal_prompt).await {\n        if is_auth_error(&err) {\n            warn!(?err, \"Failed to shallow clone due to authentication error\");\n            return Err(err);\n        }\n\n        warn!(?err, \"Failed to shallow clone, falling back to full clone\");\n        return full_clone(rev, path, terminal_prompt).await;\n    }\n\n    Ok(())\n}\n\n/// Clone a repository into an initialized destination with the requested terminal prompt mode.\npub(crate) async fn clone_repo(\n    url: &str,\n    rev: &str,\n    path: &Path,\n    terminal_prompt: TerminalPrompt,\n) -> Result<(), Error> {\n    init_repo(url, path).await?;\n    clone_repo_attempt(rev, path, terminal_prompt).await\n}\n\npub(crate) async fn has_hooks_path_set() -> Result<bool> {\n    let output = git_cmd(\"get git hooks path\")?\n        .arg(\"config\")\n        .arg(\"--get\")\n        .arg(\"core.hooksPath\")\n        .check(false)\n        .output()\n        .await?;\n    if output.status.success() {\n        Ok(!output.stdout.trim_ascii().is_empty())\n    } else {\n        Ok(false)\n    }\n}\n\n/// Compute the file mode for a newly created file based on `core.sharedRepository`.\n///\n/// This mirrors the relevant parts of Git's `git_config_perm` in `setup.c`\n/// and `calc_shared_perm` in `path.c`.\nfn shared_repository_file_mode(value: &str, mode: u32) -> Option<u32> {\n    const PERM_GROUP: u32 = 0o660;\n    const PERM_EVERYBODY: u32 = 0o664;\n\n    fn apply(mode: u32, mut tweak: u32, replace: bool) -> u32 {\n        // From Git's `calc_shared_perm`: if the original file is not\n        // user-writable, do not introduce any write bits via the shared\n        // repository permission tweak.\n        if mode & 0o200 == 0 {\n            tweak &= !0o222;\n        }\n        // Also from `calc_shared_perm`: for executable files, mirror read bits\n        // into execute bits so an explicit mode like 0640 becomes 0750 when\n        // applied to a 0755 file.\n        if mode & 0o100 != 0 {\n            tweak |= (tweak & 0o444) >> 2;\n        }\n        // Named values like `group` and `all` add permissions on top of the\n        // existing mode, while octal values replace the low permission bits.\n        if replace {\n            (mode & !0o777) | tweak\n        } else {\n            mode | tweak\n        }\n    }\n\n    let value = value.trim().to_ascii_lowercase();\n    let (tweak, replace) = match value.as_str() {\n        \"\" | \"umask\" | \"false\" | \"no\" | \"off\" | \"0\" => return None,\n        \"group\" | \"true\" | \"yes\" | \"on\" | \"1\" => (PERM_GROUP, false),\n        \"all\" | \"world\" | \"everybody\" | \"2\" => (PERM_EVERYBODY, false),\n        // Parsed like Git's `git_config_perm`, which also accepts explicit\n        // octal modes such as `0640`.\n        _ => (u32::from_str_radix(&value, 8).ok()?, true),\n    };\n\n    // `git_config_perm` rejects explicit modes that do not grant user read/write.\n    if replace && tweak & 0o600 != 0o600 {\n        return None;\n    }\n\n    Some(apply(mode, tweak, replace))\n}\n\n/// Resolve the file mode implied by `core.sharedRepository` for a newly created file.\npub(crate) async fn get_shared_repository_file_mode(mode: u32) -> Result<u32> {\n    let output = git_cmd(\"get shared repository config\")?\n        .arg(\"config\")\n        .arg(\"--get\")\n        .arg(\"core.sharedRepository\")\n        .check(false)\n        .output()\n        .await?;\n    if output.status.success() {\n        let value = str::from_utf8(&output.stdout)?;\n        Ok(shared_repository_file_mode(value, mode).unwrap_or(mode))\n    } else {\n        Ok(mode)\n    }\n}\n\npub(crate) async fn get_lfs_files(paths: &[&Path]) -> Result<FxHashSet<PathBuf>, Error> {\n    if paths.is_empty() {\n        return Ok(FxHashSet::default());\n    }\n\n    let mut child = git_cmd(\"git check-attr\")?\n        .arg(\"check-attr\")\n        .arg(\"filter\")\n        .arg(\"-z\")\n        .arg(\"--stdin\")\n        .stdin(Stdio::piped())\n        .stdout(Stdio::piped())\n        .stderr(Stdio::null())\n        .check(true)\n        .spawn()?;\n\n    let mut stdout = child.stdout.take().expect(\"failed to open stdout\");\n    let mut stdin = child.stdin.take().expect(\"failed to open stdin\");\n\n    let writer = async move {\n        for path in paths {\n            stdin.write_all(path.to_string_lossy().as_bytes()).await?;\n            stdin.write_all(b\"\\0\").await?;\n        }\n        stdin.shutdown().await?;\n        Ok::<(), std::io::Error>(())\n    };\n    let reader = async move {\n        let mut out = Vec::new();\n        stdout.read_to_end(&mut out).await?;\n        Ok::<_, std::io::Error>(out)\n    };\n\n    let (read_result, _write_result) = tokio::try_join!(biased; reader, writer)?;\n\n    let status = child.wait().await?;\n    if !status.success() {\n        return Err(Error::Command(process::Error::Status {\n            summary: \"git check-attr\".to_string(),\n            error: StatusError {\n                status,\n                output: None,\n            },\n        }));\n    }\n\n    let mut lfs_files = FxHashSet::default();\n    let read_result = String::from_utf8_lossy(&read_result);\n    let mut it = read_result.split_terminator('\\0');\n    loop {\n        let (Some(file), Some(_attr), Some(value)) = (it.next(), it.next(), it.next()) else {\n            break;\n        };\n        if value == \"lfs\" {\n            lfs_files.insert(PathBuf::from(file));\n        }\n    }\n\n    Ok(lfs_files)\n}\n\n/// Check if a git revision exists\npub(crate) async fn rev_exists(rev: &str) -> Result<bool, Error> {\n    let output = git_cmd(\"git cat-file\")?\n        .arg(\"cat-file\")\n        // Exit with zero status if <object> exists and is a valid object.\n        .arg(\"-e\")\n        .arg(rev)\n        .check(false)\n        .output()\n        .await?;\n    Ok(output.status.success())\n}\n\n/// Get commits that are ancestors of the given commit but not in the specified remote\npub(crate) async fn get_ancestors_not_in_remote(\n    local_sha: &str,\n    remote_name: &str,\n) -> Result<Vec<String>, Error> {\n    let output = git_cmd(\"get ancestors not in remote\")?\n        .arg(\"rev-list\")\n        .arg(local_sha)\n        .arg(\"--topo-order\")\n        .arg(\"--reverse\")\n        .arg(\"--not\")\n        .arg(format!(\"--remotes={remote_name}\"))\n        .check(true)\n        .output()\n        .await?;\n    Ok(str::from_utf8(&output.stdout)?\n        .trim_ascii()\n        .lines()\n        .map(ToString::to_string)\n        .collect())\n}\n\n/// Get root commits (commits with no parents) for the given commit\npub(crate) async fn get_root_commits(local_sha: &str) -> Result<FxHashSet<String>, Error> {\n    let output = git_cmd(\"get root commits\")?\n        .arg(\"rev-list\")\n        .arg(\"--max-parents=0\")\n        .arg(local_sha)\n        .check(true)\n        .output()\n        .await?;\n    Ok(str::from_utf8(&output.stdout)?\n        .trim_ascii()\n        .lines()\n        .map(ToString::to_string)\n        .collect())\n}\n\n/// Get the parent commit of the given commit\npub(crate) async fn get_parent_commit(commit: &str) -> Result<Option<String>, Error> {\n    let output = git_cmd(\"get parent commit\")?\n        .arg(\"rev-parse\")\n        .arg(format!(\"{commit}^\"))\n        .check(false)\n        .output()\n        .await?;\n    if output.status.success() {\n        Ok(Some(\n            str::from_utf8(&output.stdout)?.trim_ascii().to_string(),\n        ))\n    } else {\n        Ok(None)\n    }\n}\n\n/// Return a list of absolute paths of all git submodules in the repository.\n#[instrument(level = \"trace\")]\npub(crate) fn list_submodules(git_root: &Path) -> Result<Vec<PathBuf>, Error> {\n    if !git_root.join(\".gitmodules\").exists() {\n        return Ok(vec![]);\n    }\n\n    let git = GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?;\n    let output = std::process::Command::new(git)\n        .current_dir(git_root)\n        .arg(\"config\")\n        .arg(\"--file\")\n        .arg(\".gitmodules\")\n        .arg(\"--get-regexp\")\n        .arg(r\"^submodule\\..*\\.path$\")\n        .output()?;\n\n    Ok(String::from_utf8_lossy(&output.stdout)\n        .trim_ascii()\n        .lines()\n        .filter_map(|line| line.split_whitespace().nth(1))\n        .map(|submodule| git_root.join(submodule))\n        .collect())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::shared_repository_file_mode;\n\n    #[test]\n    fn shared_repository_group_mode_matches_git_behavior() {\n        for value in [\"group\", \"true\", \"yes\", \"on\", \"1\"] {\n            assert_eq!(shared_repository_file_mode(value, 0o755), Some(0o775));\n        }\n    }\n\n    #[test]\n    fn shared_repository_everybody_mode_matches_git_behavior() {\n        for value in [\"all\", \"world\", \"everybody\", \"2\"] {\n            assert_eq!(shared_repository_file_mode(value, 0o755), Some(0o775));\n        }\n    }\n\n    #[test]\n    fn shared_repository_octal_mode_matches_git_behavior() {\n        assert_eq!(shared_repository_file_mode(\"0640\", 0o644), Some(0o640));\n        assert_eq!(shared_repository_file_mode(\"0640\", 0o755), Some(0o750));\n    }\n\n    #[test]\n    fn shared_repository_umask_or_invalid_values_do_not_override_mode() {\n        for value in [\"\", \"umask\", \"false\", \"no\", \"off\", \"0\", \"invalid\", \"0400\"] {\n            assert_eq!(shared_repository_file_mode(value, 0o755), None);\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hook.rs",
    "content": "use std::borrow::Cow;\nuse std::ffi::OsStr;\nuse std::fmt::{Display, Formatter};\nuse std::ops::Deref;\nuse std::path::{Path, PathBuf};\nuse std::sync::{Arc, OnceLock};\n\nuse anyhow::{Context, Result};\nuse prek_consts::PRE_COMMIT_HOOKS_YAML;\nuse prek_identify::{TagSet, tags};\nuse rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};\nuse serde::{Deserialize, Serialize};\nuse tempfile::TempDir;\nuse thiserror::Error;\nuse tracing::trace;\n\nuse crate::config::{\n    self, BuiltinHook, Config, FilePattern, HookOptions, Language, LocalHook, ManifestHook,\n    MetaHook, PassFilenames, RemoteHook, Stages, read_manifest,\n};\nuse crate::languages::version::LanguageRequest;\nuse crate::languages::{extract_metadata, resolve_command};\nuse crate::store::Store;\nuse crate::workspace::Project;\n\n#[derive(Error, Debug)]\npub(crate) enum Error {\n    #[error(transparent)]\n    Config(#[from] config::Error),\n\n    #[error(\"Invalid hook `{hook}`\")]\n    Hook {\n        hook: String,\n        #[source]\n        error: anyhow::Error,\n    },\n\n    #[error(\"Failed to read manifest of `{repo}`\")]\n    Manifest {\n        repo: String,\n        #[source]\n        error: config::Error,\n    },\n\n    #[error(\"Failed to create directory for hook environment\")]\n    TmpDir(#[from] std::io::Error),\n}\n\n/// A hook specification that all hook types can be converted into.\n#[derive(Debug, Clone)]\npub(crate) struct HookSpec {\n    pub id: String,\n    pub name: String,\n    pub entry: String,\n    pub language: Language,\n    pub priority: Option<u32>,\n    pub options: HookOptions,\n}\n\nimpl HookSpec {\n    pub(crate) fn apply_remote_hook_overrides(&mut self, config: &RemoteHook) {\n        if let Some(name) = &config.name {\n            self.name.clone_from(name);\n        }\n        if let Some(entry) = &config.entry {\n            self.entry.clone_from(entry);\n        }\n        if let Some(language) = &config.language {\n            self.language.clone_from(language);\n        }\n        if let Some(priority) = config.priority {\n            self.priority = Some(priority);\n        }\n\n        self.options.update(&config.options);\n    }\n\n    pub(crate) fn apply_project_defaults(&mut self, config: &Config) {\n        let language = self.language;\n        if self.options.language_version.is_none() {\n            self.options.language_version = config\n                .default_language_version\n                .as_ref()\n                .and_then(|v| v.get(&language).cloned());\n        }\n\n        if self.options.stages.as_ref().is_none_or(Stages::is_empty) {\n            self.options.stages = Some(config.default_stages.clone().unwrap_or(Stages::All));\n        }\n    }\n}\n\nimpl From<ManifestHook> for HookSpec {\n    fn from(hook: ManifestHook) -> Self {\n        Self {\n            id: hook.id,\n            name: hook.name,\n            entry: hook.entry,\n            language: hook.language,\n            priority: None,\n            options: hook.options,\n        }\n    }\n}\n\nimpl From<LocalHook> for HookSpec {\n    fn from(hook: LocalHook) -> Self {\n        Self {\n            id: hook.id,\n            name: hook.name,\n            entry: hook.entry,\n            language: hook.language,\n            priority: hook.priority,\n            options: hook.options,\n        }\n    }\n}\n\nimpl From<MetaHook> for HookSpec {\n    fn from(hook: MetaHook) -> Self {\n        Self {\n            id: hook.id,\n            name: hook.name,\n            entry: String::new(),\n            language: Language::System,\n            priority: hook.priority,\n            options: hook.options,\n        }\n    }\n}\n\nimpl From<BuiltinHook> for HookSpec {\n    fn from(hook: BuiltinHook) -> Self {\n        Self {\n            id: hook.id,\n            name: hook.name,\n            entry: hook.entry,\n            language: Language::System,\n            priority: hook.priority,\n            options: hook.options,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum Repo {\n    Remote {\n        /// Path to the cloned repo.\n        path: PathBuf,\n        url: String,\n        rev: String,\n        hooks: Vec<HookSpec>,\n    },\n    Local {\n        hooks: Vec<HookSpec>,\n    },\n    Meta {\n        hooks: Vec<HookSpec>,\n    },\n    Builtin {\n        hooks: Vec<HookSpec>,\n    },\n}\n\nimpl Repo {\n    /// Load the remote repo manifest from the path.\n    pub(crate) fn remote(url: String, rev: String, path: PathBuf) -> Result<Self, Error> {\n        let manifest =\n            read_manifest(&path.join(PRE_COMMIT_HOOKS_YAML)).map_err(|e| Error::Manifest {\n                repo: url.clone(),\n                error: e,\n            })?;\n        let hooks = manifest.hooks.into_iter().map(Into::into).collect();\n\n        Ok(Self::Remote {\n            path,\n            url,\n            rev,\n            hooks,\n        })\n    }\n\n    /// Construct a local repo from a list of hooks.\n    pub(crate) fn local(hooks: Vec<LocalHook>) -> Self {\n        Self::Local {\n            hooks: hooks.into_iter().map(Into::into).collect(),\n        }\n    }\n\n    /// Construct a meta repo.\n    pub(crate) fn meta(hooks: Vec<MetaHook>) -> Self {\n        Self::Meta {\n            hooks: hooks.into_iter().map(Into::into).collect(),\n        }\n    }\n\n    /// Construct a builtin repo.\n    pub(crate) fn builtin(hooks: Vec<BuiltinHook>) -> Self {\n        Self::Builtin {\n            hooks: hooks.into_iter().map(Into::into).collect(),\n        }\n    }\n\n    /// Get the path to the cloned repo if it is a remote repo.\n    pub(crate) fn path(&self) -> Option<&Path> {\n        match self {\n            Repo::Remote { path, .. } => Some(path),\n            _ => None,\n        }\n    }\n\n    /// Get a hook by id.\n    pub(crate) fn get_hook(&self, id: &str) -> Option<&HookSpec> {\n        let hooks = match self {\n            Repo::Remote { hooks, .. } => hooks,\n            Repo::Local { hooks } => hooks,\n            Repo::Meta { hooks } => hooks,\n            Repo::Builtin { hooks } => hooks,\n        };\n        hooks.iter().find(|hook| hook.id == id)\n    }\n}\n\nimpl Display for Repo {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Repo::Remote { url, rev, .. } => write!(f, \"{url}@{rev}\"),\n            Repo::Local { .. } => write!(f, \"local\"),\n            Repo::Meta { .. } => write!(f, \"meta\"),\n            Repo::Builtin { .. } => write!(f, \"builtin\"),\n        }\n    }\n}\n\npub(crate) struct HookBuilder {\n    project: Arc<Project>,\n    repo: Arc<Repo>,\n    hook_spec: HookSpec,\n    // The index of the hook in the project configuration.\n    idx: usize,\n}\n\nimpl HookBuilder {\n    pub(crate) fn new(\n        project: Arc<Project>,\n        repo: Arc<Repo>,\n        hook_spec: HookSpec,\n        idx: usize,\n    ) -> Self {\n        Self {\n            project,\n            repo,\n            hook_spec,\n            idx,\n        }\n    }\n\n    /// Check the hook configuration.\n    fn check(&self) -> Result<(), Error> {\n        let language = self.hook_spec.language;\n        let HookOptions {\n            language_version,\n            additional_dependencies,\n            ..\n        } = &self.hook_spec.options;\n\n        let additional_dependencies = additional_dependencies\n            .as_ref()\n            .map_or(&[][..], |deps| deps.as_slice());\n\n        if !additional_dependencies.is_empty() {\n            if !language.supports_install_env() {\n                return Err(Error::Hook {\n                    hook: self.hook_spec.id.clone(),\n                    error: anyhow::anyhow!(\n                        \"Hook specified `additional_dependencies: {}` but the language `{}` does not install an environment\",\n                        additional_dependencies.join(\", \"),\n                        language,\n                    ),\n                });\n            }\n\n            if !language.supports_dependency() {\n                return Err(Error::Hook {\n                    hook: self.hook_spec.id.clone(),\n                    error: anyhow::anyhow!(\n                        \"Hook specified `additional_dependencies: {}` but the language `{}` does not support installing dependencies for now\",\n                        additional_dependencies.join(\", \"),\n                        language,\n                    ),\n                });\n            }\n        }\n\n        if !language.supports_language_version() {\n            if let Some(language_version) = language_version\n                && language_version != \"default\"\n            {\n                return Err(Error::Hook {\n                    hook: self.hook_spec.id.clone(),\n                    error: anyhow::anyhow!(\n                        \"Hook specified `language_version: {language_version}` but the language `{language}` does not support toolchain installation for now\",\n                    ),\n                });\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Build the hook.\n    pub(crate) async fn build(mut self) -> Result<Hook, Error> {\n        self.hook_spec.apply_project_defaults(self.project.config());\n\n        self.check()?;\n\n        let options = self.hook_spec.options;\n        let language_version = options.language_version.unwrap_or_default();\n        let alias = options.alias.unwrap_or_default();\n        let args = options.args.unwrap_or_default();\n        let env = options.env.unwrap_or_default();\n        let types = options.types.unwrap_or(tags::TAG_SET_FILE);\n        let types_or = options.types_or.unwrap_or_default();\n        let exclude_types = options.exclude_types.unwrap_or_default();\n        let always_run = options.always_run.unwrap_or(false);\n        let fail_fast = options.fail_fast.unwrap_or(false);\n        let pass_filenames = options.pass_filenames.unwrap_or(PassFilenames::All);\n        let require_serial = options.require_serial.unwrap_or(false);\n        let verbose = options.verbose.unwrap_or(false);\n        let stages = options.stages.unwrap_or(Stages::All);\n        let additional_dependencies = options\n            .additional_dependencies\n            .unwrap_or_default()\n            .into_iter()\n            .collect::<FxHashSet<_>>();\n\n        let language_request = LanguageRequest::parse(self.hook_spec.language, &language_version)\n            .map_err(|e| Error::Hook {\n            hook: self.hook_spec.id.clone(),\n            error: anyhow::anyhow!(e),\n        })?;\n\n        let entry = Entry::new(self.hook_spec.id.clone(), self.hook_spec.entry);\n\n        let priority = self\n            .hook_spec\n            .priority\n            .unwrap_or(u32::try_from(self.idx).expect(\"idx too large\"));\n\n        let mut hook = Hook {\n            dependencies: OnceLock::new(),\n            project: self.project,\n            repo: self.repo,\n            idx: self.idx,\n            id: self.hook_spec.id,\n            name: self.hook_spec.name,\n            language: self.hook_spec.language,\n\n            priority,\n            entry,\n            stages,\n            language_request,\n            additional_dependencies,\n            alias,\n            types,\n            types_or,\n            exclude_types,\n            args,\n            env,\n            always_run,\n            fail_fast,\n            pass_filenames,\n            require_serial,\n            verbose,\n            files: options.files,\n            exclude: options.exclude,\n            description: options.description,\n            log_file: options.log_file,\n            minimum_prek_version: options.minimum_prek_version,\n        };\n\n        if let Err(err) = extract_metadata(&mut hook).await {\n            if err\n                .downcast_ref::<std::io::Error>()\n                .is_some_and(|e| e.kind() != std::io::ErrorKind::NotFound)\n            {\n                trace!(\"Failed to extract metadata from entry for hook `{hook}`: {err}\");\n            }\n        }\n\n        Ok(hook)\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct Entry {\n    hook: String,\n    entry: String,\n}\n\nimpl Entry {\n    pub(crate) fn new(hook: String, entry: String) -> Self {\n        Self { hook, entry }\n    }\n\n    /// Split the entry and resolve the command by parsing its shebang.\n    pub(crate) fn resolve(&self, env_path: Option<&OsStr>) -> Result<Vec<String>, Error> {\n        let split = self.split()?;\n\n        Ok(resolve_command(split, env_path))\n    }\n\n    /// Split the entry into a list of commands.\n    pub(crate) fn split(&self) -> Result<Vec<String>, Error> {\n        let splits = shlex::split(&self.entry).ok_or_else(|| Error::Hook {\n            hook: self.hook.clone(),\n            error: anyhow::anyhow!(\"Failed to parse entry `{}` as commands\", &self.entry),\n        })?;\n        if splits.is_empty() {\n            return Err(Error::Hook {\n                hook: self.hook.clone(),\n                error: anyhow::anyhow!(\"Failed to parse entry: entry is empty\"),\n            });\n        }\n        Ok(splits)\n    }\n\n    /// Get the original entry string.\n    pub(crate) fn raw(&self) -> &str {\n        &self.entry\n    }\n}\n\n#[allow(clippy::struct_excessive_bools)]\n#[derive(Debug, Clone)]\npub(crate) struct Hook {\n    project: Arc<Project>,\n    repo: Arc<Repo>,\n    // Cached computed dependencies.\n    dependencies: OnceLock<FxHashSet<String>>,\n\n    /// The index of the hook defined in the configuration file.\n    pub idx: usize,\n    pub id: String,\n    pub name: String,\n    pub entry: Entry,\n    pub language: Language,\n    pub alias: String,\n    pub files: Option<FilePattern>,\n    pub exclude: Option<FilePattern>,\n    pub types: TagSet,\n    pub types_or: TagSet,\n    pub exclude_types: TagSet,\n    pub additional_dependencies: FxHashSet<String>,\n    pub args: Vec<String>,\n    pub env: FxHashMap<String, String>,\n    pub always_run: bool,\n    pub fail_fast: bool,\n    pub pass_filenames: PassFilenames,\n    pub description: Option<String>,\n    pub language_request: LanguageRequest,\n    pub log_file: Option<String>,\n    pub require_serial: bool,\n    pub stages: Stages,\n    pub verbose: bool,\n    pub minimum_prek_version: Option<String>,\n    pub priority: u32,\n}\n\nimpl Display for Hook {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        if f.alternate() {\n            write!(f, \"{}:{}\", self.repo, self.id)\n        } else {\n            write!(f, \"{}\", self.id)\n        }\n    }\n}\n\nimpl Hook {\n    pub(crate) fn project(&self) -> &Project {\n        &self.project\n    }\n\n    pub(crate) fn repo(&self) -> &Repo {\n        &self.repo\n    }\n\n    /// Get the path to the repository that contains the hook.\n    pub(crate) fn repo_path(&self) -> Option<&Path> {\n        self.repo.path()\n    }\n\n    pub(crate) fn full_id(&self) -> String {\n        let path = self.project.relative_path();\n        if path.as_os_str().is_empty() {\n            format!(\".:{}\", self.id)\n        } else {\n            format!(\"{}:{}\", path.display(), self.id)\n        }\n    }\n\n    /// Get the path where the hook should be executed.\n    pub(crate) fn work_dir(&self) -> &Path {\n        self.project.path()\n    }\n\n    pub(crate) fn is_remote(&self) -> bool {\n        matches!(&*self.repo, Repo::Remote { .. })\n    }\n\n    /// Dependencies used to identify whether an existing hook environment can be reused.\n    ///\n    /// For remote hooks, the repo URL is included to avoid reusing an environment created\n    /// from a different remote repository.\n    pub(crate) fn env_key_dependencies(&self) -> &FxHashSet<String> {\n        if !self.is_remote() {\n            return &self.additional_dependencies;\n        }\n        self.dependencies.get_or_init(|| {\n            env_key_dependencies(&self.additional_dependencies, Some(&self.repo.to_string()))\n        })\n    }\n\n    /// Returns a lightweight view of the hook environment identity used for reusing installs.\n    ///\n    /// Returns `None` for languages that do not install an environment.\n    pub(crate) fn env_key(&self) -> Option<HookEnvKeyRef<'_>> {\n        if !self.language.supports_install_env() {\n            return None;\n        }\n\n        Some(HookEnvKeyRef {\n            language: self.language,\n            dependencies: self.env_key_dependencies(),\n            language_request: &self.language_request,\n        })\n    }\n\n    /// Dependencies to pass to language dependency installers.\n    ///\n    /// For remote hooks, this includes the local path to the cloned repository so that\n    /// installers can install the hook's package/project itself.\n    pub(crate) fn install_dependencies(&self) -> Cow<'_, FxHashSet<String>> {\n        if let Some(repo_path) = self.repo_path() {\n            let mut deps = self.additional_dependencies.clone();\n            deps.insert(repo_path.to_string_lossy().to_string());\n            Cow::Owned(deps)\n        } else {\n            Cow::Borrowed(&self.additional_dependencies)\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct HookEnvKey {\n    pub(crate) language: Language,\n    pub(crate) dependencies: FxHashSet<String>,\n    pub(crate) language_request: LanguageRequest,\n}\n\n/// Borrowed form of [`HookEnvKey`] for comparing a hook to an existing installation\n/// without allocating/cloning dependency sets.\n#[derive(Debug, Clone, Copy)]\npub(crate) struct HookEnvKeyRef<'a> {\n    pub(crate) language: Language,\n    pub(crate) dependencies: &'a FxHashSet<String>,\n    pub(crate) language_request: &'a LanguageRequest,\n}\n\n/// Builds the dependency set used to identify a hook environment.\n///\n/// For remote hooks, `remote_repo_dependency` is included so environments from different\n/// repositories are not reused accidentally.\nfn env_key_dependencies(\n    additional_dependencies: &FxHashSet<String>,\n    remote_repo_dependency: Option<&str>,\n) -> FxHashSet<String> {\n    let mut deps = FxHashSet::with_capacity_and_hasher(\n        additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()),\n        FxBuildHasher,\n    );\n    deps.extend(additional_dependencies.iter().cloned());\n    if let Some(dep) = remote_repo_dependency {\n        deps.insert(dep.to_string());\n    }\n    deps\n}\n\n/// Shared matching logic between a computed hook env key (owned or borrowed) and an installed\n/// environment described by [`InstallInfo`].\nfn matches_install_info(\n    language: Language,\n    dependencies: &FxHashSet<String>,\n    language_request: &LanguageRequest,\n    info: &InstallInfo,\n) -> bool {\n    info.language == language\n        && info.dependencies == *dependencies\n        && language_request.satisfied_by(info)\n}\n\nimpl HookEnvKey {\n    /// Compute the key used to match an installed hook environment.\n    ///\n    /// Returns `Ok(None)` if this hook does not install an environment.\n    pub(crate) fn from_hook_spec(\n        config: &Config,\n        mut hook_spec: HookSpec,\n        remote_repo_dependency: Option<&str>,\n    ) -> Result<Option<Self>> {\n        let language = hook_spec.language;\n        if !language.supports_install_env() {\n            return Ok(None);\n        }\n\n        hook_spec.apply_project_defaults(config);\n        hook_spec.options.language_version.get_or_insert_default();\n        hook_spec\n            .options\n            .additional_dependencies\n            .get_or_insert_default();\n\n        let request = hook_spec.options.language_version.as_deref().unwrap_or(\"\");\n        let language_request = LanguageRequest::parse(language, request).with_context(|| {\n            format!(\n                \"Invalid language_version `{request}` for hook `{}`\",\n                hook_spec.id\n            )\n        })?;\n\n        let additional_dependencies: FxHashSet<String> = hook_spec\n            .options\n            .additional_dependencies\n            .as_ref()\n            .map_or_else(FxHashSet::default, |deps| deps.iter().cloned().collect());\n\n        let dependencies = env_key_dependencies(&additional_dependencies, remote_repo_dependency);\n\n        Ok(Some(Self {\n            language,\n            dependencies,\n            language_request,\n        }))\n    }\n\n    pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool {\n        matches_install_info(\n            self.language,\n            &self.dependencies,\n            &self.language_request,\n            info,\n        )\n    }\n}\n\nimpl HookEnvKeyRef<'_> {\n    /// Returns true if this env key matches the given installed environment.\n    pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool {\n        matches_install_info(\n            self.language,\n            self.dependencies,\n            self.language_request,\n            info,\n        )\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum InstalledHook {\n    Installed {\n        hook: Arc<Hook>,\n        info: Arc<InstallInfo>,\n    },\n    NoNeedInstall(Arc<Hook>),\n}\n\nimpl Deref for InstalledHook {\n    type Target = Hook;\n\n    fn deref(&self) -> &Self::Target {\n        match self {\n            InstalledHook::Installed { hook, .. } => hook,\n            InstalledHook::NoNeedInstall(hook) => hook,\n        }\n    }\n}\n\nimpl Display for InstalledHook {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        // TODO: add more information\n        self.deref().fmt(f)\n    }\n}\n\npub(crate) const HOOK_MARKER: &str = \".prek-hook.json\";\n\nimpl InstalledHook {\n    /// Get the path to the environment where the hook is installed.\n    pub(crate) fn env_path(&self) -> Option<&Path> {\n        match self {\n            InstalledHook::Installed { info, .. } => Some(&info.env_path),\n            InstalledHook::NoNeedInstall(_) => None,\n        }\n    }\n\n    /// Get the directory the toolchain is installed in.\n    pub(crate) fn toolchain_dir(&self) -> Option<&Path> {\n        match self {\n            InstalledHook::Installed { info, .. } => info.toolchain.parent(),\n            InstalledHook::NoNeedInstall(_) => None,\n        }\n    }\n\n    /// Get the install info of the hook if it is installed.\n    pub(crate) fn install_info(&self) -> Option<&InstallInfo> {\n        match self {\n            InstalledHook::Installed { info, .. } => Some(info),\n            InstalledHook::NoNeedInstall(_) => None,\n        }\n    }\n\n    /// Mark the hook as installed in the environment.\n    pub(crate) async fn mark_as_installed(&self, _store: &Store) -> Result<()> {\n        let Some(info) = self.install_info() else {\n            return Ok(());\n        };\n\n        let content =\n            serde_json::to_string_pretty(info).context(\"Failed to serialize install info\")?;\n\n        fs_err::tokio::write(info.env_path.join(HOOK_MARKER), content)\n            .await\n            .context(\"Failed to write install info\")?;\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize)]\npub(crate) struct InstallInfo {\n    pub(crate) language: Language,\n    pub(crate) language_version: semver::Version,\n    pub(crate) dependencies: FxHashSet<String>,\n    pub(crate) env_path: PathBuf,\n    pub(crate) toolchain: PathBuf,\n    extra: FxHashMap<String, String>,\n    #[serde(skip, default)]\n    temp_dir: Option<TempDir>,\n}\n\nimpl Clone for InstallInfo {\n    fn clone(&self) -> Self {\n        Self {\n            language: self.language,\n            language_version: self.language_version.clone(),\n            dependencies: self.dependencies.clone(),\n            env_path: self.env_path.clone(),\n            toolchain: self.toolchain.clone(),\n            extra: self.extra.clone(),\n            temp_dir: None,\n        }\n    }\n}\n\nimpl InstallInfo {\n    pub(crate) fn new(\n        language: Language,\n        dependencies: FxHashSet<String>,\n        hooks_dir: &Path,\n    ) -> Result<Self, Error> {\n        let env_path = tempfile::Builder::new()\n            .prefix(&format!(\"{language}-\"))\n            .rand_bytes(20)\n            .tempdir_in(hooks_dir)?;\n\n        Ok(Self {\n            language,\n            dependencies,\n            env_path: env_path.path().to_path_buf(),\n            language_version: semver::Version::new(0, 0, 0),\n            toolchain: PathBuf::new(),\n            extra: FxHashMap::default(),\n            temp_dir: Some(env_path),\n        })\n    }\n\n    pub(crate) fn persist_env_path(&mut self) {\n        if let Some(temp_dir) = self.temp_dir.take() {\n            self.env_path = temp_dir.keep();\n        }\n    }\n\n    pub(crate) async fn from_env_path(path: &Path) -> Result<Self> {\n        let content = fs_err::tokio::read_to_string(path.join(HOOK_MARKER)).await?;\n        let info: InstallInfo = serde_json::from_str(&content)?;\n\n        Ok(info)\n    }\n\n    pub(crate) async fn check_health(&self) -> Result<()> {\n        self.language.check_health(self).await\n    }\n\n    pub(crate) fn with_language_version(&mut self, version: semver::Version) -> &mut Self {\n        self.language_version = version;\n        self\n    }\n\n    pub(crate) fn with_toolchain(&mut self, toolchain: PathBuf) -> &mut Self {\n        self.toolchain = toolchain;\n        self\n    }\n\n    pub(crate) fn with_extra(&mut self, key: &str, value: &str) -> &mut Self {\n        self.extra.insert(key.to_string(), value.to_string());\n        self\n    }\n\n    pub(crate) fn get_extra(&self, key: &str) -> Option<&String> {\n        self.extra.get(key)\n    }\n\n    pub(crate) fn matches(&self, hook: &Hook) -> bool {\n        hook.env_key()\n            .is_some_and(|key| key.matches_install_info(self))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::borrow::Cow;\n    use std::path::PathBuf;\n    use std::sync::Arc;\n\n    use anyhow::Result;\n    use prek_consts::PRE_COMMIT_CONFIG_YAML;\n    use prek_identify::tags;\n    use rustc_hash::FxHashMap;\n\n    use crate::config::{Config, HookOptions, Language, PassFilenames, RemoteHook, Stage, Stages};\n    use crate::hook::HookSpec;\n    use crate::languages::version::LanguageRequest;\n    use crate::workspace::Project;\n\n    use super::{Hook, HookBuilder, Repo};\n\n    #[tokio::test]\n    async fn hook_builder_build_fills_and_merges_attributes() -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);\n\n        // Ensure `combine()` can supply defaults for stages and language_version.\n        fs_err::write(\n            &config_path,\n            indoc::indoc! {r\"\n                repos: []\n                default_language_version:\n                  python: python3.12\n                default_stages: [manual]\n            \"},\n        )?;\n\n        let project = Arc::new(Project::from_config_file(\n            Cow::Borrowed(&config_path),\n            None,\n        )?);\n        let repo = Arc::new(Repo::Local { hooks: vec![] });\n\n        // Base hook spec (e.g. from a manifest): minimal options, one env var.\n        let mut base_env = FxHashMap::default();\n        base_env.insert(\"BASE\".to_string(), \"1\".to_string());\n\n        let mut hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"original-name\".to_string(),\n            entry: \"python3 -c 'print(1)'\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions {\n                env: Some(base_env),\n                ..Default::default()\n            },\n        };\n\n        // Project config overrides (e.g. from `.pre-commit-config.yaml`).\n        let mut override_env = FxHashMap::default();\n        override_env.insert(\"OVERRIDE\".to_string(), \"2\".to_string());\n\n        let hook_override = RemoteHook {\n            id: \"test-hook\".to_string(),\n            name: Some(\"override-name\".to_string()),\n            entry: Some(\"python3 -c 'print(2)'\".to_string()),\n            language: None,\n            priority: Some(42),\n            options: HookOptions {\n                alias: Some(\"alias-1\".to_string()),\n                types: Some(tags::TAG_SET_TEXT),\n                args: Some(vec![\"--flag\".to_string()]),\n                env: Some(override_env),\n                always_run: Some(true),\n                pass_filenames: Some(PassFilenames::None),\n                verbose: Some(true),\n                description: Some(\"desc\".to_string()),\n                ..Default::default()\n            },\n        };\n\n        hook_spec.apply_remote_hook_overrides(&hook_override);\n        hook_spec.apply_project_defaults(project.config());\n\n        let builder = HookBuilder::new(project.clone(), repo, hook_spec, 7);\n        let hook = builder.build().await?;\n\n        insta::assert_debug_snapshot!(hook, @r#\"\n        Hook {\n            project: Project {\n                relative_path: \"\",\n                idx: 0,\n                config: Config {\n                    repos: [],\n                    default_install_hook_types: None,\n                    default_language_version: Some(\n                        {\n                            Python: \"python3.12\",\n                        },\n                    ),\n                    default_stages: Some(\n                        Some(\n                            {\n                                Manual,\n                            },\n                        ),\n                    ),\n                    files: None,\n                    exclude: None,\n                    fail_fast: None,\n                    minimum_prek_version: None,\n                    orphan: None,\n                    _unused_keys: {},\n                },\n                repos: [],\n                ..\n            },\n            repo: Local {\n                hooks: [],\n            },\n            dependencies: OnceLock(\n                <uninit>,\n            ),\n            idx: 7,\n            id: \"test-hook\",\n            name: \"override-name\",\n            entry: Entry {\n                hook: \"test-hook\",\n                entry: \"python3 -c 'print(2)'\",\n            },\n            language: Python,\n            alias: \"alias-1\",\n            files: None,\n            exclude: None,\n            types: [\n                \"text\",\n            ],\n            types_or: [],\n            exclude_types: [],\n            additional_dependencies: {},\n            args: [\n                \"--flag\",\n            ],\n            env: {\n                \"BASE\": \"1\",\n                \"OVERRIDE\": \"2\",\n            },\n            always_run: true,\n            fail_fast: false,\n            pass_filenames: None,\n            description: Some(\n                \"desc\",\n            ),\n            language_request: Python(\n                MajorMinor(\n                    3,\n                    12,\n                ),\n            ),\n            log_file: None,\n            require_serial: false,\n            stages: Some(\n                {\n                    Manual,\n                },\n            ),\n            verbose: true,\n            minimum_prek_version: None,\n            priority: 42,\n        }\n        \"#);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_empty_hook_stages_inherit_default_stages() -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);\n        fs_err::write(&config_path, \"repos: []\\ndefault_stages: [manual]\\n\")?;\n\n        let project = Arc::new(Project::from_config_file(\n            Cow::Borrowed(&config_path),\n            None,\n        )?);\n        let repo = Arc::new(Repo::Local { hooks: vec![] });\n\n        let hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"test-hook\".to_string(),\n            entry: \"python3 -c 'print(1)'\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions {\n                stages: Some(Stages::Some(std::collections::BTreeSet::new())),\n                ..Default::default()\n            },\n        };\n\n        let hook = HookBuilder::new(project, repo, hook_spec, 0)\n            .build()\n            .await?;\n\n        assert_eq!(\n            hook.stages,\n            Stages::Some([Stage::Manual].into_iter().collect())\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn hook_spec_apply_project_defaults_sets_explicit_all_when_default_stages_missing() {\n        let config: Config = serde_saphyr::from_str(\"repos: []\\n\").expect(\"config should parse\");\n\n        let mut hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"test-hook\".to_string(),\n            entry: \"python3 -c 'print(1)'\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions::default(),\n        };\n\n        hook_spec.apply_project_defaults(&config);\n\n        assert_eq!(hook_spec.options.stages, Some(Stages::All));\n    }\n\n    #[tokio::test]\n    async fn hook_builder_preserves_explicit_empty_default_stages() -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);\n        fs_err::write(&config_path, \"repos: []\\ndefault_stages: []\\n\")?;\n\n        let project = Arc::new(Project::from_config_file(\n            Cow::Borrowed(&config_path),\n            None,\n        )?);\n        let repo = Arc::new(Repo::Local { hooks: vec![] });\n\n        let hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"test-hook\".to_string(),\n            entry: \"python3 -c 'print(1)'\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions::default(),\n        };\n\n        let hook = HookBuilder::new(project, repo, hook_spec, 0)\n            .build()\n            .await?;\n\n        assert_eq!(hook.stages, Stages::Some(std::collections::BTreeSet::new()));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_defaults_to_all_when_stages_and_default_stages_missing() -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);\n        fs_err::write(&config_path, \"repos: []\\n\")?;\n\n        let project = Arc::new(Project::from_config_file(\n            Cow::Borrowed(&config_path),\n            None,\n        )?);\n        let repo = Arc::new(Repo::Local { hooks: vec![] });\n\n        let hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"test-hook\".to_string(),\n            entry: \"python3 -c 'print(1)'\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions::default(),\n        };\n\n        let hook = HookBuilder::new(project, repo, hook_spec, 0)\n            .build()\n            .await?;\n\n        assert_eq!(hook.stages, Stages::All);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_empty_hook_stages_default_to_all_when_default_stages_missing()\n    -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);\n        fs_err::write(&config_path, \"repos: []\\n\")?;\n\n        let project = Arc::new(Project::from_config_file(\n            Cow::Borrowed(&config_path),\n            None,\n        )?);\n        let repo = Arc::new(Repo::Local { hooks: vec![] });\n\n        let hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"test-hook\".to_string(),\n            entry: \"python3 -c 'print(1)'\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions {\n                stages: Some(Stages::Some(std::collections::BTreeSet::new())),\n                ..Default::default()\n            },\n        };\n\n        let hook = HookBuilder::new(project, repo, hook_spec, 0)\n            .build()\n            .await?;\n\n        assert_eq!(hook.stages, Stages::All);\n        Ok(())\n    }\n\n    /// Set up a temporary directory with a minimal `.pre-commit-config.yaml`\n    /// and a `remote-repo` subdirectory.\n    fn setup_python_hook_test() -> Result<(tempfile::TempDir, Arc<Project>)> {\n        let temp = tempfile::tempdir()?;\n        let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);\n        fs_err::write(&config_path, \"repos: []\\n\")?;\n\n        let project = Arc::new(Project::from_config_file(\n            Cow::Borrowed(&config_path),\n            None,\n        )?);\n\n        let repo_path = temp.path().join(\"remote-repo\");\n        fs_err::create_dir_all(&repo_path)?;\n\n        Ok((temp, project))\n    }\n\n    /// Build a hook from the given repo path and options via `HookBuilder`.\n    async fn build_python_hook(\n        project: Arc<Project>,\n        repo_path: PathBuf,\n        language_version: Option<&str>,\n    ) -> Result<Hook> {\n        let repo = Arc::new(Repo::Remote {\n            path: repo_path,\n            url: \"https://example.invalid/hooks\".to_string(),\n            rev: \"v0.1.0\".to_string(),\n            hooks: vec![],\n        });\n\n        let hook_spec = HookSpec {\n            id: \"test-hook\".to_string(),\n            name: \"test-hook\".to_string(),\n            entry: \"./hook.py\".to_string(),\n            language: Language::Python,\n            priority: None,\n            options: HookOptions {\n                language_version: language_version.map(str::to_string),\n                ..Default::default()\n            },\n        };\n\n        Ok(HookBuilder::new(project, repo, hook_spec, 0)\n            .build()\n            .await?)\n    }\n\n    static PEP723_SCRIPT: &str = indoc::indoc! {r#\"\n        # /// script\n        # requires-python = \">=3.11\"\n        # ///\n        print(\"hello\")\n    \"#};\n\n    #[tokio::test]\n    async fn hook_builder_python_pep723_overrides_user_and_pyproject() -> Result<()> {\n        let (temp, project) = setup_python_hook_test()?;\n        let repo_path = temp.path().join(\"remote-repo\");\n        fs_err::write(\n            repo_path.join(\"pyproject.toml\"),\n            \"[project]\\nrequires-python = \\\">=3.8\\\"\\n\",\n        )?;\n        fs_err::write(repo_path.join(\"hook.py\"), PEP723_SCRIPT)?;\n\n        let hook = build_python_hook(project, repo_path, Some(\"3.9\")).await?;\n\n        assert_eq!(\n            hook.language_request,\n            LanguageRequest::parse(Language::Python, \">=3.11\")?\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_python_user_language_version_overrides_pyproject() -> Result<()> {\n        let (temp, project) = setup_python_hook_test()?;\n        let repo_path = temp.path().join(\"remote-repo\");\n        fs_err::write(\n            repo_path.join(\"pyproject.toml\"),\n            \"[project]\\nrequires-python = \\\">=3.11\\\"\\n\",\n        )?;\n        fs_err::write(repo_path.join(\"hook.py\"), \"print(\\\"hello\\\")\\n\")?;\n\n        let hook = build_python_hook(project, repo_path, Some(\"3.9\")).await?;\n\n        assert_eq!(\n            hook.language_request,\n            LanguageRequest::parse(Language::Python, \"3.9\")?\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_python_pep723_overrides_pyproject_without_user_version() -> Result<()> {\n        let (temp, project) = setup_python_hook_test()?;\n        let repo_path = temp.path().join(\"remote-repo\");\n        fs_err::write(\n            repo_path.join(\"pyproject.toml\"),\n            \"[project]\\nrequires-python = \\\">=3.8\\\"\\n\",\n        )?;\n        fs_err::write(repo_path.join(\"hook.py\"), PEP723_SCRIPT)?;\n\n        let hook = build_python_hook(project, repo_path, None).await?;\n\n        assert_eq!(\n            hook.language_request,\n            LanguageRequest::parse(Language::Python, \">=3.11\")?\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_python_defaults_to_any_without_version_sources() -> Result<()> {\n        let (temp, project) = setup_python_hook_test()?;\n        let repo_path = temp.path().join(\"remote-repo\");\n        fs_err::write(repo_path.join(\"hook.py\"), \"print(\\\"hello\\\")\\n\")?;\n\n        let hook = build_python_hook(project, repo_path, None).await?;\n\n        assert!(hook.language_request.is_any());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn hook_builder_python_pyproject_provides_version_when_no_other_source() -> Result<()> {\n        let (temp, project) = setup_python_hook_test()?;\n        let repo_path = temp.path().join(\"remote-repo\");\n        fs_err::write(\n            repo_path.join(\"pyproject.toml\"),\n            \"[project]\\nrequires-python = \\\">=3.10\\\"\\n\",\n        )?;\n        fs_err::write(repo_path.join(\"hook.py\"), \"print(\\\"hello\\\")\\n\")?;\n\n        let hook = build_python_hook(project, repo_path, None).await?;\n\n        assert_eq!(\n            hook.language_request,\n            LanguageRequest::parse(Language::Python, \">=3.10\")?\n        );\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/builtin_hooks/check_json5.rs",
    "content": "use std::path::Path;\n\nuse crate::hook::Hook;\nuse crate::hooks::pre_commit_hooks::check_json::JsonValue;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\npub(crate) async fn check_json5(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> anyhow::Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn check_file(file_base: &Path, filename: &Path) -> anyhow::Result<(i32, Vec<u8>)> {\n    let file_path = file_base.join(filename);\n    let content = fs_err::tokio::read_to_string(file_path).await?;\n    if content.is_empty() {\n        return Ok((0, Vec::new()));\n    }\n\n    match json5::from_str::<JsonValue>(&content) {\n        Ok(_) => Ok((0, Vec::new())),\n        Err(e) => {\n            let error_message = format!(\"{}: Failed to json5 decode ({})\\n\", filename.display(), e);\n            Ok((1, error_message.into_bytes()))\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> anyhow::Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_valid_json5() -> anyhow::Result<()> {\n        let dir = tempdir()?;\n        let content = indoc::indoc! {r#\"\n        {\n          // comments\n          unquoted: \"and you can quote me on that\",\n          singleQuotes: 'I can use \"double quotes\" here',\n          lineBreaks: \"Look, Mom! \\\n          No \\\\n's!\",\n          hexadecimal: 0xdecaf,\n          leadingDecimalPoint: 0.8675309,\n          andTrailing: 8675309,\n          positiveSign: +1,\n          trailingComma: \"in objects\",\n          andIn: [\"arrays\"],\n          backwardsCompatible: \"with JSON\",\n        }\n        \"#};\n        let file_path = create_test_file(&dir, \"valid.json5\", content.as_bytes()).await?;\n        let (code, output) = check_file(dir.path(), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_duplicate_keys() -> anyhow::Result<()> {\n        // JSON5 warns duplicate names are unpredictable; implementations may error or accept.\n        // Our JsonValue custom deserializer rejects duplicates.\n        let dir = tempdir()?;\n        let content = indoc::indoc! {r#\"\n        {\n          key: \"value1\",\n          key: \"value2\",\n          key: \"value3\",\n        }\n        \"#};\n        let file_path = create_test_file(&dir, \"duplicate.json5\", content.as_bytes()).await?;\n        let (code, output) = check_file(dir.path(), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(String::from_utf8_lossy(&output).contains(\"duplicate key\"));\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_json5() -> anyhow::Result<()> {\n        let dir = tempdir()?;\n        let file_path = create_test_file(&dir, \"invalid.json5\", b\"{ key: 'value' \").await?;\n        let (code, output) = check_file(dir.path(), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/builtin_hooks/mod.rs",
    "content": "use std::path::Path;\nuse std::str::FromStr;\n\nuse anyhow::Result;\nuse prek_identify::tags;\n\nuse crate::cli::reporter::HookRunReporter;\nuse crate::config::{BuiltinHook, HookOptions, PassFilenames, Stage};\nuse crate::hook::Hook;\nuse crate::hooks::pre_commit_hooks;\nuse crate::store::Store;\n\nmod check_json5;\n\n#[derive(\n    Debug,\n    Copy,\n    Clone,\n    PartialEq,\n    Eq,\n    strum::AsRefStr,\n    strum::Display,\n    strum::EnumIter,\n    strum::EnumString,\n)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\n#[cfg_attr(feature = \"schemars\", schemars(rename_all = \"kebab-case\"))]\n#[strum(serialize_all = \"kebab-case\")]\npub(crate) enum BuiltinHooks {\n    CheckAddedLargeFiles,\n    CheckCaseConflict,\n    CheckExecutablesHaveShebangs,\n    CheckJson,\n    CheckJson5,\n    CheckMergeConflict,\n    CheckSymlinks,\n    CheckToml,\n    CheckXml,\n    CheckYaml,\n    DetectPrivateKey,\n    EndOfFileFixer,\n    FixByteOrderMarker,\n    MixedLineEnding,\n    NoCommitToBranch,\n    TrailingWhitespace,\n}\n\nimpl BuiltinHooks {\n    pub(crate) async fn run(\n        self,\n        _store: &Store,\n        hook: &Hook,\n        filenames: &[&Path],\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n        let result = match self {\n            Self::CheckAddedLargeFiles => {\n                pre_commit_hooks::check_added_large_files(hook, filenames).await\n            }\n            Self::CheckCaseConflict => pre_commit_hooks::check_case_conflict(hook, filenames).await,\n            Self::CheckExecutablesHaveShebangs => {\n                pre_commit_hooks::check_executables_have_shebangs(hook, filenames).await\n            }\n            Self::CheckJson => pre_commit_hooks::check_json(hook, filenames).await,\n            Self::CheckJson5 => check_json5::check_json5(hook, filenames).await,\n            Self::CheckMergeConflict => {\n                pre_commit_hooks::check_merge_conflict(hook, filenames).await\n            }\n            Self::CheckSymlinks => pre_commit_hooks::check_symlinks(hook, filenames).await,\n            Self::CheckToml => pre_commit_hooks::check_toml(hook, filenames).await,\n            Self::CheckXml => pre_commit_hooks::check_xml(hook, filenames).await,\n            Self::CheckYaml => pre_commit_hooks::check_yaml(hook, filenames).await,\n            Self::DetectPrivateKey => pre_commit_hooks::detect_private_key(hook, filenames).await,\n            Self::EndOfFileFixer => pre_commit_hooks::fix_end_of_file(hook, filenames).await,\n            Self::FixByteOrderMarker => {\n                pre_commit_hooks::fix_byte_order_marker(hook, filenames).await\n            }\n            Self::MixedLineEnding => pre_commit_hooks::mixed_line_ending(hook, filenames).await,\n            Self::NoCommitToBranch => pre_commit_hooks::no_commit_to_branch(hook).await,\n            Self::TrailingWhitespace => {\n                pre_commit_hooks::fix_trailing_whitespace(hook, filenames).await\n            }\n        };\n        reporter.on_run_complete(progress);\n        result\n    }\n}\n\nimpl BuiltinHook {\n    pub(crate) fn from_id(id: &str) -> Result<Self, ()> {\n        let hook_id = BuiltinHooks::from_str(id).map_err(|_| ())?;\n        Ok(match hook_id {\n            BuiltinHooks::CheckAddedLargeFiles => BuiltinHook {\n                id: \"check-added-large-files\".to_string(),\n                name: \"check for added large files\".to_string(),\n                entry: \"check-added-large-files\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"prevents giant files from being committed.\".to_string()),\n                    stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckCaseConflict => BuiltinHook {\n                id: \"check-case-conflict\".to_string(),\n                name: \"check for case conflicts\".to_string(),\n                entry: \"check-case-conflict\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\n                        \"checks for files that would conflict in case-insensitive filesystems\"\n                            .to_string(),\n                    ),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckExecutablesHaveShebangs => BuiltinHook {\n                id: \"check-executables-have-shebangs\".to_string(),\n                name: \"check that executables have shebangs\".to_string(),\n                entry: \"check-executables-have-shebangs\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\n                        \"ensures that (non-binary) executables have a shebang.\".to_string(),\n                    ),\n                    types: Some(tags::TAG_SET_EXECUTABLE_TEXT),\n                    stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckJson => BuiltinHook {\n                id: \"check-json\".to_string(),\n                name: \"check json\".to_string(),\n                entry: \"check-json\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"checks json files for parseable syntax.\".to_string()),\n                    types: Some(tags::TAG_SET_JSON),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckJson5 => BuiltinHook {\n                id: \"check-json5\".to_string(),\n                name: \"check json5\".to_string(),\n                entry: \"check-json5\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"checks json5 files for parseable syntax.\".to_string()),\n                    types: Some(tags::TAG_SET_JSON5),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckMergeConflict => BuiltinHook {\n                id: \"check-merge-conflict\".to_string(),\n                name: \"check for merge conflicts\".to_string(),\n                entry: \"check-merge-conflict\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\n                        \"checks for files that contain merge conflict strings.\".to_string(),\n                    ),\n                    types: Some(tags::TAG_SET_TEXT),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckSymlinks => BuiltinHook {\n                id: \"check-symlinks\".to_string(),\n                name: \"check for broken symlinks\".to_string(),\n                entry: \"check-symlinks\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\n                        \"checks for symlinks which do not point to anything.\".to_string(),\n                    ),\n                    types: Some(tags::TAG_SET_SYMLINK),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckToml => BuiltinHook {\n                id: \"check-toml\".to_string(),\n                name: \"check toml\".to_string(),\n                entry: \"check-toml\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"checks toml files for parseable syntax.\".to_string()),\n                    types: Some(tags::TAG_SET_TOML),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckXml => BuiltinHook {\n                id: \"check-xml\".to_string(),\n                name: \"check xml\".to_string(),\n                entry: \"check-xml\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"checks xml files for parseable syntax.\".to_string()),\n                    types: Some(tags::TAG_SET_XML),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::CheckYaml => BuiltinHook {\n                id: \"check-yaml\".to_string(),\n                name: \"check yaml\".to_string(),\n                entry: \"check-yaml\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"checks yaml files for parseable syntax.\".to_string()),\n                    types: Some(tags::TAG_SET_YAML),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::DetectPrivateKey => BuiltinHook {\n                id: \"detect-private-key\".to_string(),\n                name: \"detect private key\".to_string(),\n                entry: \"detect-private-key\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"detects the presence of private keys.\".to_string()),\n                    types: Some(tags::TAG_SET_TEXT),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::EndOfFileFixer => BuiltinHook {\n                id: \"end-of-file-fixer\".to_string(),\n                name: \"fix end of files\".to_string(),\n                entry: \"end-of-file-fixer\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\n                        \"ensures that a file is either empty, or ends with one newline.\"\n                            .to_string(),\n                    ),\n                    types: Some(tags::TAG_SET_TEXT),\n                    stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::FixByteOrderMarker => BuiltinHook {\n                id: \"fix-byte-order-marker\".to_string(),\n                name: \"fix utf-8 byte order marker\".to_string(),\n                entry: \"fix-byte-order-marker\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"removes utf-8 byte order marker.\".to_string()),\n                    types: Some(tags::TAG_SET_TEXT),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::MixedLineEnding => BuiltinHook {\n                id: \"mixed-line-ending\".to_string(),\n                name: \"mixed line ending\".to_string(),\n                entry: \"mixed-line-ending\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"replaces or checks mixed line ending.\".to_string()),\n                    types: Some(tags::TAG_SET_TEXT),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::NoCommitToBranch => BuiltinHook {\n                id: \"no-commit-to-branch\".to_string(),\n                name: \"don't commit to branch\".to_string(),\n                entry: \"no-commit-to-branch\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    pass_filenames: Some(PassFilenames::None),\n                    always_run: Some(true),\n                    ..Default::default()\n                },\n            },\n            BuiltinHooks::TrailingWhitespace => BuiltinHook {\n                id: \"trailing-whitespace\".to_string(),\n                name: \"trim trailing whitespace\".to_string(),\n                entry: \"trailing-whitespace-fixer\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    description: Some(\"trims trailing whitespace.\".to_string()),\n                    types: Some(tags::TAG_SET_TEXT),\n                    stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()),\n                    ..Default::default()\n                },\n            },\n        })\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/meta_hooks.rs",
    "content": "use std::io::Write;\nuse std::path::Path;\nuse std::str::FromStr;\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::CONFIG_FILENAMES;\n\nuse crate::cli::reporter::HookRunReporter;\nuse crate::cli::run::{CollectOptions, FileFilter, collect_files};\nuse crate::config::{self, FilePattern, HookOptions, Language, MetaHook};\nuse crate::hook::Hook;\nuse crate::store::Store;\nuse crate::workspace::Project;\n\n// For builtin hooks (meta hooks and builtin pre-commit-hooks), they are not run\n// in the project root like other hooks. Instead, they run in the workspace root.\n// But the input filenames are all relative to the project root. So when accessing these files,\n// we need to adjust the paths by prepending the project relative path.\n// When matching files (files or exclude), we need to match against the filenames\n// relative to the project root.\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::AsRefStr, strum::Display, strum::EnumString)]\n#[cfg_attr(feature = \"schemars\", derive(schemars::JsonSchema))]\n#[cfg_attr(feature = \"schemars\", schemars(rename_all = \"kebab-case\"))]\n#[strum(serialize_all = \"kebab-case\")]\npub(crate) enum MetaHooks {\n    CheckHooksApply,\n    CheckUselessExcludes,\n    Identity,\n}\n\nimpl MetaHooks {\n    pub(crate) async fn run(\n        self,\n        store: &Store,\n        hook: &Hook,\n        filenames: &[&Path],\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n        let result = match self {\n            Self::CheckHooksApply => check_hooks_apply(store, hook, filenames).await,\n            Self::CheckUselessExcludes => check_useless_excludes(hook, filenames).await,\n            Self::Identity => Ok(identity(hook, filenames)),\n        };\n        reporter.on_run_complete(progress);\n        result\n    }\n}\n\nimpl MetaHook {\n    pub(crate) fn from_id(id: &str) -> Result<Self, ()> {\n        let hook_id = MetaHooks::from_str(id).map_err(|_| ())?;\n        let config_file_glob =\n            FilePattern::new_glob(CONFIG_FILENAMES.iter().map(ToString::to_string).collect())\n                .unwrap();\n\n        Ok(match hook_id {\n            MetaHooks::CheckHooksApply => MetaHook {\n                id: \"check-hooks-apply\".to_string(),\n                name: \"Check hooks apply\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    files: Some(config_file_glob.clone()),\n                    ..Default::default()\n                },\n            },\n            MetaHooks::CheckUselessExcludes => MetaHook {\n                id: \"check-useless-excludes\".to_string(),\n                name: \"Check useless excludes\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    files: Some(config_file_glob),\n                    ..Default::default()\n                },\n            },\n            MetaHooks::Identity => MetaHook {\n                id: \"identity\".to_string(),\n                name: \"identity\".to_string(),\n                priority: None,\n                options: HookOptions {\n                    verbose: Some(true),\n                    ..Default::default()\n                },\n            },\n        })\n    }\n}\n\n/// Ensures that the configured hooks apply to at least one file in the repository.\npub(crate) async fn check_hooks_apply(\n    store: &Store,\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>)> {\n    let relative_path = hook.project().relative_path();\n    // Collect all files in the project\n    let input = collect_files(hook.work_dir(), CollectOptions::all_files()).await?;\n    // Prepend the project relative path to each input file\n    let input: Vec<_> = input.into_iter().map(|f| relative_path.join(f)).collect();\n\n    let mut code = 0;\n    let mut output = Vec::new();\n\n    for filename in filenames {\n        let path = relative_path.join(filename);\n        let mut project = Project::from_config_file(path.into(), None)?;\n        project.with_relative_path(relative_path.to_path_buf());\n\n        let project_hooks = project\n            .init_hooks(store, None)\n            .await\n            .context(\"Failed to init hooks\")?;\n        let filter = FileFilter::for_project(input.iter(), &project, None);\n\n        for project_hook in project_hooks {\n            if project_hook.always_run || matches!(project_hook.language, Language::Fail) {\n                continue;\n            }\n\n            let filenames = filter.for_hook(&project_hook);\n\n            if filenames.is_empty() {\n                code = 1;\n                writeln!(\n                    &mut output,\n                    \"{} does not apply to this repository\",\n                    project_hook.id\n                )?;\n            }\n        }\n    }\n\n    Ok((code, output))\n}\n\n// Returns true if the exclude pattern matches any files matching the include pattern.\nfn excludes_any(\n    files: &[impl AsRef<Path>],\n    include: Option<&FilePattern>,\n    exclude: Option<&FilePattern>,\n) -> bool {\n    if exclude.is_none() {\n        return true;\n    }\n\n    files.iter().any(|f| {\n        let Some(f) = f.as_ref().to_str() else {\n            return false; // Skip files that cannot be converted to a string\n        };\n\n        if let Some(pattern) = &include {\n            if !pattern.is_match(f) {\n                return false;\n            }\n        }\n        if let Some(pattern) = &exclude {\n            if !pattern.is_match(f) {\n                return false;\n            }\n        }\n        true\n    })\n}\n\n/// Ensures that exclude directives apply to any file in the repository.\npub(crate) async fn check_useless_excludes(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>)> {\n    let relative_path = hook.project().relative_path();\n    // `collect_files` returns paths relative to the hook's project root.\n    // The meta hook itself runs from the workspace root, so we build both:\n    // - `input_project`: for matching `files`/`exclude` patterns (project-relative)\n    // - `input_workspace`: for `FileFilter` (workspace-relative)\n    let input_project = collect_files(hook.work_dir(), CollectOptions::all_files()).await?;\n    let input_workspace: Vec<_> = input_project\n        .iter()\n        .map(|f| relative_path.join(f))\n        .collect();\n\n    let mut code = 0;\n    let mut output = Vec::new();\n\n    for filename in filenames {\n        let path = relative_path.join(filename);\n        let mut project = Project::from_config_file(path.into(), None)?;\n        project.with_relative_path(relative_path.to_path_buf());\n\n        let config = project.config();\n        if !excludes_any(&input_project, None, config.exclude.as_ref()) {\n            code = 1;\n            let display = config\n                .exclude\n                .as_ref()\n                .map(ToString::to_string)\n                .unwrap_or_default();\n            writeln!(\n                &mut output,\n                \"The global exclude pattern `{display}` does not match any files\"\n            )?;\n        }\n\n        let filter = FileFilter::for_project(input_workspace.iter(), &project, None);\n\n        for repo in &config.repos {\n            let hooks_iter: Box<dyn Iterator<Item = (&String, &HookOptions)>> = match repo {\n                config::Repo::Remote(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),\n                config::Repo::Local(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),\n                config::Repo::Meta(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),\n                config::Repo::Builtin(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),\n            };\n\n            for (hook_id, opts) in hooks_iter {\n                let filtered_files = filter.by_type(\n                    opts.types.as_ref(),\n                    opts.types_or.as_ref(),\n                    opts.exclude_types.as_ref(),\n                );\n\n                // `filtered_files` is workspace-relative (it includes the project prefix).\n                // Match patterns against paths relative to the project root.\n                let filtered_files_relative: Vec<&Path> = if relative_path.as_os_str().is_empty() {\n                    filtered_files\n                } else {\n                    filtered_files\n                        .into_iter()\n                        .filter_map(|f| f.strip_prefix(relative_path).ok())\n                        .collect()\n                };\n\n                if !excludes_any(\n                    &filtered_files_relative,\n                    opts.files.as_ref(),\n                    opts.exclude.as_ref(),\n                ) {\n                    code = 1;\n                    let display = opts\n                        .exclude\n                        .as_ref()\n                        .map(ToString::to_string)\n                        .unwrap_or_default();\n                    writeln!(\n                        &mut output,\n                        \"The exclude pattern `{display}` for `{hook_id}` does not match any files\"\n                    )?;\n                }\n            }\n        }\n    }\n\n    Ok((code, output))\n}\n\n/// Prints all arguments passed to the hook. Useful for debugging.\npub fn identity(_hook: &Hook, filenames: &[&Path]) -> (i32, Vec<u8>) {\n    (\n        0,\n        filenames\n            .iter()\n            .map(|f| f.to_string_lossy())\n            .join(\"\\n\")\n            .into_bytes(),\n    )\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML};\n\n    fn regex_pattern(pattern: &str) -> FilePattern {\n        FilePattern::new_regex(pattern).unwrap()\n    }\n\n    #[test]\n    fn test_excludes_any() {\n        let files = vec![\n            Path::new(\"file1.txt\"),\n            Path::new(\"file2.txt\"),\n            Path::new(\"file3.txt\"),\n        ];\n        let include = regex_pattern(r\"file.*\");\n        let exclude = regex_pattern(r\"file2\\.txt\");\n        assert!(excludes_any(&files, Some(&include), Some(&exclude)));\n\n        let include = regex_pattern(r\"file.*\");\n        let exclude = regex_pattern(r\"file4\\.txt\");\n        assert!(!excludes_any(&files, Some(&include), Some(&exclude)));\n        assert!(excludes_any(&files, None, None));\n\n        let files = vec![Path::new(\"html/file1.html\"), Path::new(\"html/file2.html\")];\n        let exclude = regex_pattern(r\"^html/\");\n        assert!(excludes_any(&files, None, Some(&exclude)));\n    }\n\n    #[test]\n    fn meta_hook_patterns_cover_config_files() {\n        let apply = MetaHook::from_id(\"check-hooks-apply\").expect(\"known meta hook\");\n        let apply_files = apply.options.files.as_ref().expect(\"files should be set\");\n        assert!(apply_files.is_match(PRE_COMMIT_CONFIG_YAML));\n        assert!(apply_files.is_match(PRE_COMMIT_CONFIG_YML));\n        assert!(apply_files.is_match(PREK_TOML));\n\n        let useless = MetaHook::from_id(\"check-useless-excludes\").expect(\"known meta hook\");\n        let useless_files = useless.options.files.as_ref().expect(\"files should be set\");\n        assert!(useless_files.is_match(PRE_COMMIT_CONFIG_YAML));\n        assert!(useless_files.is_match(PRE_COMMIT_CONFIG_YML));\n        assert!(useless_files.is_match(PREK_TOML));\n\n        let identity = MetaHook::from_id(\"identity\").expect(\"known meta hook\");\n        assert!(identity.options.files.is_none());\n        assert_eq!(identity.options.verbose, Some(true));\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/mod.rs",
    "content": "use std::future::Future;\nuse std::path::Path;\nuse std::str::FromStr;\nuse std::sync::LazyLock;\n\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::cli::reporter::HookRunReporter;\nuse crate::hook::{Hook, Repo};\npub(crate) use crate::hooks::builtin_hooks::BuiltinHooks;\npub(crate) use crate::hooks::meta_hooks::MetaHooks;\nuse crate::hooks::pre_commit_hooks::{PreCommitHooks, is_pre_commit_hooks};\nuse crate::store::Store;\n\nmod builtin_hooks;\nmod meta_hooks;\nmod pre_commit_hooks;\n\nstatic NO_FAST_PATH: LazyLock<bool> = LazyLock::new(|| EnvVars::is_set(EnvVars::PREK_NO_FAST_PATH));\n\n/// Returns true if the hook has a builtin Rust implementation.\npub fn check_fast_path(hook: &Hook) -> bool {\n    if *NO_FAST_PATH {\n        return false;\n    }\n\n    match hook.repo() {\n        Repo::Remote { url, .. } if is_pre_commit_hooks(url) => {\n            let Ok(implemented) = PreCommitHooks::from_str(hook.id.as_str()) else {\n                return false;\n            };\n            implemented.check_supported(hook)\n        }\n        _ => false,\n    }\n}\n\npub async fn run_fast_path(\n    _store: &Store,\n    hook: &Hook,\n    filenames: &[&Path],\n    reporter: &HookRunReporter,\n) -> anyhow::Result<(i32, Vec<u8>)> {\n    let progress = reporter.on_run_start(hook, filenames.len());\n\n    let result = match hook.repo() {\n        Repo::Remote { url, .. } if is_pre_commit_hooks(url) => {\n            PreCommitHooks::from_str(hook.id.as_str())\n                .unwrap()\n                .run(hook, filenames)\n                .await\n        }\n        _ => unreachable!(),\n    };\n\n    reporter.on_run_complete(progress);\n\n    result\n}\n\npub(crate) async fn run_concurrent_file_checks<'a, I, F, Fut>(\n    filenames: I,\n    concurrency: usize,\n    check: F,\n) -> anyhow::Result<(i32, Vec<u8>)>\nwhere\n    I: IntoIterator<Item = &'a Path>,\n    F: Fn(&'a Path) -> Fut,\n    Fut: Future<Output = anyhow::Result<(i32, Vec<u8>)>>,\n{\n    use futures::StreamExt;\n\n    let mut tasks = futures::stream::iter(filenames)\n        .map(check)\n        .buffered(concurrency);\n\n    let mut code = 0;\n    let mut output = Vec::new();\n\n    while let Some(result) = tasks.next().await {\n        let (c, o) = result?;\n        code |= c;\n        output.extend(o);\n    }\n\n    Ok((code, output))\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_added_large_files.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse clap::Parser;\nuse rustc_hash::FxHashSet;\n\nuse crate::git::{get_added_files, get_lfs_files};\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\nenum FileFilter {\n    NoFilter,\n    Files(FxHashSet<PathBuf>),\n}\n\nimpl FileFilter {\n    fn contains(&self, path: &Path) -> bool {\n        match self {\n            FileFilter::NoFilter => true,\n            FileFilter::Files(files) => files.contains(path),\n        }\n    }\n}\n\n#[derive(Parser)]\n#[command(disable_help_subcommand = true)]\n#[command(disable_version_flag = true)]\n#[command(disable_help_flag = true)]\nstruct Args {\n    #[arg(long)]\n    enforce_all: bool,\n    #[arg(long = \"maxkb\", default_value = \"500\")]\n    max_kb: u64,\n}\n\npub(crate) async fn check_added_large_files(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> anyhow::Result<(i32, Vec<u8>)> {\n    let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?;\n\n    let filter = if args.enforce_all {\n        FileFilter::NoFilter\n    } else {\n        let add_files = get_added_files(hook.work_dir())\n            .await?\n            .into_iter()\n            .collect::<FxHashSet<_>>();\n        FileFilter::Files(add_files)\n    };\n\n    let lfs_files = get_lfs_files(filenames).await?;\n\n    let filenames = filenames\n        .iter()\n        .copied()\n        .filter(|f| filter.contains(f))\n        .filter(|f| !lfs_files.contains(*f));\n\n    run_concurrent_file_checks(filenames, *CONCURRENCY, |filename| async move {\n        let file_path = hook.project().relative_path().join(filename);\n        let size = fs_err::tokio::metadata(file_path).await?.len() / 1024;\n        if size > args.max_kb {\n            anyhow::Ok((\n                1,\n                format!(\n                    \"{} ({size} KB) exceeds {} KB\\n\",\n                    filename.display(),\n                    args.max_kb\n                )\n                .into_bytes(),\n            ))\n        } else {\n            anyhow::Ok((0, Vec::new()))\n        }\n    })\n    .await\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_case_conflict.rs",
    "content": "use std::collections::hash_map::Entry;\nuse std::path::Path;\n\nuse anyhow::Result;\nuse rustc_hash::FxHashMap;\nuse rustc_hash::FxHashSet;\n\nuse crate::git;\nuse crate::hook::Hook;\n\npub(crate) async fn check_case_conflict(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>)> {\n    let work_dir = hook.work_dir();\n\n    // Get all files in the repo.\n    let repo_files = git::ls_files(work_dir, Path::new(\".\")).await?;\n    let mut repo_files_with_dirs: FxHashSet<&Path> = FxHashSet::default();\n    for path in &repo_files {\n        insert_path_and_parents(&mut repo_files_with_dirs, path);\n    }\n\n    // Get relevant files (filenames + added files) and include their parent directories.\n    let added = git::get_added_files(work_dir).await?;\n    let mut relevant_files_with_dirs: FxHashSet<&Path> = FxHashSet::default();\n    for filename in filenames {\n        insert_path_and_parents(&mut relevant_files_with_dirs, filename);\n    }\n    for path in &added {\n        insert_path_and_parents(&mut relevant_files_with_dirs, path);\n    }\n\n    // Remove relevant files from repo files (avoid self-conflicts).\n    for file in &relevant_files_with_dirs {\n        repo_files_with_dirs.remove(file);\n    }\n\n    // Compute conflicts:\n    // 1) relevant vs repo (case-insensitive intersection)\n    // 2) relevant vs relevant (case-insensitive duplicates)\n    let mut repo_lower: FxHashSet<String> = FxHashSet::default();\n    repo_lower.reserve(repo_files_with_dirs.len());\n    for path in &repo_files_with_dirs {\n        repo_lower.insert(lower_key(path));\n    }\n\n    let mut conflicts: FxHashSet<String> = FxHashSet::default();\n    let mut relevant_lower_counts: FxHashMap<String, u8> = FxHashMap::default();\n    relevant_lower_counts.reserve(relevant_files_with_dirs.len());\n\n    for path in &relevant_files_with_dirs {\n        let lower = lower_key(path);\n\n        if repo_lower.contains(&lower) {\n            conflicts.insert(lower.clone());\n        }\n\n        match relevant_lower_counts.entry(lower) {\n            Entry::Vacant(entry) => {\n                entry.insert(1);\n            }\n            Entry::Occupied(mut entry) => {\n                let count = entry.get_mut();\n                *count = count.saturating_add(1);\n                if *count == 2 {\n                    // Only mark the conflict on the *first* duplicate to avoid repeated\n                    // cloning/inserting for the 3rd+ occurrences of the same lowercase key.\n                    conflicts.insert(entry.key().clone());\n                }\n            }\n        }\n    }\n\n    let mut output = Vec::new();\n    if conflicts.is_empty() {\n        return Ok((0, output));\n    }\n\n    // The sets are disjoint at this point (relevant removed from repo), so we can just chain.\n    let mut conflicting_files: Vec<_> = repo_files_with_dirs\n        .iter()\n        .chain(relevant_files_with_dirs.iter())\n        .filter(|path| conflicts.contains(&lower_key(path)))\n        .collect();\n    conflicting_files.sort();\n\n    for filename in conflicting_files {\n        let line = format!(\n            \"Case-insensitivity conflict found: {}\\n\",\n            filename.display()\n        );\n        output.extend(line.into_bytes());\n    }\n\n    Ok((1, output))\n}\n\nfn insert_path_and_parents<'p>(set: &mut FxHashSet<&'p Path>, file: &'p Path) {\n    set.insert(file);\n\n    let mut current = file;\n    while let Some(parent) = current.parent() {\n        if parent.as_os_str().is_empty() {\n            break;\n        }\n        set.insert(parent);\n        current = parent;\n    }\n}\n\nfn lower_key(path: &Path) -> String {\n    path.to_string_lossy().to_lowercase()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_insert_path_and_parents() {\n        let mut set: FxHashSet<&Path> = FxHashSet::default();\n        insert_path_and_parents(&mut set, Path::new(\"foo/bar/baz.txt\"));\n        assert!(set.contains(Path::new(\"foo/bar/baz.txt\")));\n        assert!(set.contains(Path::new(\"foo/bar\")));\n        assert!(set.contains(Path::new(\"foo\")));\n        assert_eq!(set.len(), 3);\n\n        let mut set: FxHashSet<&Path> = FxHashSet::default();\n        insert_path_and_parents(&mut set, Path::new(\"single.txt\"));\n        assert!(set.contains(Path::new(\"single.txt\")));\n        assert_eq!(set.len(), 1);\n    }\n\n    #[test]\n    fn test_insert_path_and_parents_nested() {\n        let mut set: FxHashSet<&Path> = FxHashSet::default();\n        insert_path_and_parents(&mut set, Path::new(\"a/b/c/d/e/f.txt\"));\n        for expected in [\n            \"a/b/c/d/e/f.txt\",\n            \"a/b/c/d/e\",\n            \"a/b/c/d\",\n            \"a/b/c\",\n            \"a/b\",\n            \"a\",\n        ] {\n            assert!(set.contains(Path::new(expected)));\n        }\n    }\n\n    #[test]\n    fn test_insert_path_and_parents_no_slash() {\n        let mut set: FxHashSet<&Path> = FxHashSet::default();\n        insert_path_and_parents(&mut set, Path::new(\"file.txt\"));\n        assert_eq!(set.len(), 1);\n    }\n\n    #[test]\n    fn test_lower_key() {\n        assert_eq!(lower_key(Path::new(\"Foo.txt\")), \"foo.txt\");\n        assert_eq!(lower_key(Path::new(\"BAR.txt\")), \"bar.txt\");\n        assert_eq!(lower_key(Path::new(\"baz.TXT\")), \"baz.txt\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs",
    "content": "use std::path::Path;\n\nuse futures::StreamExt;\nuse owo_colors::OwoColorize;\nuse rustc_hash::FxHashSet;\nuse tokio::io::AsyncReadExt;\n\nuse crate::git;\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\npub(crate) async fn check_executables_have_shebangs(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>), anyhow::Error> {\n    let stdout = git::git_cmd(\"get file file mode\")?\n        .arg(\"config\")\n        .arg(\"core.fileMode\")\n        .check(true)\n        .output()\n        .await?\n        .stdout;\n\n    let tracks_executable_bit = std::str::from_utf8(&stdout)?.trim() != \"false\";\n    let file_base = hook.project().relative_path();\n\n    let (code, output) = if tracks_executable_bit {\n        // core.fileMode=true means the platform honors the executable bit, so trust the FS metadata.\n        // The `executables-have-shebangs` hook already restricts inputs to executable text files (`types: [text, executable]`).\n        os_check_shebangs(file_base, filenames).await?\n    } else {\n        // If on win32 use git to check executable bit\n        git_check_shebangs(file_base, filenames).await?\n    };\n\n    Ok((code, output))\n}\n\nasync fn os_check_shebangs(\n    file_base: &Path,\n    paths: &[&Path],\n) -> Result<(i32, Vec<u8>), anyhow::Error> {\n    run_concurrent_file_checks(paths.iter().copied(), *CONCURRENCY, |file| async move {\n        let file_path = file_base.join(file);\n        let has_shebang = file_has_shebang(&file_path).await?;\n        if has_shebang {\n            anyhow::Ok((0, Vec::new()))\n        } else {\n            let msg = print_shebang_warning(file);\n            Ok((1, msg.into_bytes()))\n        }\n    })\n    .await\n}\n\nfn print_shebang_warning(path: &Path) -> String {\n    let path_str = path.display();\n\n    format!(\n        \"{}\\n\\\n         {}\\n\\\n         {}\\n\\\n         {}\\n\",\n        format!(\n            \"{} marked executable but has no (or invalid) shebang!\",\n            path_str.yellow()\n        )\n        .bold(),\n        format!(\"  If it isn't supposed to be executable, try: 'chmod -x {path_str}'\").dimmed(),\n        format!(\"  If on Windows, you may also need to: 'git add --chmod=-x {path_str}'\").dimmed(),\n        \"  If it is supposed to be executable, double-check its shebang.\".dimmed(),\n    )\n}\n\nasync fn git_check_shebangs(\n    file_base: &Path,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>), anyhow::Error> {\n    let filenames: FxHashSet<_> = filenames.iter().collect();\n\n    let output = git::git_cmd(\"git ls-files\")?\n        .arg(\"ls-files\")\n        // Show staged contents' mode bits, object name and stage number in the output.\n        .arg(\"--stage\")\n        .arg(\"-z\")\n        .arg(\"--\")\n        .arg(if file_base.as_os_str().is_empty() {\n            Path::new(\".\")\n        } else {\n            file_base\n        })\n        .check(true)\n        .output()\n        .await?;\n\n    let entries = output.stdout.split(|&b| b == b'\\0').filter_map(|entry| {\n        let entry = str::from_utf8(entry).ok()?;\n        if entry.is_empty() {\n            return None;\n        }\n\n        let mut parts = entry.split('\\t');\n        let metadata = parts.next()?;\n        let file_name = parts.next()?;\n        let file_name = Path::new(file_name);\n        if !filenames.contains(&file_name) {\n            return None;\n        }\n\n        let mode_str = metadata.split_whitespace().next()?;\n        let mode_bits = u32::from_str_radix(mode_str, 8).ok()?;\n        let is_executable = (mode_bits & 0o111) != 0;\n        Some((file_name, is_executable))\n    });\n\n    let mut tasks = futures::stream::iter(entries)\n        .map(async |(file_name, is_executable)| {\n            if is_executable {\n                let has_shebang = file_has_shebang(file_name).await?;\n                if has_shebang {\n                    anyhow::Ok((0, Vec::new()))\n                } else {\n                    let stripped = file_name.strip_prefix(file_base).unwrap_or(file_name);\n                    let msg = print_shebang_warning(stripped);\n                    Ok((1, msg.into_bytes()))\n                }\n            } else {\n                Ok((0, Vec::new()))\n            }\n        })\n        .buffered(*CONCURRENCY);\n\n    let mut code = 0;\n    let mut output = Vec::new();\n\n    while let Some(result) = tasks.next().await {\n        let (c, o) = result?;\n        code |= c;\n        output.extend(o);\n    }\n\n    Ok((code, output))\n}\n\n/// Check first 2 bytes for shebang (#!)\nasync fn file_has_shebang(path: &Path) -> Result<bool, anyhow::Error> {\n    let mut file = fs_err::tokio::File::open(path).await?;\n    let mut buf = [0u8; 2];\n    let n = file.read(&mut buf).await?;\n    Ok(n >= 2 && buf[0] == b'#' && buf[1] == b'!')\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::NamedTempFile;\n\n    #[tokio::test]\n    async fn test_file_with_shebang() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"#!/bin/bash\\necho Hello World\\n\").await?;\n\n        assert!(file_has_shebang(file.path()).await?);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_without_shebang() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"echo Hello World\\n\").await?;\n\n        assert!(!file_has_shebang(file.path()).await?);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_file() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"\").await?;\n\n        assert!(!file_has_shebang(file.path()).await?);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_with_partial_shebang() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"#\\n\").await?;\n        assert!(!file_has_shebang(file.path()).await?);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_with_shebang_and_spaces() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"#! /bin/bash\\necho Test\\n\").await?;\n        assert!(file_has_shebang(file.path()).await?);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_with_non_shebang_start() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"##!/bin/bash\\n\").await?;\n        assert!(!file_has_shebang(file.path()).await?);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_os_check_shebangs_with_shebang() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"#!/bin/bash\\necho ok\\n\").await?;\n        let files = vec![file.path()];\n        let (code, output) = os_check_shebangs(Path::new(\"\"), &files).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_os_check_shebangs_without_shebang() -> Result<(), anyhow::Error> {\n        let file = NamedTempFile::new()?;\n        tokio::fs::write(file.path(), b\"echo ok\\n\").await?;\n        let files = vec![file.path()];\n        let (code, output) = os_check_shebangs(Path::new(\"\"), &files).await?;\n        assert_eq!(code, 1);\n        assert!(\n            String::from_utf8_lossy(&output)\n                .contains(\"marked executable but has no (or invalid) shebang!\")\n        );\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_json.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse rustc_hash::FxHashMap;\nuse serde::{Deserialize, Deserializer};\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\n#[derive(Debug)]\npub(crate) enum JsonValue {\n    Object(FxHashMap<String, JsonValue>),\n    Array(Vec<JsonValue>),\n    String(String),\n    Number(serde_json::Number),\n    Bool(bool),\n    Null,\n}\n\npub(crate) async fn check_json(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let file_path = file_base.join(filename);\n    let content = fs_err::tokio::read(file_path).await?;\n    if content.is_empty() {\n        return Ok((0, Vec::new()));\n    }\n\n    let mut deserializer = serde_json::Deserializer::from_slice(&content);\n    deserializer.disable_recursion_limit();\n    let deserializer = serde_stacker::Deserializer::new(&mut deserializer);\n\n    // Try to parse with duplicate key detection\n    match JsonValue::deserialize(deserializer) {\n        Ok(json) => {\n            carefully_drop_nested_json(json);\n            Ok((0, Vec::new()))\n        }\n        Err(e) => {\n            let error_message = format!(\"{}: Failed to json decode ({e})\\n\", filename.display());\n            Ok((1, error_message.into_bytes()))\n        }\n    }\n}\n\n// For deeply nested JSON structures, `Drop` can cause stack overflow.\nfn carefully_drop_nested_json(value: JsonValue) {\n    let mut stack = vec![value];\n    let mut map = FxHashMap::default();\n    while let Some(value) = stack.pop() {\n        match value {\n            JsonValue::Array(array) => stack.extend(array),\n            JsonValue::Object(object) => map.extend(object),\n            _ => {}\n        }\n    }\n}\n\nimpl<'de> Deserialize<'de> for JsonValue {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        use serde::de::{self, MapAccess, SeqAccess, Visitor};\n        use std::fmt;\n\n        struct JsonValueVisitor;\n\n        impl<'de> Visitor<'de> for JsonValueVisitor {\n            type Value = JsonValue;\n\n            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {\n                formatter.write_str(\"a JSON value\")\n            }\n\n            fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E> {\n                Ok(JsonValue::Bool(v))\n            }\n\n            fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E> {\n                Ok(JsonValue::Number(v.into()))\n            }\n\n            fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> {\n                Ok(JsonValue::Number(v.into()))\n            }\n\n            fn visit_f64<E>(self, v: f64) -> Result<Self::Value, E> {\n                Ok(JsonValue::Number(serde_json::Number::from_f64(v).unwrap()))\n            }\n\n            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> {\n                Ok(JsonValue::String(v.to_string()))\n            }\n\n            fn visit_string<E>(self, v: String) -> Result<Self::Value, E> {\n                Ok(JsonValue::String(v))\n            }\n\n            fn visit_unit<E>(self) -> Result<Self::Value, E> {\n                Ok(JsonValue::Null)\n            }\n\n            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>\n            where\n                A: SeqAccess<'de>,\n            {\n                let mut vec = Vec::new();\n                while let Some(element) = seq.next_element()? {\n                    vec.push(element);\n                }\n                Ok(JsonValue::Array(vec))\n            }\n\n            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>\n            where\n                A: MapAccess<'de>,\n            {\n                let mut object = FxHashMap::default();\n                while let Some(key) = map.next_key::<String>()? {\n                    if object.contains_key(&key) {\n                        return Err(de::Error::custom(format!(\"duplicate key `{key}`\")));\n                    }\n                    let value = map.next_value()?;\n                    object.insert(key, value);\n                }\n                Ok(JsonValue::Object(object))\n            }\n        }\n\n        deserializer.deserialize_any(JsonValueVisitor)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::{Path, PathBuf};\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_valid_json() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"{\"key1\": \"value1\", \"key2\": \"value2\"}\"#;\n        let file_path = create_test_file(&dir, \"valid.json\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_json() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"{\"key1\": \"value1\", \"key2\": \"value2\"\"#;\n        let file_path = create_test_file(&dir, \"invalid.json\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_duplicate_keys() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"{\"key1\": \"value1\", \"key1\": \"value2\"}\"#;\n        let file_path = create_test_file(&dir, \"duplicate.json\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_json() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.json\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_valid_json_array() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"[{\"key1\": \"value1\"}, {\"key2\": \"value2\"}]\"#;\n        let file_path = create_test_file(&dir, \"valid_array.json\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_duplicate_keys_in_nested_object() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"{\"key1\": \"value1\", \"key2\": {\"nested_key\": 1, \"nested_key\": 2}}\"#;\n        let file_path = create_test_file(&dir, \"nested_duplicate.json\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_recursion_limit() -> Result<()> {\n        let dir = tempdir()?;\n\n        let mut json = String::new();\n        for _ in 0..10000 {\n            json = format!(\"[{json}]\");\n        }\n\n        let file_path = create_test_file(&dir, \"deeply_nested.json\", json.as_bytes()).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_merge_conflict.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse clap::Parser;\nuse tokio::io::AsyncBufReadExt;\n\nuse crate::git::get_git_dir;\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\nconst CONFLICT_PATTERNS: &[&[u8]] = &[\n    b\"<<<<<<< \",\n    b\"======= \",\n    b\"=======\\r\\n\",\n    b\"=======\\n\",\n    b\">>>>>>> \",\n];\n\n#[derive(Parser)]\n#[command(disable_help_subcommand = true)]\n#[command(disable_version_flag = true)]\n#[command(disable_help_flag = true)]\nstruct Args {\n    #[arg(long)]\n    assume_in_merge: bool,\n}\n\npub(crate) async fn check_merge_conflict(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>)> {\n    let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?;\n\n    // Check if we're in a merge state or assuming merge\n    if !args.assume_in_merge && !is_in_merge().await? {\n        return Ok((0, Vec::new()));\n    }\n\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn is_in_merge() -> Result<bool> {\n    // Change directory temporarily or ensure we're in the right directory\n    let git_dir = get_git_dir().await?;\n\n    // Check if MERGE_MSG exists\n    let merge_msg_exists = git_dir.join(\"MERGE_MSG\").exists();\n    if !merge_msg_exists {\n        return Ok(false);\n    }\n\n    // Check if any of the merge state files exist\n    Ok(git_dir.join(\"MERGE_HEAD\").exists()\n        || git_dir.join(\"rebase-apply\").exists()\n        || git_dir.join(\"rebase-merge\").exists())\n}\n\nasync fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let file_path = file_base.join(filename);\n    let file = fs_err::tokio::File::open(&file_path).await?;\n    let mut reader = tokio::io::BufReader::new(file);\n\n    let mut code = 0;\n    let mut output = Vec::new();\n    let mut line = Vec::new();\n    let mut line_number = 1;\n\n    while reader.read_until(b'\\n', &mut line).await? != 0 {\n        // Check all patterns\n        for pattern in CONFLICT_PATTERNS {\n            if line.starts_with(pattern) {\n                // Don't trim the pattern - display it as-is (minus any line endings)\n                let pattern_display = if pattern.ends_with(b\"\\r\\n\") {\n                    &pattern[..pattern.len() - 2]\n                } else if pattern.ends_with(b\"\\n\") {\n                    &pattern[..pattern.len() - 1]\n                } else {\n                    pattern\n                };\n                let pattern_str = str::from_utf8(pattern_display)\n                    .expect(\"conflict pattern should be valid UTF-8\");\n                let error_message = format!(\n                    \"{}:{line_number}: Merge conflict string {pattern_str:?} found\\n\",\n                    filename.display(),\n                );\n                output.extend(error_message.into_bytes());\n                code = 1;\n                break; // Only report one pattern per line\n            }\n        }\n\n        line.clear();\n        line_number += 1;\n    }\n\n    Ok((code, output))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_no_conflict_markers() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"This is a normal file\\nWith no conflict markers\\n\";\n        let file_path = create_test_file(&dir, \"clean.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_conflict_marker_start() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Some content\\n<<<<<<< HEAD\\nConflicting line\\n\";\n        let file_path = create_test_file(&dir, \"conflict.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"<<<<<<< \"));\n        assert!(output_str.contains(\"conflict.txt:2\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_conflict_marker_middle() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Some content\\n======= \\nConflicting line\\n\";\n        let file_path = create_test_file(&dir, \"conflict.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"======= \"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_conflict_marker_end() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Some content\\n>>>>>>> branch\\nMore content\\n\";\n        let file_path = create_test_file(&dir, \"conflict.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\">>>>>>> \"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_full_conflict_block() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Before conflict\\n<<<<<<< HEAD\\nOur changes\\n=======\\nTheir changes\\n>>>>>>> branch\\nAfter conflict\\n\";\n        let file_path = create_test_file(&dir, \"conflict.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        // Should find all three markers\n        assert!(output_str.contains(\"<<<<<<< \"));\n        assert!(output_str.contains(\"=======\"));\n        assert!(output_str.contains(\">>>>>>> \"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_conflict_marker_not_at_start() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Some content <<<<<<< HEAD\\n\";\n        let file_path = create_test_file(&dir, \"no_conflict.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        // Should not detect conflict since marker is not at line start\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_conflict_marker_crlf() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Some content\\r\\n=======\\r\\nConflicting line\\r\\n\";\n        let file_path = create_test_file(&dir, \"conflict_crlf.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_conflict_marker_lf() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Some content\\n=======\\nConflicting line\\n\";\n        let file_path = create_test_file(&dir, \"conflict_lf.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_file() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_multiple_conflicts() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"<<<<<<< HEAD\\nFirst\\n=======\\nSecond\\n>>>>>>> branch\\nMiddle\\n<<<<<<< HEAD\\nThird\\n=======\\nFourth\\n>>>>>>> other\\n\";\n        let file_path = create_test_file(&dir, \"multiple.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        // Should find all markers from both conflicts (one per line with marker)\n        let marker_count = output_str.matches(\"Merge conflict string\").count();\n        assert_eq!(marker_count, 6); // 3 markers per conflict * 2 conflicts\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_binary_file_with_conflict() -> Result<()> {\n        let dir = tempdir()?;\n        let mut content = vec![0xFF, 0xFE, 0xFD];\n        content.extend_from_slice(b\"\\n<<<<<<< HEAD\\n\");\n        let file_path = create_test_file(&dir, \"binary.bin\", &content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_symlinks.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\npub(crate) async fn check_symlinks(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\n#[allow(clippy::unused_async)]\nasync fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let path = file_base.join(filename);\n\n    // Check if it's a symlink and if it's broken\n    if path.is_symlink() && !path.exists() {\n        let error_message = format!(\"{}: Broken symlink\\n\", filename.display());\n        return Ok((1, error_message.into_bytes()));\n    }\n\n    Ok((0, Vec::new()))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_regular_file() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"regular file content\";\n        let file_path = create_test_file(&dir, \"regular.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    #[cfg(unix)]\n    async fn test_valid_symlink_unix() -> Result<()> {\n        let dir = tempdir()?;\n        let target = create_test_file(&dir, \"target.txt\", b\"content\").await?;\n        let link_path = dir.path().join(\"link.txt\");\n        tokio::fs::symlink(&target, &link_path).await?;\n\n        let (code, output) = check_file(Path::new(\"\"), &link_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    #[cfg(unix)]\n    async fn test_broken_symlink_unix() -> Result<()> {\n        let dir = tempdir()?;\n        let link_path = dir.path().join(\"broken_link.txt\");\n        let nonexistent = dir.path().join(\"nonexistent.txt\");\n        tokio::fs::symlink(&nonexistent, &link_path).await?;\n\n        let (code, output) = check_file(Path::new(\"\"), &link_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"Broken symlink\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    #[cfg(windows)]\n    async fn test_valid_symlink_windows() -> Result<()> {\n        let dir = tempdir()?;\n        let target = create_test_file(&dir, \"target.txt\", b\"content\").await?;\n        let link_path = dir.path().join(\"link.txt\");\n\n        // Windows requires different APIs for file vs directory symlinks\n        if tokio::fs::symlink_file(&target, &link_path).await.is_err() {\n            // Skipping test: insufficient permissions for symlink creation on Windows\n            return Ok(());\n        }\n\n        let (code, output) = check_file(Path::new(\"\"), &link_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    #[cfg(windows)]\n    async fn test_broken_symlink_windows() -> Result<()> {\n        let dir = tempdir()?;\n        let link_path = dir.path().join(\"broken_link.txt\");\n        let nonexistent = dir.path().join(\"nonexistent.txt\");\n\n        // On Windows, symlink creation might require admin privileges\n        // If this fails in CI, the test will be skipped\n        if tokio::fs::symlink_file(&nonexistent, &link_path)\n            .await\n            .is_err()\n        {\n            // Skipping test: insufficient permissions for symlink creation on Windows\n            return Ok(());\n        }\n\n        let (code, output) = check_file(Path::new(\"\"), &link_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"Broken symlink\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    #[cfg(target_os = \"macos\")]\n    async fn test_valid_symlink_macos() -> Result<()> {\n        let dir = tempdir()?;\n        let target = create_test_file(&dir, \"target.txt\", b\"content\").await?;\n        let link_path = dir.path().join(\"link.txt\");\n        tokio::fs::symlink(&target, &link_path).await?;\n\n        let (code, output) = check_file(Path::new(\"\"), &link_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    #[cfg(target_os = \"macos\")]\n    async fn test_broken_symlink_macos() -> Result<()> {\n        let dir = tempdir()?;\n        let link_path = dir.path().join(\"broken_link.txt\");\n        let nonexistent = dir.path().join(\"nonexistent.txt\");\n        tokio::fs::symlink(&nonexistent, &link_path).await?;\n\n        let (code, output) = check_file(Path::new(\"\"), &link_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"Broken symlink\"));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_toml.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\npub(crate) async fn check_toml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let content = fs_err::tokio::read(file_base.join(filename)).await?;\n    if content.is_empty() {\n        return Ok((0, Vec::new()));\n    }\n\n    // Use string content for borrowed parsing\n    let content_str = match std::str::from_utf8(&content) {\n        Ok(s) => s,\n        Err(e) => {\n            let error_message = format!(\"{}: Failed to decode UTF-8 ({e})\\n\", filename.display());\n            return Ok((1, error_message.into_bytes()));\n        }\n    };\n\n    // Use DeTable::parse_recoverable to report all parse errors at once\n    let (_parsed, errors) = toml::de::DeTable::parse_recoverable(content_str);\n    if errors.is_empty() {\n        Ok((0, Vec::new()))\n    } else {\n        let mut error_messages = Vec::new();\n        for error in errors {\n            error_messages.push(format!(\n                \"{}: Failed to toml decode ({error})\",\n                filename.display()\n            ));\n        }\n        let combined_errors = error_messages.join(\"\\n\") + \"\\n\";\n        Ok((1, combined_errors.into_bytes()))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_valid_toml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"key1 = \"value1\"\nkey2 = \"value2\"\n\"#;\n        let file_path = create_test_file(&dir, \"valid.toml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_toml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"key1 = \"value1\nkey2 = \"value2\"\n\"#;\n        let file_path = create_test_file(&dir, \"invalid.toml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_duplicate_keys() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"key1 = \"value1\"\nkey1 = \"value2\"\n\"#;\n        let file_path = create_test_file(&dir, \"duplicate.toml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_toml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.toml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_multiple_errors_reported() -> Result<()> {\n        let dir = tempdir()?;\n        // TOML with multiple syntax errors\n        let content = br#\"key1 = \"unclosed string\nkey2 = \"value2\"\nkey3 = invalid_value_without_quotes\n[section\nkey4 = \"another unclosed string\n\"#;\n        let file_path = create_test_file(&dir, \"multiple_errors.toml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n\n        // Should contain multiple error messages (one for each error found)\n        let error_count = output_str.matches(\"Failed to toml decode\").count();\n        assert!(error_count == 3, \"Expected three errors, got: {output_str}\");\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_utf8() -> Result<()> {\n        let dir = tempdir()?;\n        // Create content with invalid UTF-8 bytes\n        let content = b\"key1 = \\\"\\xff\\xfe\\xfd\\\"\\nkey2 = \\\"valid\\\"\";\n        let file_path = create_test_file(&dir, \"invalid_utf8.toml\", content).await?;\n\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"Failed to decode UTF-8\"));\n        assert!(output_str.contains(\"invalid_utf8.toml\"));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_xml.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\npub(crate) async fn check_xml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let content = fs_err::tokio::read(file_base.join(filename)).await?;\n\n    // Empty XML is invalid - should have at least one element\n    if content.is_empty() {\n        let error_message = format!(\n            \"{}: Failed to xml parse (no element found)\\n\",\n            filename.display()\n        );\n        return Ok((1, error_message.into_bytes()));\n    }\n\n    let mut reader = quick_xml::Reader::from_reader(&content[..]);\n    reader.config_mut().check_end_names = true;\n    reader.config_mut().expand_empty_elements = true;\n\n    let mut buf = Vec::new();\n    let mut root_count = 0;\n    let mut depth = 0;\n\n    loop {\n        match reader.read_event_into(&mut buf) {\n            Ok(quick_xml::events::Event::Eof) => break,\n            Ok(quick_xml::events::Event::Start(_)) => {\n                if depth == 0 {\n                    root_count += 1;\n                    if root_count > 1 {\n                        let error_message = format!(\n                            \"{}: Failed to xml parse (junk after document element)\\n\",\n                            filename.display()\n                        );\n                        return Ok((1, error_message.into_bytes()));\n                    }\n                }\n                depth += 1;\n            }\n            Ok(quick_xml::events::Event::End(_)) => {\n                depth -= 1;\n            }\n            Err(e) => {\n                let error_message = format!(\"{}: Failed to xml parse ({e})\\n\", filename.display());\n                return Ok((1, error_message.into_bytes()));\n            }\n            Ok(_) => {}\n        }\n        buf.clear();\n    }\n\n    Ok((0, Vec::new()))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_valid_xml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</element>\n</root>\"#;\n        let file_path = create_test_file(&dir, \"valid.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_xml_unclosed_tag() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value\n</root>\"#;\n        let file_path = create_test_file(&dir, \"invalid.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"Failed to xml parse\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_xml_mismatched_tags() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</different>\n</root>\"#;\n        let file_path = create_test_file(&dir, \"mismatched.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_xml_syntax_error() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element attribute=\"unclosed value>text</element>\n</root>\"#;\n        let file_path = create_test_file(&dir, \"syntax_error.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_xml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1); // Changed from 0 to 1\n        assert!(!output.is_empty()); // Changed from is_empty() to !is_empty()\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"no element found\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_valid_xml_with_attributes() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root xmlns=\"http://example.com\">\n    <element id=\"1\" type=\"test\">value</element>\n    <element id=\"2\">another value</element>\n</root>\"#;\n        let file_path = create_test_file(&dir, \"attributes.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_valid_xml_with_cdata() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element><![CDATA[Some <special> characters & symbols]]></element>\n</root>\"#;\n        let file_path = create_test_file(&dir, \"cdata.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_valid_xml_with_comments() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <!-- This is a comment -->\n    <element>value</element>\n    <!-- Another comment -->\n</root>\"#;\n        let file_path = create_test_file(&dir, \"comments.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_xml_with_doctype() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE root SYSTEM \"root.dtd\">\n<root>\n    <element>value</element>\n</root>\"#;\n        let file_path = create_test_file(&dir, \"doctype.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_xml_no_root() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<element>value</element>\n<another>value</another>\"#;\n        let file_path = create_test_file(&dir, \"no_root.xml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/check_yaml.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse clap::Parser;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\n#[derive(Parser)]\n#[command(disable_help_subcommand = true)]\n#[command(disable_version_flag = true)]\n#[command(disable_help_flag = true)]\nstruct Args {\n    #[arg(long, short = 'm', alias = \"multi\")]\n    allow_multiple_documents: bool,\n    // `--unsafe` flag is not supported yet.\n    // #[arg(long)]\n    // r#unsafe: bool,\n}\n\npub(crate) async fn check_yaml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?;\n\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(\n            hook.project().relative_path(),\n            filename,\n            args.allow_multiple_documents,\n        )\n    })\n    .await\n}\n\nasync fn check_file(\n    file_base: &Path,\n    filename: &Path,\n    allow_multi_docs: bool,\n) -> Result<(i32, Vec<u8>)> {\n    let content = fs_err::tokio::read(file_base.join(filename)).await?;\n    if content.is_empty() {\n        return Ok((0, Vec::new()));\n    }\n\n    let options = serde_saphyr::Options {\n        budget: Some(serde_saphyr::Budget {\n            // `check-yaml` is a syntax/structure validator, not a service parsing\n            // untrusted YAML at runtime. Keep the absolute caps, but allow\n            // high-reuse anchors that are common in compose-style files.\n            enforce_alias_anchor_ratio: false,\n            ..Default::default()\n        }),\n        ignore_binary_tag_for_string: true,\n        ..Default::default()\n    };\n    if allow_multi_docs {\n        if let Err(e) = serde_saphyr::from_slice_multiple_with_options::<serde_json::Value>(\n            &content,\n            options.clone(),\n        ) {\n            let error_message = format!(\"{}: Failed to yaml decode ({e})\\n\", filename.display());\n            return Ok((1, error_message.into_bytes()));\n        }\n        Ok((0, Vec::new()))\n    } else {\n        match serde_saphyr::from_slice_with_options::<serde_json::Value>(&content, options) {\n            Ok(_) => Ok((0, Vec::new())),\n            Err(e) => {\n                let err = e.render_with_formatter(&serde_saphyr::UserMessageFormatter);\n                let error_message =\n                    format!(\"{}: Failed to yaml decode ({err})\\n\", filename.display());\n                Ok((1, error_message.into_bytes()))\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::fmt::Write;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_valid_yaml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br\"key1: value1\nkey2: value2\n\";\n        let file_path = create_test_file(&dir, \"valid.yaml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_yaml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br\"key1: value1\nkey2: value2: another_value\n\";\n        let file_path = create_test_file(&dir, \"invalid.yaml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_duplicate_keys() -> Result<()> {\n        let dir = tempdir()?;\n        let content = br\"key1: value1\nkey1: value2\n\";\n        let file_path = create_test_file(&dir, \"duplicate.yaml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_yaml() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.yaml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_multiple_documents() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\\\n---\nkey1: value1\n---\nkey2: value2\n\";\n        let file_path = create_test_file(&dir, \"multi.yaml\", content).await?;\n\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 1);\n        assert!(!output.is_empty());\n\n        let (code, output) = check_file(Path::new(\"\"), &file_path, true).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_yaml_with_binary_scalar() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\\\nresponse:\n  body:\n    string: !!binary |\n      H4sIAAAAAAAAA4xTPW/bMBDd9SsON9uFJaeJ4y0oujRIEXQpisiQaOokM6VIgjzFSQ3/94KSYzmt\n      A2TRwPfBd/eoXQKAqsIloNwIlq3T0y/rF6JfbXYT2m3rvan+NLfXt/zj2/f5NsVJVNj1I0l+VX2S\n      tnWaWFkzwNKTYIqu6dXlPL28mmeLHmhtRTrKGsfTCzvNZtnFNE2n2ewg3FglKeASHhIAgF3/jRFN\n      Rc+4hNnk9aSlEERDuDySANBbHU9QhKACC8M4GUFpDZPpU5dl+Risyc0uNwA5smJNOS4hxxu4Jx8c\n      SVZPBNbA12enhRFxugC2hjurSXZaeLj3VCkZAbiLg4UcJ4Of6HhjfYiODzn+JK3FVjATEIPQOa4O\n      vMqqyDGd1rnZ56Ysy9PEnuouCH1gnADCGMtDpHjF6oDsj9vRtnHersM/UqyVUWFTeBLBmriJwNZh\n      j+4TgFXfQvdmsei8bR0XbH9Tf91iPtjhWPsIzq8PIFsWejxPs2xyxq6oiIXS4aRGlEJuqBqlY+ei\n      q5Q9AZKTof9Pc857GFyZ5iP2IyAlOaaqcMfGz9E8xb/iPdpxyX1gDOSflKSCFflYREW16PTwYDG8\n      BKa2qJVpyDuvhldbu0LOFtnicypnC0z2yV8AAAD//wMALvIkjL4DAAA=\n\";\n        let file_path = create_test_file(&dir, \"binary.yaml\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_yaml_with_many_aliases_and_few_anchors() -> Result<()> {\n        let dir = tempdir()?;\n        let mut content = indoc::formatdoc! {\"\n        defaults: &defaults\n          image: alpine\n        services:\n        \"};\n        for index in 0..158 {\n            let _ = write!(content, \"  svc{index}:\\n    <<: *defaults\\n\");\n        }\n\n        let file_path = create_test_file(&dir, \"many-aliases.yaml\", content.as_bytes()).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path, false).await?;\n        assert_eq!(code, 0, \"{}\", String::from_utf8_lossy(&output));\n        assert!(output.is_empty());\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/detect_private_key.rs",
    "content": "use std::path::Path;\nuse std::sync::LazyLock;\n\nuse aho_corasick::AhoCorasick;\nuse anyhow::Result;\nuse tokio::io::AsyncReadExt;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\nconst BLACKLIST: &[&[u8]] = &[\n    b\"BEGIN RSA PRIVATE KEY\",\n    b\"BEGIN DSA PRIVATE KEY\",\n    b\"BEGIN EC PRIVATE KEY\",\n    b\"BEGIN OPENSSH PRIVATE KEY\",\n    b\"BEGIN PRIVATE KEY\",\n    b\"PuTTY-User-Key-File-2\",\n    b\"BEGIN SSH2 ENCRYPTED PRIVATE KEY\",\n    b\"BEGIN PGP PRIVATE KEY BLOCK\",\n    b\"BEGIN ENCRYPTED PRIVATE KEY\",\n    b\"BEGIN OpenVPN Static key V1\",\n];\nconst BUFFER_SIZE: usize = 8192;\n\n// Keep at most the longest marker minus one byte so split matches can span two reads.\nconst CARRY_CAPACITY: usize = {\n    let mut max_len = 0;\n    let mut idx = 0;\n    while idx < BLACKLIST.len() {\n        let len = BLACKLIST[idx].len();\n        if len > max_len {\n            max_len = len;\n        }\n        idx += 1;\n    }\n\n    max_len.saturating_sub(1)\n};\nstatic PRIVATE_KEY_MATCHER: LazyLock<AhoCorasick> = LazyLock::new(|| {\n    AhoCorasick::new(BLACKLIST).expect(\"private key blacklist patterns should be valid\")\n});\n\npub(crate) async fn detect_private_key(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        check_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\n/// Scan the file in chunks while preserving a small tail between reads.\n///\n/// For example, if one read ends with `BEGIN RSA PRIV` and the next read starts\n/// with `ATE KEY`, we keep the tail of the first read, prepend it to the second\n/// read, and search the combined window so `BEGIN RSA PRIVATE KEY` is still found.\nasync fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let mut file = fs_err::tokio::File::open(file_base.join(filename)).await?;\n    let mut buf = vec![0u8; BUFFER_SIZE + CARRY_CAPACITY];\n    let mut carry_len = 0;\n\n    loop {\n        let bytes_read = file.read(&mut buf[carry_len..]).await?;\n        if bytes_read == 0 {\n            break;\n        }\n\n        let search_len = carry_len + bytes_read;\n        let search_buf = &buf[..search_len];\n\n        if PRIVATE_KEY_MATCHER.find(search_buf).is_some() {\n            let error_message = format!(\"Private key found: {}\\n\", filename.display());\n            return Ok((1, error_message.into_bytes()));\n        }\n\n        // Move the tail of this chunk to the front of the buffer so a key marker\n        // split across this read and the next read is still seen.\n        carry_len = CARRY_CAPACITY.min(search_len);\n        if carry_len > 0 {\n            buf.copy_within(search_len - carry_len..search_len, 0);\n        }\n    }\n\n    Ok((0, Vec::new()))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_no_private_key() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"This is just a regular file\\nwith some content\\n\";\n        let file_path = create_test_file(&dir, \"clean.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_rsa_private_key() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"-----BEGIN RSA PRIVATE KEY-----\\nMIIE...\\n-----END RSA PRIVATE KEY-----\\n\";\n        let file_path = create_test_file(&dir, \"id_rsa\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"Private key found\"));\n        assert!(output_str.contains(\"id_rsa\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_key_in_middle_of_file() -> Result<()> {\n        let dir = tempdir()?;\n        let content =\n            b\"Some documentation\\n\\nHere is a key:\\n-----BEGIN RSA PRIVATE KEY-----\\ndata\\n\";\n        let file_path = create_test_file(&dir, \"doc.txt\", content).await?;\n        let (code, _output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_false_positive_similar_text() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"This file talks about BEGIN_RSA_PRIVATE_KEY but doesn't contain one\\n\";\n        let file_path = create_test_file(&dir, \"false_positive.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_file() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.txt\", content).await?;\n        let (code, output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_binary_file_with_key() -> Result<()> {\n        let dir = tempdir()?;\n        let mut content = vec![0xFF, 0xFE, 0x00];\n        content.extend_from_slice(b\"BEGIN RSA PRIVATE KEY\");\n        let file_path = create_test_file(&dir, \"binary.dat\", &content).await?;\n        let (code, _output) = check_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1);\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/fix_byte_order_marker.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom};\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\nconst UTF8_BOM: &[u8] = b\"\\xef\\xbb\\xbf\";\nconst BUFFER_SIZE: usize = 8192; // 8KB buffer for streaming\n\npub(crate) async fn fix_byte_order_marker(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        fix_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn fix_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let file_path = file_base.join(filename);\n\n    let mut file = fs_err::tokio::OpenOptions::new()\n        .read(true)\n        .write(true)\n        .open(&file_path)\n        .await?;\n    let file_len = file.seek(SeekFrom::End(0)).await?;\n    if file_len < UTF8_BOM.len() as u64 {\n        return Ok((0, Vec::new()));\n    }\n\n    let mut bom_buffer = [0u8; 3];\n    file.seek(SeekFrom::Start(0)).await?;\n    file.read_exact(&mut bom_buffer).await?;\n    if bom_buffer != UTF8_BOM {\n        return Ok((0, Vec::new()));\n    }\n\n    if file_len == UTF8_BOM.len() as u64 {\n        file.set_len(0).await?;\n    } else {\n        // Shift the payload in place so large files do not need a full second buffer.\n        shift_file_left(&mut file, UTF8_BOM.len() as u64).await?;\n    }\n\n    Ok((\n        1,\n        format!(\"{}: removed byte-order marker\\n\", filename.display()).into_bytes(),\n    ))\n}\n\nasync fn shift_file_left(file: &mut fs_err::tokio::File, offset: u64) -> Result<()> {\n    let file_len = file.seek(SeekFrom::End(0)).await?;\n    let mut buf = vec![0u8; BUFFER_SIZE];\n    let mut read_pos = offset;\n    let mut write_pos = 0;\n\n    while read_pos < file_len {\n        // Read after the BOM and rewrite earlier in the same file.\n        let remaining = usize::try_from(file_len - read_pos)?;\n        let chunk_len = BUFFER_SIZE.min(remaining);\n        file.seek(SeekFrom::Start(read_pos)).await?;\n        file.read_exact(&mut buf[..chunk_len]).await?;\n        file.seek(SeekFrom::Start(write_pos)).await?;\n        file.write_all(&buf[..chunk_len]).await?;\n        read_pos += chunk_len as u64;\n        write_pos += chunk_len as u64;\n    }\n\n    file.set_len(file_len - offset).await?;\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_file_with_bom() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\\xef\\xbb\\xbfHello, World!\";\n        let file_path = create_test_file(&dir, \"with_bom.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"removed byte-order marker\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"Hello, World!\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_without_bom() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Hello, World!\";\n        let file_path = create_test_file(&dir, \"without_bom.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_file() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_shorter_than_bom() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"Hi\";\n        let file_path = create_test_file(&dir, \"short.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_file_with_partial_bom() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\\xef\\xbbHello\"; // Only first 2 bytes of BOM\n        let file_path = create_test_file(&dir, \"partial_bom.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_bom_only_file() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\\xef\\xbb\\xbf\";\n        let file_path = create_test_file(&dir, \"bom_only.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"removed byte-order marker\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_utf8_content_with_bom() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"\\xef\\xbb\\xbf\\xe4\\xb8\\xad\\xe6\\x96\\x87\"; // BOM + Chinese characters \"中文\"\n        let file_path = create_test_file(&dir, \"utf8_with_bom.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"removed byte-order marker\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"\\xe4\\xb8\\xad\\xe6\\x96\\x87\"); // Just the Chinese characters\n\n        // Verify we can still read it as valid UTF-8\n        let text = String::from_utf8(new_content)?;\n        assert_eq!(text, \"中文\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_large_file_streaming() -> Result<()> {\n        let dir = tempdir()?;\n\n        // Create a large file (>64KB) with BOM\n        let mut content = Vec::with_capacity(100_000);\n        content.extend_from_slice(b\"\\xef\\xbb\\xbf\");\n        content.extend(b\"x\".repeat(100_000));\n\n        let file_path = create_test_file(&dir, \"large_with_bom.txt\", &content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1);\n        let output_str = String::from_utf8_lossy(&output);\n        assert!(output_str.contains(\"removed byte-order marker\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content.len(), 100_000);\n        assert!(new_content.iter().all(|&b| b == b'x'));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/fix_end_of_file.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWriteExt, SeekFrom};\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\npub(crate) async fn fix_end_of_file(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        fix_file(hook.project().relative_path(), filename)\n    })\n    .await\n}\n\nasync fn fix_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec<u8>)> {\n    let file_path = file_base.join(filename);\n    let mut file = fs_err::tokio::OpenOptions::new()\n        .read(true)\n        .write(true)\n        .open(file_path)\n        .await?;\n\n    // If the file is empty, do nothing.\n    let file_size = file.metadata().await?.len();\n    if file_size == 0 {\n        return Ok((0, Vec::new()));\n    }\n\n    match find_last_non_ending(&mut file).await? {\n        (None, _) => {\n            // File contains only line endings, so we can just set it to empty.\n            file.set_len(0).await?;\n            file.flush().await?;\n            file.shutdown().await?;\n            Ok((1, format!(\"Fixing {}\\n\", filename.display()).into_bytes()))\n        }\n        (Some(pos), None) => {\n            // File has some content, but no line ending at the end.\n            file.seek(SeekFrom::Start(pos + 1)).await?;\n            file.write_all(b\"\\n\").await?;\n            file.flush().await?;\n            file.shutdown().await?;\n            Ok((1, format!(\"Fixing {}\\n\", filename.display()).into_bytes()))\n        }\n        (Some(pos), Some(line_ending)) => {\n            // File has some content and at least one line ending.\n            let new_size = pos + 1 + line_ending.len() as u64;\n            if file_size == new_size {\n                // File already has the correct line ending.\n                return Ok((0, Vec::new()));\n            }\n            file.set_len(new_size).await?;\n            Ok((1, format!(\"Fixing {}\\n\", filename.display()).into_bytes()))\n        }\n    }\n}\n\nfn determine_line_ending(first: u8, second: u8) -> Option<&'static str> {\n    if first == b'\\r' && second == b'\\n' {\n        Some(\"\\r\\n\")\n    } else if first == b'\\n' {\n        Some(\"\\n\")\n    } else if first == b'\\r' {\n        Some(\"\\r\")\n    } else {\n        None\n    }\n}\n\n/// Searches for the last non-line-ending character in the file.\n/// Returns the position of the last non-line-ending character and the line ending type.\nasync fn find_last_non_ending<T>(reader: &mut T) -> Result<(Option<u64>, Option<&str>)>\nwhere\n    T: AsyncRead + AsyncSeek + Unpin,\n{\n    const MAX_SCAN_SIZE: usize = 4 * 1024; // 4KB\n\n    let data_len = reader.seek(SeekFrom::End(0)).await?;\n    if data_len == 0 {\n        return Ok((None, None));\n    }\n\n    let mut read_len = 0;\n    let mut next_char = 0;\n    let mut buf = vec![0u8; MAX_SCAN_SIZE];\n    let mut line_ending = None;\n\n    while read_len < data_len {\n        let block_size = MAX_SCAN_SIZE.min(usize::try_from(data_len - read_len)?);\n        // SAFETY: block_size is guaranteed to be less than or equal to MAX_SCAN_SIZE\n        reader\n            .seek(SeekFrom::Current(-i64::try_from(block_size).unwrap()))\n            .await?;\n        reader.read_exact(&mut buf[..block_size]).await?;\n        read_len += block_size as u64;\n\n        let mut pos = block_size;\n        while pos > 0 {\n            pos -= 1;\n\n            if matches!(buf[pos], b'\\n' | b'\\r') {\n                line_ending = if pos + 1 == block_size {\n                    determine_line_ending(buf[pos], next_char)\n                } else {\n                    determine_line_ending(buf[pos], buf[pos + 1])\n                };\n            } else {\n                return Ok((Some(data_len - read_len + pos as u64), line_ending));\n            }\n        }\n\n        next_char = buf[0];\n    }\n\n    Ok((None, line_ending))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    use anyhow::Ok;\n    use bstr::ByteSlice;\n    use std::path::{Path, PathBuf};\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_no_line_ending_1() -> Result<()> {\n        let dir = tempdir()?;\n\n        // For files without line endings, just append \"\\n\" at the end, no matter\n        // what line endings are previously used.\n        // This is consistent with the behavior of `pre-commit`.\n\n        let content = b\"line1\\nline2\\nline3\";\n        let file_path = create_test_file(&dir, \"unix_no_eof.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1, \"Should fix the file\");\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\nline2\\nline3\\n\");\n\n        let content = b\"line1\\r\\nline2\\nline3\\r\\nline4\";\n        let file_path = create_test_file(&dir, \"mixed.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1, \"Should fix the file\");\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\r\\nline2\\nline3\\r\\nline4\\n\");\n\n        let content = b\"line1\\r\\nline2\\r\\nline3\";\n        let file_path = create_test_file(&dir, \"windows_no_eof.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n        assert_eq!(code, 1, \"Should fix the file\");\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\r\\nline2\\r\\nline3\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_already_has_correct_windows_ending() -> Result<()> {\n        let dir = tempdir()?;\n\n        let content = b\"line1\\r\\nline2\\r\\nline3\\r\\n\";\n        let file_path = create_test_file(&dir, \"windows_with_eof.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0, \"Should not change the file\");\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_already_has_correct_unix_ending() -> Result<()> {\n        let dir = tempdir()?;\n\n        let content = b\"line1\\nline2\\nline3\\n\";\n        let file_path = create_test_file(&dir, \"unix_with_eof.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0, \"Should not change the file\");\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_file() -> Result<()> {\n        let dir = tempdir()?;\n\n        let content = b\"\";\n        let file_path = create_test_file(&dir, \"empty.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 0, \"Should not change empty file\");\n        assert!(output.is_empty());\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_excess_newlines_removal() -> Result<()> {\n        let dir = tempdir()?;\n\n        let content = b\"line1\\nline2\\n\\n\\n\\n\";\n        let file_path = create_test_file(&dir, \"excess_newlines.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1, \"Should fix the file\");\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\nline2\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_excess_crlf_removal() -> Result<()> {\n        let dir = tempdir()?;\n\n        let content = b\"line1\\r\\nline2\\r\\n\\r\\n\\r\\n\";\n        let file_path = create_test_file(&dir, \"excess_crlf.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1, \"Should fix the file\");\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\r\\nline2\\r\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_all_newlines_make_empty() -> Result<()> {\n        let dir = tempdir()?;\n\n        let content = b\"\\n\\n\\n\\n\";\n        let file_path = create_test_file(&dir, \"only_newlines.txt\", content).await?;\n\n        let (code, output) = fix_file(Path::new(\"\"), &file_path).await?;\n\n        assert_eq!(code, 1, \"Should fix the file\");\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/fix_trailing_whitespace.rs",
    "content": "use std::ops::Deref;\nuse std::path::Path;\nuse std::str::FromStr;\n\nuse anyhow::Result;\nuse bstr::ByteSlice;\nuse clap::Parser;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\nconst MARKDOWN_LINE_BREAK: &[u8] = b\"  \";\n\n#[derive(Clone)]\nstruct Chars(Vec<char>);\n\nimpl FromStr for Chars {\n    type Err = String;\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        Ok(Chars(s.chars().collect()))\n    }\n}\n\nimpl Deref for Chars {\n    type Target = Vec<char>;\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\n#[derive(Parser)]\n#[command(disable_help_subcommand = true)]\n#[command(disable_version_flag = true)]\n#[command(disable_help_flag = true)]\nstruct Args {\n    #[arg(long)]\n    markdown_linebreak_ext: Vec<String>,\n    // `clap` cannot parse `--chars= \\t` into vec<char> correctly.\n    // so, we use Chars to achieve it.\n    #[arg(long)]\n    chars: Option<Chars>,\n}\n\nimpl Args {\n    fn markdown_exts(&self) -> Result<Vec<String>> {\n        let markdown_exts = self\n            .markdown_linebreak_ext\n            .iter()\n            .flat_map(|ext| ext.split(','))\n            .map(|ext| format!(\".{}\", ext.trim_start_matches('.')).to_ascii_lowercase())\n            .collect::<Vec<_>>();\n\n        // Validate extensions don't contain path separators\n        for ext in &markdown_exts {\n            if ext[1..]\n                .chars()\n                .any(|c| matches!(c, '.' | '/' | '\\\\' | ':'))\n            {\n                anyhow::bail!(\"bad `--markdown-linebreak-ext` argument '{ext}' (has . / \\\\ :)\");\n            }\n        }\n        Ok(markdown_exts)\n    }\n\n    fn force_markdown(&self) -> bool {\n        self.markdown_linebreak_ext.iter().any(|ext| ext == \"*\")\n    }\n}\n\npub(crate) async fn fix_trailing_whitespace(\n    hook: &Hook,\n    filenames: &[&Path],\n) -> Result<(i32, Vec<u8>)> {\n    let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?;\n\n    let force_markdown = args.force_markdown();\n    let markdown_exts = args.markdown_exts()?;\n    let chars = if let Some(chars) = args.chars {\n        chars.deref().to_owned()\n    } else {\n        Vec::new()\n    };\n\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        fix_file(\n            hook.project().relative_path(),\n            filename,\n            &chars,\n            force_markdown,\n            &markdown_exts,\n        )\n    })\n    .await\n}\n\nasync fn fix_file(\n    file_base: &Path,\n    filename: &Path,\n    chars: &[char],\n    force_markdown: bool,\n    markdown_exts: &[String],\n) -> Result<(i32, Vec<u8>)> {\n    let is_markdown = force_markdown || {\n        Path::new(filename)\n            .extension()\n            .and_then(|e| e.to_str())\n            .map(|e| format!(\".{}\", e.to_ascii_lowercase()))\n            .is_some_and(|e| markdown_exts.contains(&e))\n    };\n\n    let file_path = file_base.join(filename);\n    let content = fs_err::tokio::read(&file_path).await?;\n\n    let mut output = Vec::with_capacity(content.len());\n    let mut modified = false;\n    for line in content.split_inclusive(|&b| b == b'\\n') {\n        let line_ending = detect_line_ending(line);\n        let mut trimmed = &line[..line.len() - line_ending.len()];\n\n        let markdown_end = needs_markdown_break(is_markdown, trimmed);\n        if markdown_end {\n            trimmed = &trimmed[..trimmed.len() - MARKDOWN_LINE_BREAK.len()];\n        }\n\n        if chars.is_empty() {\n            trimmed = trimmed.trim_ascii_end();\n        } else {\n            trimmed = trimmed.trim_end_with(|c| chars.contains(&c));\n        }\n\n        output.extend_from_slice(trimmed);\n        if markdown_end {\n            output.extend_from_slice(MARKDOWN_LINE_BREAK);\n            modified |= trimmed.len() + MARKDOWN_LINE_BREAK.len() + line_ending.len() != line.len();\n        } else {\n            modified |= trimmed.len() + line_ending.len() != line.len();\n        }\n        output.extend_from_slice(line_ending);\n    }\n\n    if modified {\n        fs_err::tokio::write(&file_path, &output).await?;\n        Ok((1, format!(\"Fixing {}\\n\", filename.display()).into_bytes()))\n    } else {\n        Ok((0, Vec::new()))\n    }\n}\n\nfn detect_line_ending(line: &[u8]) -> &[u8] {\n    if line.ends_with(b\"\\r\\n\") {\n        b\"\\r\\n\"\n    } else if line.ends_with(b\"\\n\") {\n        b\"\\n\"\n    } else if line.ends_with(b\"\\r\") {\n        b\"\\r\"\n    } else {\n        b\"\"\n    }\n}\n\nfn needs_markdown_break(is_markdown: bool, trimmed: &[u8]) -> bool {\n    is_markdown\n        && !trimmed.chars().all(|b| b.is_ascii_whitespace())\n        && trimmed.ends_with(MARKDOWN_LINE_BREAK)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    use std::path::PathBuf;\n    use tempfile::TempDir;\n\n    async fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_trim_non_markdown_trims_spaces() -> Result<()> {\n        let dir = TempDir::new()?;\n        let file_path =\n            create_test_file(&dir, \"file.txt\", b\"keep this line\\ntrim trailing    \\n\").await?;\n\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\".md\".to_string()];\n\n        let (code, msg) = fix_file(Path::new(\"\"), &file_path, &chars, false, &md_exts).await?;\n\n        // modified\n        assert_eq!(code, 1);\n        let msg_str = String::from_utf8_lossy(&msg);\n        assert!(msg_str.contains(\"file.txt\"));\n\n        // file content updated: trailing spaces removed\n        let content = fs_err::tokio::read_to_string(&file_path).await?;\n        let expected = \"keep this line\\ntrim trailing\\n\";\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_markdown_preserve_two_spaces_and_reduce_extra() -> Result<()> {\n        let dir = TempDir::new()?;\n        let file_path = create_test_file(\n            &dir,\n            \"doc.md\",\n            b\"line_keep_two  \\nline_reduce_three   \\nother_line\\n\",\n        )\n        .await?;\n\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\".md\".to_string()];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &file_path, &chars, false, &md_exts).await?;\n\n        // second line changed 3 -> 2 spaces, so modified\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&file_path).await?;\n        let expected = \"line_keep_two  \\nline_reduce_three  \\nother_line\\n\";\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_force_markdown_obeys_markdown_rules() -> Result<()> {\n        let dir = TempDir::new()?;\n        // .txt normally not markdown, but we force markdown=true\n        let file_path = create_test_file(\n            &dir,\n            \"forced.txt\",\n            b\"keep_two_spaces  \\nthree_spaces_line   \\n\",\n        )\n        .await?;\n\n        let chars = vec![' ', '\\t'];\n        let md_exts: Vec<String> = vec![]; // irrelevant because force_markdown = true\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &file_path, &chars, true, &md_exts).await?;\n\n        // modified because one line had 3 spaces -> reduced to 2\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&file_path).await?;\n        let expected = \"keep_two_spaces  \\nthree_spaces_line  \\n\";\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_no_changes_returns_zero_and_no_write() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"ok.txt\", b\"already_trimmed\\nline_two\\n\").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\".md\".to_string()];\n\n        // file already trimmed -> no changes\n        let (code, msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 0);\n        assert!(msg.is_empty());\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, \"already_trimmed\\nline_two\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_empty_file_no_change() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"empty.txt\", b\"\").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![];\n\n        let (code, msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 0);\n        assert!(msg.is_empty());\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, \"\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_only_whitespace_lines_are_handled_not_markdown_end() -> Result<()> {\n        let dir = TempDir::new()?;\n        // lines are only whitespace; markdown_end_flag should NOT trigger\n        let path = create_test_file(&dir, \"ws.txt\", b\"   \\n\\t\\n  \\n\").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\".md\".to_string()];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        // trimming whitespace-only lines will change them to empty lines -> modified true\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        // Expect empty lines (newline preserved per implementation)\n        assert_eq!(content, \"\\n\\n\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_chars_empty_uses_trim_ascii_end() -> Result<()> {\n        let dir = TempDir::new()?;\n        // trailing ascii spaces should be removed by trim_ascii_end when chars is empty\n        let path = create_test_file(&dir, \"ascii.txt\", b\"foo   \\nbar \\t\\n\").await?;\n        let chars = vec![]; // will hit trim_ascii_end()\n        let md_exts = vec![];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        let expected = \"foo\\nbar\\n\";\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_crlf_lines_handling() -> Result<()> {\n        let dir = TempDir::new()?;\n        // CRLF content (use \\r\\n). Ensure trimming still works.\n        let path = create_test_file(&dir, \"crlf.txt\", b\"one  \\r\\ntwo   \\r\\n\").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\".txt\".to_string()]; // treat as markdown for this test\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        // read file and check logical lines presence (line endings may be normalized by lines())\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert!(content.contains(\"one\"));\n        assert!(content.contains(\"two\"));\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_no_newline_at_eof() -> Result<()> {\n        let dir = TempDir::new()?;\n        // no trailing newline on last line\n        let path = create_test_file(&dir, \"no_nl.txt\", b\"lastline   \").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        // Expect trailing spaces removed\n        assert_eq!(content, \"lastline\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_unicode_trim_char() -> Result<()> {\n        let dir = TempDir::new()?;\n        // use a unicode char '。' and ideographic space '　' to trim\n        let path = create_test_file(&dir, \"uni.txt\", \"hello。　\\n\".as_bytes()).await?;\n        let chars = vec!['。', '　'];\n        let md_exts = vec![];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, \"hello\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_extension_case_insensitive_matching() -> Result<()> {\n        let dir = TempDir::new()?;\n        // capital extension .MD should match .md in markdown_exts\n        let path = create_test_file(&dir, \"Doc.MD\", b\"hi   \\n\").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\".md\".to_string()];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        // markdown rules: trailing >2 -> reduce to two spaces\n        assert!(content.contains(\"hi\"));\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_mixed_lines_modified_flag_true_if_any_changed() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"mix.txt\", b\"ok\\nneedtrim   \\nalso_ok\\n\").await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        let expected = \"ok\\nneedtrim\\nalso_ok\\n\";\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_no_change_no_newline_at_eof() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"ok_no_nl.txt\", b\"foo\\nbar\").await?;\n\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![];\n\n        let (code, msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 0);\n        assert!(msg.is_empty());\n\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, \"foo\\nbar\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_markdown_wildcard_ext_and_eof_whitespace_removed() -> Result<()> {\n        let dir = TempDir::new()?;\n        let content = b\"foo  \\nbar \\nbaz    \\n\\t\\n\\n  \";\n        let path = create_test_file(&dir, \"wild.md\", content).await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts = vec![\"*\".to_string()];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, true, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let expected = \"foo  \\nbar\\nbaz  \\n\\n\\n\";\n        let new_content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(new_content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_markdown_with_custom_charset() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"custom_charset.md\", b\"\\ta \\t   \\n\").await?;\n        let chars = vec![' '];\n        let md_exts = vec![\"*\".to_string()];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, true, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let expected = \"\\ta \\t  \\n\";\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_eol_trim() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"trim_eol.md\", b\"a\\nb\\r\\r\\r\\n\").await?;\n        let chars = vec!['x'];\n        let md_exts = vec![];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, true, &md_exts).await?;\n        assert_eq!(code, 0);\n\n        let expected = \"a\\nb\\r\\r\\r\\n\";\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_markdown_trim() -> Result<()> {\n        let dir = TempDir::new()?;\n        let path = create_test_file(&dir, \"trim_markdown.md\", b\"axxx  \\n\").await?;\n        let chars = vec!['x'];\n        let md_exts = vec![\"md\".to_string()];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, true, &md_exts).await?;\n        assert_eq!(code, 1);\n\n        let expected = \"a  \\n\";\n        let content = fs_err::tokio::read_to_string(&path).await?;\n        assert_eq!(content, expected);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_invalid_utf8_file_is_handled() -> Result<()> {\n        let dir = TempDir::new()?;\n        // This is valid ASCII followed by invalid UTF-8 (0xFF)\n        let content = b\"valid line\\ninvalid utf8 here:\\xff\\n\";\n        let path = create_test_file(&dir, \"invalid_utf8.txt\", content).await?;\n        let chars = vec![' ', '\\t'];\n        let md_exts: Vec<String> = vec![];\n\n        let (code, _msg) = fix_file(Path::new(\"\"), &path, &chars, false, &md_exts).await?;\n        assert_eq!(code, 0);\n\n        let new_content = fs_err::tokio::read(&path).await?;\n        // The invalid byte should still be present, but trailing whitespace should be trimmed\n        assert!(new_content.starts_with(b\"valid line\\ninvalid utf8 here:\\xff\\n\"));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/mixed_line_ending.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse bstr::ByteSlice;\nuse clap::{Parser, ValueEnum};\nuse rustc_hash::FxHashMap;\n\nuse crate::hook::Hook;\nuse crate::hooks::run_concurrent_file_checks;\nuse crate::run::CONCURRENCY;\n\nconst CRLF: &[u8] = b\"\\r\\n\";\nconst LF: &[u8] = b\"\\n\";\nconst CR: &[u8] = b\"\\r\";\nconst ALL_ENDINGS: [&[u8]; 3] = [CR, CRLF, LF];\n\n#[derive(Parser)]\n#[command(disable_help_subcommand = true)]\n#[command(disable_version_flag = true)]\n#[command(disable_help_flag = true)]\nstruct Args {\n    /// Fix mixed line endings by converting to the most common line ending\n    /// or a specified line ending.\n    #[clap(long, short, value_enum, default_value_t = FixMode::Auto)]\n    fix: FixMode,\n}\n\n#[derive(Copy, Clone, Debug, Default, ValueEnum)]\n#[allow(clippy::upper_case_acronyms)]\nenum FixMode {\n    /// Automatically determine the most common line ending and use it\n    #[default]\n    Auto,\n    /// Don't fix, just report if mixed line endings are found\n    No,\n    /// Convert all line endings to LF\n    LF,\n    /// Convert all line endings to CRLF\n    CRLF,\n    /// Convert all line endings to CR\n    CR,\n}\n\npub(crate) async fn mixed_line_ending(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n    let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?;\n\n    run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| {\n        fix_file(hook.project().relative_path(), filename, args.fix)\n    })\n    .await\n}\n\n// Process a single file for mixed line endings\nasync fn fix_file(file_base: &Path, filename: &Path, fix_mode: FixMode) -> Result<(i32, Vec<u8>)> {\n    let file_path = file_base.join(filename);\n    let contents = fs_err::tokio::read(&file_path).await?;\n\n    // Skip empty files or binary files\n    if contents.is_empty() || contents.find_byte(0).is_some() {\n        return Ok((0, Vec::new()));\n    }\n\n    let counts = count_line_endings(&contents);\n    let has_mixed_endings = counts.len() > 1;\n\n    match fix_mode {\n        FixMode::No => {\n            if has_mixed_endings {\n                Ok((\n                    1,\n                    format!(\"{}: mixed line endings\\n\", filename.display()).into_bytes(),\n                ))\n            } else {\n                Ok((0, Vec::new()))\n            }\n        }\n        FixMode::Auto => {\n            if !has_mixed_endings {\n                return Ok((0, Vec::new()));\n            }\n\n            let target_ending = find_most_common_ending(&counts);\n            apply_line_ending(&file_path, &contents, target_ending).await?;\n            Ok((1, format!(\"Fixing {}\\n\", filename.display()).into_bytes()))\n        }\n        _ => {\n            let target_ending = match fix_mode {\n                FixMode::LF => LF,\n                FixMode::CRLF => CRLF,\n                FixMode::CR => CR,\n                _ => unreachable!(),\n            };\n            let needs_fixing = counts.keys().any(|&ending| ending != target_ending);\n\n            if needs_fixing {\n                apply_line_ending(&file_path, &contents, target_ending).await?;\n                Ok((1, format!(\"Fixing {}\\n\", filename.display()).into_bytes()))\n            } else {\n                Ok((0, Vec::new()))\n            }\n        }\n    }\n}\n\nfn count_line_endings(contents: &[u8]) -> FxHashMap<&'static [u8], usize> {\n    let mut counts = FxHashMap::default();\n\n    for line in split_lines_with_endings(contents) {\n        let ending = if line.ends_with(CRLF) {\n            CRLF\n        } else if line.ends_with(CR) {\n            CR\n        } else if line.ends_with(LF) {\n            LF\n        } else {\n            continue; // Line without ending\n        };\n        *counts.entry(ending).or_insert(0) += 1;\n    }\n\n    counts\n}\n\nfn find_most_common_ending(counts: &FxHashMap<&'static [u8], usize>) -> &'static [u8] {\n    ALL_ENDINGS\n        .iter()\n        .max_by_key(|&&ending| counts.get(ending).unwrap_or(&0))\n        .copied()\n        .unwrap_or(LF)\n}\n\nasync fn apply_line_ending(filename: &Path, contents: &[u8], ending: &[u8]) -> Result<()> {\n    let lines = split_lines_with_endings(contents);\n    let mut new_contents = Vec::with_capacity(contents.len());\n\n    for line in lines {\n        let line_without_ending = strip_line_ending(line);\n        new_contents.extend_from_slice(line_without_ending);\n        new_contents.extend_from_slice(ending);\n    }\n\n    fs_err::tokio::write(filename, &new_contents).await?;\n    Ok(())\n}\n\nfn strip_line_ending(line: &[u8]) -> &[u8] {\n    if line.ends_with(CRLF) {\n        &line[..line.len() - 2]\n    } else if line.ends_with(LF) || line.ends_with(CR) {\n        &line[..line.len() - 1]\n    } else {\n        line\n    }\n}\n\nfn split_lines_with_endings(contents: &[u8]) -> Vec<&[u8]> {\n    if contents.is_empty() {\n        return Vec::new();\n    }\n\n    let mut lines = Vec::new();\n    let mut last_end = 0;\n    let mut i = 0;\n\n    while i < contents.len() {\n        match contents[i] {\n            b'\\n' => {\n                lines.push(&contents[last_end..=i]);\n                last_end = i + 1;\n                i += 1;\n            }\n            b'\\r' => {\n                if i + 1 < contents.len() && contents[i + 1] == b'\\n' {\n                    // CRLF\n                    lines.push(&contents[last_end..=i + 1]);\n                    last_end = i + 2;\n                    i += 2;\n                } else {\n                    // CR\n                    lines.push(&contents[last_end..=i]);\n                    last_end = i + 1;\n                    i += 1;\n                }\n            }\n            _ => i += 1,\n        }\n    }\n\n    // Add remaining content if any\n    if last_end < contents.len() {\n        lines.push(&contents[last_end..]);\n    }\n\n    lines\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use bstr::ByteSlice;\n    use std::path::{Path, PathBuf};\n    use tempfile::tempdir;\n\n    async fn create_test_file(\n        dir: &tempfile::TempDir,\n        name: &str,\n        content: &[u8],\n    ) -> Result<PathBuf> {\n        let file_path = dir.path().join(name);\n        fs_err::tokio::write(&file_path, content).await?;\n        Ok(file_path)\n    }\n\n    #[tokio::test]\n    async fn test_auto_fix_crlf_wins() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"line1\\nline2\\r\\nline3\\r\\n\"; // 1 LF, 2 CRLF\n        let file_path = create_test_file(&dir, \"mixed_crlf.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::Auto).await?;\n        assert_eq!(code, 1);\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\r\\nline2\\r\\nline3\\r\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_auto_fix_lf_wins() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"line1\\nline2\\nline3\\r\\n\"; // 2 LF, 1 CRLF\n        let file_path = create_test_file(&dir, \"mixed_lf.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::Auto).await?;\n        assert_eq!(code, 1);\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\nline2\\nline3\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_auto_fix_tie_prefers_lf() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"line1\\nline2\\r\\n\"; // 1 LF, 1 CRLF\n        let file_path = create_test_file(&dir, \"mixed_tie.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::Auto).await?;\n        assert_eq!(code, 1);\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\nline2\\n\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_fix_no() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"line1\\nline2\\r\\n\";\n        let file_path = create_test_file(&dir, \"mixed_no.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::No).await?;\n        assert_eq!(code, 1);\n        assert!(output.as_bytes().contains_str(\"mixed line endings\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, content); // File should not be changed\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_no_line_endings() -> Result<()> {\n        let dir = tempdir()?;\n        let content = b\"some content\";\n        let file_path = create_test_file(&dir, \"no_endings.txt\", content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::Auto).await?;\n        assert_eq!(code, 0);\n        assert!(output.is_empty());\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_fix_with_cr_endings() -> Result<()> {\n        let dir = tempdir()?;\n        // A file with a mix of all three line ending types\n        let content = b\"line1\\rline2\\nline3\\r\\n\";\n        let file_path = create_test_file(&dir, \"all_mixed.txt\", content).await?;\n\n        // Test auto fix (should prefer LF as it's a 3-way tie)\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::Auto).await?;\n        assert_eq!(code, 1);\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\nline2\\nline3\\n\");\n\n        // Restore content and test fix to CRLF\n        fs_err::tokio::write(&file_path, content).await?;\n        let (code, output) = fix_file(Path::new(\"\"), &file_path, FixMode::CRLF).await?;\n        assert_eq!(code, 1);\n        assert!(output.as_bytes().contains_str(\"Fixing\"));\n        let new_content = fs_err::tokio::read(&file_path).await?;\n        assert_eq!(new_content, b\"line1\\r\\nline2\\r\\nline3\\r\\n\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/mod.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse tracing::debug;\n\nuse crate::hook::Hook;\n\nmod check_added_large_files;\nmod check_case_conflict;\nmod check_executables_have_shebangs;\npub(crate) mod check_json;\nmod check_merge_conflict;\nmod check_symlinks;\nmod check_toml;\nmod check_xml;\nmod check_yaml;\nmod detect_private_key;\nmod fix_byte_order_marker;\nmod fix_end_of_file;\nmod fix_trailing_whitespace;\nmod mixed_line_ending;\nmod no_commit_to_branch;\n\npub(crate) use check_added_large_files::check_added_large_files;\npub(crate) use check_case_conflict::check_case_conflict;\npub(crate) use check_executables_have_shebangs::check_executables_have_shebangs;\npub(crate) use check_json::check_json;\npub(crate) use check_merge_conflict::check_merge_conflict;\npub(crate) use check_symlinks::check_symlinks;\npub(crate) use check_toml::check_toml;\npub(crate) use check_xml::check_xml;\npub(crate) use check_yaml::check_yaml;\npub(crate) use detect_private_key::detect_private_key;\npub(crate) use fix_byte_order_marker::fix_byte_order_marker;\npub(crate) use fix_end_of_file::fix_end_of_file;\npub(crate) use fix_trailing_whitespace::fix_trailing_whitespace;\npub(crate) use mixed_line_ending::mixed_line_ending;\npub(crate) use no_commit_to_branch::no_commit_to_branch;\n\n/// Hooks from `https://github.com/pre-commit/pre-commit-hooks`.\n#[derive(strum::EnumString)]\n#[strum(serialize_all = \"kebab-case\")]\npub(crate) enum PreCommitHooks {\n    CheckAddedLargeFiles,\n    CheckCaseConflict,\n    CheckExecutablesHaveShebangs,\n    EndOfFileFixer,\n    FixByteOrderMarker,\n    CheckJson,\n    CheckSymlinks,\n    CheckMergeConflict,\n    CheckToml,\n    CheckXml,\n    CheckYaml,\n    MixedLineEnding,\n    DetectPrivateKey,\n    NoCommitToBranch,\n    TrailingWhitespace,\n}\n\nimpl PreCommitHooks {\n    pub(crate) fn check_supported(&self, hook: &Hook) -> bool {\n        match self {\n            // `check-yaml` does not support `--unsafe` flag yet.\n            Self::CheckYaml => !hook.args.iter().any(|s| s.starts_with(\"--unsafe\")),\n            _ => true,\n        }\n    }\n\n    pub(crate) async fn run(self, hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec<u8>)> {\n        debug!(\"Running hook `{}` in fast path\", hook.id);\n        match self {\n            Self::CheckAddedLargeFiles => check_added_large_files(hook, filenames).await,\n            Self::CheckCaseConflict => check_case_conflict(hook, filenames).await,\n            Self::CheckExecutablesHaveShebangs => {\n                check_executables_have_shebangs(hook, filenames).await\n            }\n            Self::EndOfFileFixer => fix_end_of_file(hook, filenames).await,\n            Self::FixByteOrderMarker => fix_byte_order_marker(hook, filenames).await,\n            Self::CheckJson => check_json(hook, filenames).await,\n            Self::CheckSymlinks => check_symlinks(hook, filenames).await,\n            Self::CheckMergeConflict => check_merge_conflict(hook, filenames).await,\n            Self::CheckToml => check_toml(hook, filenames).await,\n            Self::CheckYaml => check_yaml(hook, filenames).await,\n            Self::CheckXml => check_xml(hook, filenames).await,\n            Self::MixedLineEnding => mixed_line_ending(hook, filenames).await,\n            Self::DetectPrivateKey => detect_private_key(hook, filenames).await,\n            Self::NoCommitToBranch => no_commit_to_branch(hook).await,\n            Self::TrailingWhitespace => fix_trailing_whitespace(hook, filenames).await,\n        }\n    }\n}\n\n// TODO: compare rev\npub(crate) fn is_pre_commit_hooks(url: &str) -> bool {\n    url == \"https://github.com/pre-commit/pre-commit-hooks\"\n}\n"
  },
  {
    "path": "crates/prek/src/hooks/pre_commit_hooks/no_commit_to_branch.rs",
    "content": "use clap::Parser;\nuse fancy_regex::Regex;\n\nuse crate::git::git_cmd;\nuse crate::hook::Hook;\nuse anyhow::{Context, Result};\n\n#[derive(Parser)]\n#[command(disable_help_subcommand = true)]\n#[command(disable_version_flag = true)]\n#[command(disable_help_flag = true)]\nstruct Args {\n    #[arg(short, long = \"branch\", default_values = &[\"main\", \"master\"])]\n    branches: Vec<String>,\n    #[arg(short, long = \"pattern\")]\n    patterns: Vec<String>,\n}\n\nimpl Args {\n    fn check_protected(&self, branch: &str) -> Result<bool> {\n        if self.branches.iter().any(|b| b == branch) {\n            return Ok(true);\n        }\n\n        if self.patterns.is_empty() {\n            return Ok(false);\n        }\n\n        let patterns = self\n            .patterns\n            .iter()\n            .map(|p| Regex::new(p))\n            .collect::<Result<Vec<Regex>, _>>()\n            .context(\"Failed to compile regex patterns\")?;\n\n        Ok(patterns\n            .iter()\n            .any(|pattern| pattern.is_match(branch).unwrap_or(false)))\n    }\n}\n\npub(crate) async fn no_commit_to_branch(hook: &Hook) -> Result<(i32, Vec<u8>)> {\n    let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?;\n\n    let output = git_cmd(\"get current branch\")?\n        .arg(\"symbolic-ref\")\n        .arg(\"HEAD\")\n        .check(false)\n        .output()\n        .await?;\n\n    if !output.status.success() {\n        return Ok((0, Vec::new()));\n    }\n\n    let ref_name = String::from_utf8_lossy(&output.stdout);\n    // stdout must start with \"refs/heads/\"\n    let branch = ref_name.trim().trim_start_matches(\"refs/heads/\");\n\n    if args.check_protected(branch)? {\n        let err_msg = format!(\"You are not allowed to commit to branch '{branch}'\\n\");\n        Ok((1, err_msg.into_bytes()))\n    } else {\n        Ok((0, Vec::new()))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/http.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::sync::LazyLock;\n\nuse anyhow::{Context, Result};\nuse futures::TryStreamExt;\nuse prek_consts::env_vars::EnvVars;\nuse reqwest::Certificate;\nuse tokio_util::compat::FuturesAsyncReadCompatExt;\nuse tracing::debug;\n\nuse crate::archive::ArchiveExtension;\nuse crate::fs::Simplified;\nuse crate::store::Store;\nuse crate::{archive, warn_user};\n\npub(crate) async fn download_and_extract(\n    url: &str,\n    filename: &str,\n    store: &Store,\n    callback: impl AsyncFn(&Path) -> Result<()>,\n) -> Result<()> {\n    download_and_extract_with(url, filename, store, |req| req, callback).await\n}\n\n/// Like [`download_and_extract`], but accepts a `customize_request` closure\n/// that can modify the [`reqwest::RequestBuilder`] before it is sent (e.g. to\n/// add authentication headers).\npub(crate) async fn download_and_extract_with(\n    url: &str,\n    filename: &str,\n    store: &Store,\n    customize_request: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder,\n    callback: impl AsyncFn(&Path) -> Result<()>,\n) -> Result<()> {\n    let response = customize_request(REQWEST_CLIENT.get(url))\n        .send()\n        .await\n        .with_context(|| format!(\"Failed to download file from {url}\"))?;\n    if !response.status().is_success() {\n        anyhow::bail!(\n            \"Failed to download file from {}: {}\",\n            url,\n            response.status()\n        );\n    }\n\n    let tarball = response\n        .bytes_stream()\n        .map_err(std::io::Error::other)\n        .into_async_read()\n        .compat();\n\n    let scratch_dir = store.scratch_path();\n    let temp_dir = tempfile::tempdir_in(&scratch_dir)?;\n    debug!(url = %url, temp_dir = ?temp_dir.path(), \"Downloading\");\n\n    let ext = ArchiveExtension::from_path(filename)?;\n    archive::unpack(tarball, ext, temp_dir.path()).await?;\n\n    let extracted = match archive::strip_component(temp_dir.path()) {\n        Ok(top_level) => top_level,\n        Err(archive::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(),\n        Err(err) => return Err(err.into()),\n    };\n\n    callback(&extracted).await?;\n\n    drop(temp_dir);\n\n    Ok(())\n}\n\npub(crate) static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {\n    let native_tls = EnvVars::var_as_bool(EnvVars::PREK_NATIVE_TLS).unwrap_or(false);\n\n    let cert_file = EnvVars::var_os(EnvVars::SSL_CERT_FILE).map(PathBuf::from);\n    let cert_dirs: Vec<_> = if let Some(cert_dirs) = EnvVars::var_os(EnvVars::SSL_CERT_DIR) {\n        std::env::split_paths(&cert_dirs).collect()\n    } else {\n        vec![]\n    };\n\n    let certs = load_certs_from_paths(cert_file.as_deref(), &cert_dirs);\n    create_reqwest_client(native_tls, certs)\n});\n\nfn load_pem_certs_from_file(path: &Path) -> Result<Vec<Certificate>> {\n    let cert_data = fs_err::read(path)?;\n    let certs = Certificate::from_pem_bundle(&cert_data)\n        .or_else(|_| Certificate::from_pem(&cert_data).map(|cert| vec![cert]))?;\n    Ok(certs)\n}\n\n/// Load certificate from certificate directory.\nfn load_pem_certs_from_dir(dir: &Path) -> Result<Vec<Certificate>> {\n    let mut certs = Vec::new();\n\n    for entry in fs_err::read_dir(dir)?.flatten() {\n        let path = entry.path();\n\n        // `openssl rehash` used to create this directory uses symlinks. So,\n        // make sure we resolve them.\n        let metadata = match fs_err::metadata(&path) {\n            Ok(metadata) => metadata,\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {\n                // Dangling symlink\n                continue;\n            }\n            Err(_) => {\n                continue;\n            }\n        };\n\n        if metadata.is_file() {\n            if let Ok(mut loaded) = load_pem_certs_from_file(&path) {\n                certs.append(&mut loaded);\n            }\n        }\n    }\n\n    Ok(certs)\n}\n\nfn load_certs_from_paths(file: Option<&Path>, dirs: &[impl AsRef<Path>]) -> Vec<Certificate> {\n    let mut certs = Vec::new();\n\n    if let Some(file) = file {\n        match load_pem_certs_from_file(file) {\n            Ok(mut loaded) => certs.append(&mut loaded),\n            Err(e) => {\n                warn_user!(\n                    \"Failed to load certificates from {}: {e}\",\n                    file.simplified_display().cyan(),\n                );\n            }\n        }\n    }\n\n    for dir in dirs {\n        match load_pem_certs_from_dir(dir.as_ref()) {\n            Ok(mut loaded) => certs.append(&mut loaded),\n            Err(e) => {\n                warn_user!(\n                    \"Failed to load certificates from {}: {}\",\n                    dir.as_ref().simplified_display().cyan(),\n                    e\n                );\n            }\n        }\n    }\n\n    certs\n}\n\nfn create_reqwest_client(native_tls: bool, custom_certs: Vec<Certificate>) -> reqwest::Client {\n    let builder =\n        reqwest::ClientBuilder::new().user_agent(format!(\"prek/{}\", crate::version::version()));\n\n    let builder = if native_tls {\n        debug!(\"Using native TLS for reqwest client\");\n        // Use rustls with rustls-platform-verifier which uses the platform's native certificate facilities.\n        builder.tls_backend_rustls().tls_certs_merge(custom_certs)\n    } else {\n        let root_certs = webpki_root_certs::TLS_SERVER_ROOT_CERTS\n            .iter()\n            .filter_map(|cert_der| Certificate::from_der(cert_der).ok());\n\n        // Merge custom certificates on top of webpki-root-certs\n        builder\n            .tls_backend_rustls()\n            .tls_certs_only(custom_certs)\n            .tls_certs_merge(root_certs)\n    };\n\n    builder.build().expect(\"Failed to build reqwest client\")\n}\n\n#[cfg(test)]\nmod tests {\n    use anyhow::Result;\n    use std::path::Path;\n\n    const TEST_CERT_PEM: &str = \"-----BEGIN CERTIFICATE-----\nMIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5\nMQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g\nUm9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG\nA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg\nQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl\nui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j\nQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr\nttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr\nBqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM\nYyRIHN8wfdVoOw==\n-----END CERTIFICATE-----\\n\";\n\n    fn write_cert(path: &Path) {\n        fs_err::write(path, TEST_CERT_PEM).expect(\"failed to write test certificate\");\n    }\n\n    #[test]\n    fn test_load_pem_certs_from_file() -> Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let cert_path = temp_dir.path().join(\"cert.pem\");\n        write_cert(&cert_path);\n\n        let certs = super::load_pem_certs_from_file(&cert_path)?;\n        assert_eq!(certs.len(), 1);\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_load_pem_certs_from_dir_skips_invalid_files() -> Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let cert_dir = temp_dir.path().join(\"certs\");\n        fs_err::create_dir(&cert_dir)?;\n\n        write_cert(&cert_dir.join(\"valid.pem\"));\n        fs_err::write(cert_dir.join(\"invalid.pem\"), \"not a certificate\")?;\n\n        let certs = super::load_pem_certs_from_dir(&cert_dir)?;\n        assert_eq!(certs.len(), 1);\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_load_certs_from_paths_combines_sources() -> Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let cert_file = temp_dir.path().join(\"cert-file.pem\");\n        write_cert(&cert_file);\n\n        let cert_dir = temp_dir.path().join(\"cert-dir\");\n        fs_err::create_dir(&cert_dir)?;\n        write_cert(&cert_dir.join(\"cert-in-dir.pem\"));\n        fs_err::write(cert_dir.join(\"garbage.txt\"), \"invalid\")?;\n\n        let certs = super::load_certs_from_paths(Some(&cert_file), &[&cert_dir]);\n        assert_eq!(certs.len(), 2);\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_native_tls() {\n        let client = super::create_reqwest_client(true, vec![]);\n        let resp = client.get(\"https://github.com\").send().await;\n        assert!(resp.is_ok(), \"Failed to send request with native TLS\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/install_source.rs",
    "content": "use std::ffi::OsStr;\nuse std::path::{Component, Path, PathBuf};\n\n/// Represents how prek was installed on the system.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum InstallSource {\n    Homebrew,\n    Mise,\n    UvTool,\n    Pipx,\n    Asdf,\n    StandaloneInstaller,\n}\n\nimpl InstallSource {\n    /// Detect the install source from a given path.\n    fn from_path(path: &Path) -> Option<Self> {\n        // Resolve symlinks so e.g. ~/.local/bin/prek -> .../uv/tools/prek/bin/prek is detected.\n        let canonical = path.canonicalize().unwrap_or_else(|_| PathBuf::from(path));\n        let components: Vec<_> = canonical.components().map(Component::as_os_str).collect();\n\n        /// Check whether `components` contains a contiguous subsequence matching `pattern`.\n        fn contains_sequence(components: &[&OsStr], pattern: &[&OsStr]) -> bool {\n            components.windows(pattern.len()).any(|w| w == pattern)\n        }\n\n        let prek = OsStr::new(\"prek\");\n\n        // Homebrew: .../Cellar/prek/...\n        if contains_sequence(&components, &[OsStr::new(\"Cellar\"), prek]) {\n            return Some(Self::Homebrew);\n        }\n        // uv tool: .../uv/tools/prek/...\n        if contains_sequence(&components, &[OsStr::new(\"uv\"), OsStr::new(\"tools\"), prek]) {\n            return Some(Self::UvTool);\n        }\n        // pipx: .../pipx/venvs/prek/...\n        if contains_sequence(\n            &components,\n            &[OsStr::new(\"pipx\"), OsStr::new(\"venvs\"), prek],\n        ) {\n            return Some(Self::Pipx);\n        }\n        // asdf: .../.asdf/installs/prek/...\n        if contains_sequence(\n            &components,\n            &[OsStr::new(\".asdf\"), OsStr::new(\"installs\"), prek],\n        ) {\n            return Some(Self::Asdf);\n        }\n        // mise: .../mise/installs/prek/...\n        if contains_sequence(\n            &components,\n            &[OsStr::new(\"mise\"), OsStr::new(\"installs\"), prek],\n        ) {\n            return Some(Self::Mise);\n        }\n\n        None\n    }\n\n    #[cfg(feature = \"self-update\")]\n    fn is_standalone_installer() -> anyhow::Result<bool> {\n        use axoupdater::AxoUpdater;\n\n        let mut updater = AxoUpdater::new_for(\"prek\");\n        let updater = updater.load_receipt()?;\n        Ok(updater.check_receipt_is_for_this_executable()?)\n    }\n\n    /// Detect the install source from the current executable path.\n    pub(crate) fn detect() -> Option<Self> {\n        #[cfg(feature = \"self-update\")]\n        match Self::is_standalone_installer() {\n            Ok(true) => return Some(Self::StandaloneInstaller),\n            Ok(false) => {}\n            Err(e) => tracing::warn!(\"Failed to check for standalone installer: {e}\"),\n        }\n\n        Self::from_path(&std::env::current_exe().ok()?)\n    }\n\n    /// Returns a human-readable description of the install source.\n    pub(crate) fn description(self) -> &'static str {\n        match self {\n            Self::Homebrew => \"Homebrew\",\n            Self::Mise => \"mise\",\n            Self::UvTool => \"uv tool\",\n            Self::Pipx => \"pipx\",\n            Self::Asdf => \"asdf\",\n            Self::StandaloneInstaller => \"the standalone installer\",\n        }\n    }\n\n    /// Returns the command to update prek for this install source.\n    pub(crate) fn update_instructions(self) -> &'static str {\n        match self {\n            Self::Homebrew => \"brew update && brew upgrade prek\",\n            Self::Mise => \"mise upgrade prek\",\n            Self::UvTool => \"uv tool upgrade prek\",\n            Self::Pipx => \"pipx upgrade prek\",\n            Self::Asdf => \"asdf install prek latest\",\n            Self::StandaloneInstaller => \"prek self update\",\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn detects_homebrew_cellar_arm() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/opt/homebrew/Cellar/prek/0.3.1/bin/prek\")),\n            Some(InstallSource::Homebrew)\n        );\n    }\n\n    #[test]\n    fn detects_homebrew_cellar_intel() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/usr/local/Cellar/prek/0.3.1/bin/prek\")),\n            Some(InstallSource::Homebrew)\n        );\n    }\n\n    #[test]\n    fn returns_none_for_unknown_unix_path() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/usr/local/bin/prek\")),\n            None\n        );\n    }\n\n    #[test]\n    fn detects_mise_installs() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\n                \"/Users/jo/.local/share/mise/installs/prek/0.3.1/bin/prek\"\n            )),\n            Some(InstallSource::Mise)\n        );\n    }\n\n    #[test]\n    fn does_not_match_other_mise_tool() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\n                \"/Users/jo/.local/share/mise/installs/ruby/3.4.6/bin/ruby\"\n            )),\n            None\n        );\n    }\n\n    #[test]\n    fn does_not_match_other_cellar_formula() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/opt/homebrew/Cellar/other/0.1.0/bin/prek\")),\n            None\n        );\n    }\n\n    #[test]\n    fn detects_uv_tool_macos() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/Users/user/.local/share/uv/tools/prek/bin/prek\")),\n            Some(InstallSource::UvTool)\n        );\n    }\n\n    #[test]\n    fn detects_uv_tool_linux() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/home/user/.local/share/uv/tools/prek/bin/prek\")),\n            Some(InstallSource::UvTool)\n        );\n    }\n\n    #[test]\n    fn detects_uv_tool_custom_xdg() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/opt/data/uv/tools/prek/bin/prek\")),\n            Some(InstallSource::UvTool)\n        );\n    }\n\n    #[test]\n    fn does_not_match_other_uv_tool() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/home/user/.local/share/uv/tools/ruff/bin/ruff\")),\n            None\n        );\n    }\n\n    #[test]\n    fn detects_pipx_macos() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/Users/user/.local/pipx/venvs/prek/bin/prek\")),\n            Some(InstallSource::Pipx)\n        );\n    }\n\n    #[test]\n    fn detects_pipx_linux() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\n                \"/home/user/.local/share/pipx/venvs/prek/bin/prek\"\n            )),\n            Some(InstallSource::Pipx)\n        );\n    }\n\n    #[test]\n    fn does_not_match_other_pipx_package() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/home/user/.local/pipx/venvs/black/bin/black\")),\n            None\n        );\n    }\n\n    #[test]\n    fn detects_asdf() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\"/home/user/.asdf/installs/prek/0.3.1/bin/prek\")),\n            Some(InstallSource::Asdf)\n        );\n    }\n\n    #[test]\n    fn does_not_match_other_asdf_plugin() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(\n                \"/home/user/.asdf/installs/python/3.12.0/bin/python\"\n            )),\n            None\n        );\n    }\n\n    #[test]\n    #[cfg(windows)]\n    fn returns_none_for_unknown_windows_path() {\n        assert_eq!(\n            InstallSource::from_path(Path::new(r\"C:\\Program Files\\prek\\prek.exe\")),\n            None\n        );\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/bun/bun.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::InstalledHook;\nuse crate::hook::{Hook, InstallInfo};\nuse crate::languages::LanguageImpl;\nuse crate::languages::bun::BunRequest;\nuse crate::languages::bun::installer::{BunInstaller, BunResult, bin_dir, lib_dir};\nuse crate::languages::version::LanguageRequest;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{Store, ToolBucket};\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Bun;\n\nimpl LanguageImpl for Bun {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        // 1. Install bun\n        //   1) Find from `$PREK_HOME/tools/bun`\n        //   2) Find from system\n        //   3) Download from remote\n        // 2. Create env\n        // 3. Install dependencies\n\n        // 1. Install bun\n        let bun_dir = store.tools_path(ToolBucket::Bun);\n        let installer = BunInstaller::new(bun_dir);\n\n        let (bun_request, allows_download) = match &hook.language_request {\n            LanguageRequest::Any { system_only } => (&BunRequest::Any, !system_only),\n            LanguageRequest::Bun(bun_request) => (bun_request, true),\n            _ => unreachable!(),\n        };\n        let bun = installer\n            .install(store, bun_request, allows_download)\n            .await\n            .context(\"Failed to install bun\")?;\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        info.with_toolchain(bun.bun().to_path_buf());\n        // BunVersion implements Deref<Target = semver::Version>, so we clone the inner version\n        info.with_language_version((**bun.version()).clone());\n\n        // 2. Create env\n        let bin_dir = bin_dir(&info.env_path);\n        let lib_dir = lib_dir(&info.env_path);\n        fs_err::tokio::create_dir_all(&bin_dir).await?;\n        fs_err::tokio::create_dir_all(&lib_dir).await?;\n\n        // 3. Install dependencies\n        let deps = hook.install_dependencies();\n        if deps.is_empty() {\n            debug!(\"No dependencies to install\");\n        } else {\n            // `bun` needs to be in PATH for shebang scripts that use `/usr/bin/env bun`\n            let bun_bin = bun.bun().parent().expect(\"Bun binary must have parent\");\n            let new_path = prepend_paths(&[&bin_dir, bun_bin]).context(\"Failed to join PATH\")?;\n\n            // Use BUN_INSTALL to set where global packages are installed\n            // This makes `bun install -g` install to our hook environment\n            Cmd::new(bun.bun(), \"bun install\")\n                .arg(\"install\")\n                .arg(\"-g\")\n                .args(&*deps)\n                .env(EnvVars::PATH, new_path)\n                .env(EnvVars::BUN_INSTALL, &info.env_path)\n                .check(true)\n                .output()\n                .await?;\n        }\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        let bun = BunResult::from_executable(info.toolchain.clone())\n            .fill_version()\n            .await\n            .context(\"Failed to query bun version\")?;\n\n        if **bun.version() != info.language_version {\n            anyhow::bail!(\n                \"Bun version mismatch: expected {}, found {}\",\n                info.language_version,\n                bun.version()\n            );\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Bun must have env path\");\n        let bun_bin = hook.toolchain_dir().expect(\"Bun binary must have parent\");\n        let new_path =\n            prepend_paths(&[&bin_dir(env_dir), bun_bin]).context(\"Failed to join PATH\")?;\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"bun hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::BUN_INSTALL, env_dir)\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/bun/installer.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\nuse std::sync::LazyLock;\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::env_vars::EnvVars;\nuse target_lexicon::{Architecture, HOST, OperatingSystem};\nuse tracing::{debug, trace, warn};\n\nuse crate::fs::LockedFile;\nuse crate::git;\nuse crate::http::download_and_extract;\nuse crate::languages::bun::BunRequest;\nuse crate::languages::bun::version::BunVersion;\nuse crate::process::Cmd;\nuse crate::store::Store;\n\n#[derive(Debug)]\npub(crate) struct BunResult {\n    bun: PathBuf,\n    version: BunVersion,\n}\n\nimpl Display for BunResult {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}@{}\", self.bun.display(), self.version)?;\n        Ok(())\n    }\n}\n\n/// Override the Bun binary name for testing.\nstatic BUN_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {\n    if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME) {\n        name\n    } else {\n        \"bun\".to_string()\n    }\n});\n\nimpl BunResult {\n    pub(crate) fn from_executable(bun: PathBuf) -> Self {\n        Self {\n            bun,\n            version: BunVersion::default(),\n        }\n    }\n\n    pub(crate) fn from_dir(dir: &Path) -> Self {\n        let bun = bin_dir(dir).join(\"bun\").with_extension(EXE_EXTENSION);\n        Self::from_executable(bun)\n    }\n\n    pub(crate) fn with_version(mut self, version: BunVersion) -> Self {\n        self.version = version;\n        self\n    }\n\n    pub(crate) async fn fill_version(mut self) -> Result<Self> {\n        let output = Cmd::new(&self.bun, \"bun --version\")\n            .arg(\"--version\")\n            .check(true)\n            .output()\n            .await?;\n        let output_str = String::from_utf8_lossy(&output.stdout);\n        let version: BunVersion = output_str\n            .trim()\n            .parse()\n            .context(\"Failed to parse bun version\")?;\n\n        self.version = version;\n\n        Ok(self)\n    }\n\n    pub(crate) fn bun(&self) -> &Path {\n        &self.bun\n    }\n\n    pub(crate) fn version(&self) -> &BunVersion {\n        &self.version\n    }\n}\n\npub(crate) struct BunInstaller {\n    root: PathBuf,\n}\n\nimpl BunInstaller {\n    pub(crate) fn new(root: PathBuf) -> Self {\n        Self { root }\n    }\n\n    /// Install a version of Bun.\n    pub(crate) async fn install(\n        &self,\n        store: &Store,\n        request: &BunRequest,\n        allows_download: bool,\n    ) -> Result<BunResult> {\n        fs_err::tokio::create_dir_all(&self.root).await?;\n\n        let _lock = LockedFile::acquire(self.root.join(\".lock\"), \"bun\").await?;\n\n        if let Ok(bun_result) = self.find_installed(request) {\n            trace!(%bun_result, \"Found installed bun\");\n            return Ok(bun_result);\n        }\n\n        // Find all bun executables in PATH and check their versions\n        if let Some(bun_result) = self.find_system_bun(request).await? {\n            trace!(%bun_result, \"Using system bun\");\n            return Ok(bun_result);\n        }\n\n        if !allows_download {\n            anyhow::bail!(\"No suitable system Bun version found and downloads are disabled\");\n        }\n\n        let resolved_version = self.resolve_version(request).await?;\n        trace!(version = %resolved_version, \"Downloading bun\");\n\n        self.download(store, &resolved_version).await\n    }\n\n    /// Get the installed version of Bun.\n    fn find_installed(&self, req: &BunRequest) -> Result<BunResult> {\n        let mut installed = fs_err::read_dir(&self.root)\n            .ok()\n            .into_iter()\n            .flatten()\n            .filter_map(|entry| match entry {\n                Ok(entry) => Some(entry),\n                Err(err) => {\n                    warn!(?err, \"Failed to read entry\");\n                    None\n                }\n            })\n            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))\n            .filter_map(|entry| {\n                let dir_name = entry.file_name();\n                let version = BunVersion::from_str(&dir_name.to_string_lossy()).ok()?;\n                Some((version, entry.path()))\n            })\n            .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))\n            .rev();\n\n        installed\n            .find_map(|(v, path)| {\n                if req.matches(&v, Some(&path)) {\n                    Some(BunResult::from_dir(&path).with_version(v))\n                } else {\n                    None\n                }\n            })\n            .context(\"No installed bun version matches the request\")\n    }\n\n    async fn resolve_version(&self, req: &BunRequest) -> Result<BunVersion> {\n        // Latest versions come first, so we can find the latest matching version.\n        let versions = self\n            .list_remote_versions()\n            .await\n            .context(\"Failed to list remote versions\")?;\n        let version = versions\n            .into_iter()\n            .find(|version| req.matches(version, None))\n            .context(\"Version not found on remote\")?;\n        Ok(version)\n    }\n\n    /// List all versions of Bun available on GitHub releases.\n    async fn list_remote_versions(&self) -> Result<Vec<BunVersion>> {\n        let output = git::git_cmd(\"list bun tags\")?\n            .arg(\"ls-remote\")\n            .arg(\"--tags\")\n            .arg(\"https://github.com/oven-sh/bun\")\n            .output()\n            .await?\n            .stdout;\n        let output_str = str::from_utf8(&output)?;\n\n        let versions: Vec<BunVersion> = output_str\n            .lines()\n            .filter_map(|line| {\n                let reference = line.split('\\t').nth(1)?;\n                if reference.ends_with(\"^{}\") {\n                    return None;\n                }\n\n                let tag = reference.strip_prefix(\"refs/tags/\")?;\n                // Tags are in format \"bun-v1.1.0\".\n                let tag = tag.strip_prefix(\"bun-v\")?;\n                BunVersion::from_str(tag).ok()\n            })\n            .sorted_unstable_by(|a, b| b.cmp(a))\n            .collect();\n\n        Ok(versions)\n    }\n\n    /// Install a specific version of Bun.\n    async fn download(&self, store: &Store, version: &BunVersion) -> Result<BunResult> {\n        let arch = match HOST.architecture {\n            Architecture::X86_64 => \"x64\",\n            Architecture::Aarch64(_) => \"aarch64\",\n            _ => anyhow::bail!(\"Unsupported architecture\"),\n        };\n        let os = match HOST.operating_system {\n            OperatingSystem::Darwin(_) => \"darwin\",\n            OperatingSystem::Linux => \"linux\",\n            OperatingSystem::Windows => \"windows\",\n            _ => anyhow::bail!(\"Unsupported OS\"),\n        };\n\n        let filename = format!(\"bun-{os}-{arch}.zip\");\n        let url =\n            format!(\"https://github.com/oven-sh/bun/releases/download/bun-v{version}/{filename}\");\n        let target = self.root.join(version.to_string());\n\n        download_and_extract(&url, &filename, store, async |extracted| {\n            if target.exists() {\n                debug!(target = %target.display(), \"Removing existing bun\");\n                fs_err::tokio::remove_dir_all(&target).await?;\n            }\n\n            // The ZIP extracts to bun-{os}-{arch}/bun, we need to move the contents\n            // to {version}/bin/bun\n            let extracted_binary = extracted.join(\"bun\").with_extension(EXE_EXTENSION);\n            let target_bin_dir = bin_dir(&target);\n            fs_err::tokio::create_dir_all(&target_bin_dir).await?;\n\n            let target_binary = target_bin_dir.join(\"bun\").with_extension(EXE_EXTENSION);\n            debug!(?extracted_binary, target = %target_binary.display(), \"Moving bun to target\");\n            fs_err::tokio::rename(&extracted_binary, &target_binary).await?;\n\n            anyhow::Ok(())\n        })\n        .await\n        .context(\"Failed to download and extract bun\")?;\n\n        Ok(BunResult::from_dir(&target).with_version(version.clone()))\n    }\n\n    /// Find a suitable system Bun installation that matches the request.\n    async fn find_system_bun(&self, bun_request: &BunRequest) -> Result<Option<BunResult>> {\n        let bun_paths = match which::which_all(&*BUN_BINARY_NAME) {\n            Ok(paths) => paths,\n            Err(e) => {\n                debug!(\"No bun executables found in PATH: {}\", e);\n                return Ok(None);\n            }\n        };\n\n        // Check each bun executable for a matching version, stop early if found\n        for bun_path in bun_paths {\n            match BunResult::from_executable(bun_path).fill_version().await {\n                Ok(bun_result) => {\n                    // Check if this version matches the request\n                    if bun_request.matches(&bun_result.version, Some(&bun_result.bun)) {\n                        trace!(\n                            %bun_result,\n                            \"Found a matching system bun\"\n                        );\n                        return Ok(Some(bun_result));\n                    }\n                    trace!(\n                        %bun_result,\n                        \"System bun does not match requested version\"\n                    );\n                }\n                Err(e) => {\n                    warn!(?e, \"Failed to get version for system bun\");\n                }\n            }\n        }\n\n        debug!(?bun_request, \"No system bun matches the requested version\");\n        Ok(None)\n    }\n}\n\npub(crate) fn bin_dir(prefix: &Path) -> PathBuf {\n    // Bun installs global packages to $BUN_INSTALL/bin/ on all platforms\n    prefix.join(\"bin\")\n}\n\npub(crate) fn lib_dir(prefix: &Path) -> PathBuf {\n    if cfg!(windows) {\n        prefix.join(\"node_modules\")\n    } else {\n        prefix.join(\"lib\").join(\"node_modules\")\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/bun/mod.rs",
    "content": "#[allow(clippy::module_inception)]\nmod bun;\nmod installer;\nmod version;\n\npub(crate) use bun::Bun;\npub(crate) use version::BunRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/bun/version.rs",
    "content": "use std::fmt::Display;\nuse std::ops::Deref;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\n\nuse serde::Deserialize;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n#[derive(Debug, Clone, Deserialize)]\npub(crate) struct BunVersion(semver::Version);\n\nimpl Default for BunVersion {\n    fn default() -> Self {\n        BunVersion(semver::Version::new(0, 0, 0))\n    }\n}\n\nimpl Deref for BunVersion {\n    type Target = semver::Version;\n\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl Display for BunVersion {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\nimpl FromStr for BunVersion {\n    type Err = semver::Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let s = s.strip_prefix('v').unwrap_or(s).trim();\n        semver::Version::parse(s).map(BunVersion)\n    }\n}\n\n/// `language_version` field of bun can be one of the following:\n/// - `default`: Find system installed bun, or download the latest version.\n/// - `system`: Find system installed bun, or error if not found.\n/// - `bun` or `bun@latest`: Same as `default`.\n/// - `x.y` or `bun@x.y`: Install the latest version with the same major and minor version.\n/// - `x.y.z` or `bun@x.y.z`: Install the specific version.\n/// - `^x.y.z`: Install the latest version that satisfies the semver requirement.\n///   Or any other semver compatible version requirement.\n/// - `local/path/to/bun`: Use bun executable at the specified path.\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum BunRequest {\n    Any,\n    Major(u64),\n    MajorMinor(u64, u64),\n    MajorMinorPatch(u64, u64, u64),\n    Path(PathBuf),\n    Range(semver::VersionReq),\n}\n\nimpl FromStr for BunRequest {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        if s.is_empty() {\n            return Ok(BunRequest::Any);\n        }\n\n        // Handle \"bun\" or \"bun@version\" format\n        if let Some(version_part) = s.strip_prefix(\"bun@\") {\n            if version_part.eq_ignore_ascii_case(\"latest\") {\n                return Ok(BunRequest::Any);\n            }\n            return Self::parse_version_numbers(version_part, s);\n        }\n\n        if s == \"bun\" {\n            return Ok(BunRequest::Any);\n        }\n\n        Self::parse_version_numbers(s, s)\n            .or_else(|_| {\n                semver::VersionReq::parse(s)\n                    .map(BunRequest::Range)\n                    .map_err(|_| Error::InvalidVersion(s.to_string()))\n            })\n            .or_else(|_| {\n                let path = PathBuf::from(s);\n                if path.exists() {\n                    Ok(BunRequest::Path(path))\n                } else {\n                    Err(Error::InvalidVersion(s.to_string()))\n                }\n            })\n    }\n}\n\nimpl BunRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, BunRequest::Any)\n    }\n\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<BunRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n\n        match parts.as_slice() {\n            [major] => Ok(BunRequest::Major(*major)),\n            [major, minor] => Ok(BunRequest::MajorMinor(*major, *minor)),\n            [major, minor, patch] => Ok(BunRequest::MajorMinorPatch(*major, *minor, *patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        let version = &install_info.language_version;\n        self.matches(\n            &BunVersion(version.clone()),\n            Some(install_info.toolchain.as_ref()),\n        )\n    }\n\n    pub(crate) fn matches(&self, version: &BunVersion, toolchain: Option<&Path>) -> bool {\n        match self {\n            Self::Any => true,\n            Self::Major(major) => version.major == *major,\n            Self::MajorMinor(major, minor) => version.major == *major && version.minor == *minor,\n            Self::MajorMinorPatch(major, minor, patch) => {\n                version.major == *major && version.minor == *minor && version.patch == *patch\n            }\n            // FIXME: consider resolving symlinks and normalizing paths before comparison\n            Self::Path(path) => toolchain.is_some_and(|toolchain_path| toolchain_path == path),\n            Self::Range(req) => req.matches(version),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_bun_version_from_str() {\n        let v: BunVersion = \"1.1.0\".parse().unwrap();\n        assert_eq!(v.major, 1);\n        assert_eq!(v.minor, 1);\n        assert_eq!(v.patch, 0);\n\n        let v: BunVersion = \"v1.2.3\".parse().unwrap();\n        assert_eq!(v.major, 1);\n        assert_eq!(v.minor, 2);\n        assert_eq!(v.patch, 3);\n    }\n\n    #[test]\n    fn test_bun_request_from_str() {\n        assert_eq!(BunRequest::from_str(\"bun\").unwrap(), BunRequest::Any);\n        assert_eq!(BunRequest::from_str(\"bun@latest\").unwrap(), BunRequest::Any);\n        assert_eq!(BunRequest::from_str(\"\").unwrap(), BunRequest::Any);\n\n        assert_eq!(BunRequest::from_str(\"1\").unwrap(), BunRequest::Major(1));\n        assert_eq!(BunRequest::from_str(\"bun@1\").unwrap(), BunRequest::Major(1));\n\n        assert_eq!(\n            BunRequest::from_str(\"1.1\").unwrap(),\n            BunRequest::MajorMinor(1, 1)\n        );\n        assert_eq!(\n            BunRequest::from_str(\"bun@1.1\").unwrap(),\n            BunRequest::MajorMinor(1, 1)\n        );\n\n        assert_eq!(\n            BunRequest::from_str(\"1.1.0\").unwrap(),\n            BunRequest::MajorMinorPatch(1, 1, 0)\n        );\n        assert_eq!(\n            BunRequest::from_str(\"bun@1.1.0\").unwrap(),\n            BunRequest::MajorMinorPatch(1, 1, 0)\n        );\n    }\n\n    #[test]\n    fn test_bun_request_range() {\n        let req = BunRequest::from_str(\">=1.0\").unwrap();\n        assert!(matches!(req, BunRequest::Range(_)));\n\n        let req = BunRequest::from_str(\">=1.0, <2.0\").unwrap();\n        assert!(matches!(req, BunRequest::Range(_)));\n    }\n\n    #[test]\n    fn test_bun_request_invalid() {\n        assert!(BunRequest::from_str(\"1.1.0.1\").is_err());\n        assert!(BunRequest::from_str(\"1.1a\").is_err());\n        assert!(BunRequest::from_str(\"invalid\").is_err());\n    }\n\n    #[test]\n    fn test_bun_request_matches() {\n        let version = BunVersion(semver::Version::new(1, 1, 4));\n\n        assert!(BunRequest::Any.matches(&version, None));\n        assert!(BunRequest::Major(1).matches(&version, None));\n        assert!(!BunRequest::Major(2).matches(&version, None));\n        assert!(BunRequest::MajorMinor(1, 1).matches(&version, None));\n        assert!(!BunRequest::MajorMinor(1, 2).matches(&version, None));\n        assert!(BunRequest::MajorMinorPatch(1, 1, 4).matches(&version, None));\n        assert!(!BunRequest::MajorMinorPatch(1, 1, 5).matches(&version, None));\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/deno/deno.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::languages::deno::DenoRequest;\nuse crate::languages::deno::installer::{DenoInstaller, DenoResult, bin_dir};\nuse crate::languages::version::LanguageRequest;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{CacheBucket, Store, ToolBucket};\n\nfn is_valid_install_name(name: &str) -> bool {\n    let mut chars = name.chars();\n    matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric())\n        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')\n}\n\n/// Parse a Deno `additional_dependencies` item.\n///\n/// Deno support treats every additional dependency as an executable install target for\n/// `deno install --global`. That makes the contract explicit and avoids guessing whether\n/// a string should be handled as `deno add` or `deno install`.\n///\n/// The optional `:name` suffix is interpreted as `deno install --name <name>`, but only\n/// when the left side clearly looks like an install target that may legitimately contain\n/// colons itself:\n/// - specifiers such as `npm:semver@7`\n/// - URLs such as `https://...`\n/// - local paths such as `./cli.ts`\n///\n/// Plain command strings are left untouched so we do not accidentally split on a colon\n/// that is part of the dependency string.\nfn parse_install_dependency(spec: &str) -> (&str, Option<&str>) {\n    let Some((dep, name)) = spec.rsplit_once(':') else {\n        return (spec, None);\n    };\n\n    let looks_like_path = dep.starts_with('.') || dep.starts_with('/') || dep.contains(['/', '\\\\']);\n\n    if is_valid_install_name(name) && (looks_like_path || dep.contains(':')) {\n        (dep, Some(name))\n    } else {\n        (spec, None)\n    }\n}\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Deno;\n\nimpl LanguageImpl for Deno {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        // 1. Install deno\n        let deno_dir = store.tools_path(ToolBucket::Deno);\n        let installer = DenoInstaller::new(deno_dir);\n\n        let (deno_request, allows_download) = match &hook.language_request {\n            LanguageRequest::Any { system_only } => (&DenoRequest::Any, !system_only),\n            LanguageRequest::Deno(deno_request) => (deno_request, true),\n            _ => unreachable!(),\n        };\n        let deno = installer\n            .install(store, deno_request, allows_download)\n            .await\n            .context(\"Failed to install deno\")?;\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        info.with_toolchain(deno.deno().to_path_buf());\n        info.with_language_version((**deno.version()).clone());\n\n        // 2. Create env\n        let env_bin_dir = bin_dir(&info.env_path);\n        fs_err::tokio::create_dir_all(&env_bin_dir).await?;\n\n        // Relative install targets in `additional_dependencies` are resolved by Deno\n        // against the process working directory. For remote hooks that should be the\n        // cloned hook repository so `./cli.ts:name` refers to files shipped by the hook.\n        // For local hooks we keep resolution in the user's work tree.\n        let install_dir = hook.repo_path().unwrap_or(hook.work_dir());\n\n        // We share one Deno cache bucket across install and run. Executable shims live in\n        // the per-hook env bin dir, while downloaded modules and npm artifacts are reused\n        // from this cache bucket.\n        let deno_cache_dir = store.cache_path(CacheBucket::Deno);\n        fs_err::tokio::create_dir_all(&deno_cache_dir).await?;\n\n        // 3. Install additional dependencies as executables in the hook env.\n        //\n        // Current Deno contract:\n        // - prek does not try to install the remote hook repo itself\n        // - prek does not inspect or rewrite `entry` to derive install targets\n        // - every `additional_dependencies` item is provisioned into `<env>/bin`\n        //\n        // This keeps installation and execution separate. If a remote hook repo wants to\n        // expose its own executable, it must declare a local file in\n        // `additional_dependencies`, for example `./cli.ts:repo-tool`, and then use\n        // `repo-tool` in `entry`.\n        //\n        // We intentionally pass `--allow-all` because `deno install` bakes permissions into\n        // the installed wrapper. Since prek does not parse `entry` or repo metadata to infer\n        // a minimal permission set, the simplest predictable behavior is to install the\n        // executable with full permissions and let the hook author choose the installed\n        // command name explicitly when needed via `dep:name`.\n        if !hook.additional_dependencies.is_empty() {\n            debug!(deps = ?hook.additional_dependencies, \"Installing deno dependencies\");\n        }\n        for spec in &hook.additional_dependencies {\n            let (dep, name) = parse_install_dependency(spec);\n\n            let mut install_cmd = Cmd::new(deno.deno(), \"deno install dependency\");\n            install_cmd\n                .current_dir(install_dir)\n                .env(EnvVars::DENO_DIR, &deno_cache_dir)\n                .env(EnvVars::DENO_NO_UPDATE_CHECK, \"1\")\n                .arg(\"install\")\n                .arg(\"--allow-all\")\n                .arg(\"--global\")\n                .arg(\"--force\")\n                .arg(\"--root\")\n                .arg(&info.env_path);\n\n            if let Some(name) = name {\n                install_cmd.arg(\"--name\").arg(name);\n            }\n\n            install_cmd\n                .arg(dep)\n                .check(true)\n                .output()\n                .await\n                .with_context(|| format!(\"Failed to install deno dependency `{spec}`\"))?;\n        }\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        let deno = DenoResult::from_executable(info.toolchain.clone())\n            .fill_version()\n            .await\n            .context(\"Failed to query deno version\")?;\n\n        if **deno.version() != info.language_version {\n            anyhow::bail!(\n                \"Deno version mismatch: expected {}, found {}\",\n                info.language_version,\n                deno.version()\n            );\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let deno_cache_dir = store.cache_path(CacheBucket::Deno);\n        let info = hook.install_info().expect(\"Deno must be installed\");\n        let deno_binary = &info.toolchain;\n        let env_dir = &info.env_path;\n        let deno_bin_dir = deno_binary.parent().expect(\"Deno binary must have parent\");\n        let new_path =\n            prepend_paths(&[&bin_dir(env_dir), deno_bin_dir]).context(\"Failed to join PATH\")?;\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n\n        let run = async |batch: &[&Path]| {\n            let mut cmd = Cmd::new(&entry[0], \"deno hook\");\n            let mut output = cmd\n                .current_dir(hook.work_dir())\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::DENO_DIR, &deno_cache_dir)\n                .env(EnvVars::DENO_NO_UPDATE_CHECK, \"1\")\n                .envs(&hook.env)\n                .args(&entry[1..])\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::parse_install_dependency;\n\n    #[test]\n    fn parse_install_dependency_without_name() {\n        assert_eq!(\n            parse_install_dependency(\"npm:prettier@3\"),\n            (\"npm:prettier@3\", None)\n        );\n    }\n\n    #[test]\n    fn parse_install_dependency_with_name() {\n        assert_eq!(\n            parse_install_dependency(\"npm:prettier@3:fmt-tool\"),\n            (\"npm:prettier@3\", Some(\"fmt-tool\"))\n        );\n    }\n\n    #[test]\n    fn parse_install_dependency_with_local_path_name() {\n        assert_eq!(\n            parse_install_dependency(\"./tools/echo.ts:echo-tool\"),\n            (\"./tools/echo.ts\", Some(\"echo-tool\"))\n        );\n    }\n\n    #[test]\n    fn parse_install_dependency_with_invalid_name_keeps_original() {\n        assert_eq!(\n            parse_install_dependency(\"./tools/echo.ts:not valid\"),\n            (\"./tools/echo.ts:not valid\", None)\n        );\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/deno/installer.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\nuse std::sync::LazyLock;\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::env_vars::EnvVars;\nuse serde::Deserialize;\nuse target_lexicon::{Architecture, HOST, OperatingSystem};\nuse tracing::{debug, trace, warn};\n\nuse crate::fs::LockedFile;\nuse crate::http::{REQWEST_CLIENT, download_and_extract};\nuse crate::languages::deno::DenoRequest;\nuse crate::languages::deno::version::DenoVersion;\nuse crate::process::Cmd;\nuse crate::store::Store;\n\n#[derive(Debug)]\npub(crate) struct DenoResult {\n    deno: PathBuf,\n    version: DenoVersion,\n}\n\nimpl Display for DenoResult {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}@{}\", self.deno.display(), self.version)?;\n        Ok(())\n    }\n}\n\n/// Override the Deno binary name for testing.\nstatic DENO_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {\n    if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__DENO_BINARY_NAME) {\n        name\n    } else {\n        \"deno\".to_string()\n    }\n});\n\nimpl DenoResult {\n    pub(crate) fn from_executable(deno: PathBuf) -> Self {\n        Self {\n            deno,\n            version: DenoVersion::default(),\n        }\n    }\n\n    pub(crate) fn from_dir(dir: &Path) -> Self {\n        let deno = bin_dir(dir).join(\"deno\").with_extension(EXE_EXTENSION);\n        Self::from_executable(deno)\n    }\n\n    pub(crate) fn with_version(mut self, version: DenoVersion) -> Self {\n        self.version = version;\n        self\n    }\n\n    pub(crate) async fn fill_version(mut self) -> Result<Self> {\n        let output = Cmd::new(&self.deno, \"deno --version\")\n            .env(EnvVars::DENO_NO_UPDATE_CHECK, \"1\")\n            .arg(\"--version\")\n            .check(true)\n            .output()\n            .await?;\n        // Output format: \"deno 2.1.0 (release, x86_64-unknown-linux-gnu)\\n...\"\n        let output_str = String::from_utf8_lossy(&output.stdout);\n        let version_str = output_str\n            .lines()\n            .next()\n            .and_then(|line| line.strip_prefix(\"deno \"))\n            .and_then(|rest| rest.split_whitespace().next())\n            .context(\"Failed to parse deno version output\")?;\n\n        self.version = version_str\n            .parse()\n            .context(\"Failed to parse deno version\")?;\n\n        Ok(self)\n    }\n\n    pub(crate) fn deno(&self) -> &Path {\n        &self.deno\n    }\n\n    pub(crate) fn version(&self) -> &DenoVersion {\n        &self.version\n    }\n}\n\npub(crate) struct DenoInstaller {\n    root: PathBuf,\n}\n\nimpl DenoInstaller {\n    pub(crate) fn new(root: PathBuf) -> Self {\n        Self { root }\n    }\n\n    /// Install a version of Deno.\n    pub(crate) async fn install(\n        &self,\n        store: &Store,\n        request: &DenoRequest,\n        allows_download: bool,\n    ) -> Result<DenoResult> {\n        fs_err::tokio::create_dir_all(&self.root).await?;\n\n        let _lock = LockedFile::acquire(self.root.join(\".lock\"), \"deno\").await?;\n\n        if let Ok(deno_result) = self.find_installed(request) {\n            trace!(%deno_result, \"Found installed deno\");\n            return Ok(deno_result);\n        }\n\n        // Find all deno executables in PATH and check their versions\n        if let Some(deno_result) = self.find_system_deno(request).await? {\n            trace!(%deno_result, \"Using system deno\");\n            return Ok(deno_result);\n        }\n\n        if !allows_download {\n            anyhow::bail!(\"No suitable system Deno version found and downloads are disabled\");\n        }\n\n        let resolved_version = self.resolve_version(request).await?;\n        trace!(version = %resolved_version, \"Downloading deno\");\n\n        self.download(store, &resolved_version).await\n    }\n\n    /// Get the installed version of Deno.\n    fn find_installed(&self, req: &DenoRequest) -> Result<DenoResult> {\n        let mut installed = fs_err::read_dir(&self.root)\n            .ok()\n            .into_iter()\n            .flatten()\n            .filter_map(|entry| match entry {\n                Ok(entry) => Some(entry),\n                Err(err) => {\n                    warn!(?err, \"Failed to read entry\");\n                    None\n                }\n            })\n            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))\n            .filter_map(|entry| {\n                let dir_name = entry.file_name();\n                let version = DenoVersion::from_str(&dir_name.to_string_lossy()).ok()?;\n                Some((version, entry.path()))\n            })\n            .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))\n            .rev();\n\n        installed\n            .find_map(|(v, path)| {\n                if req.matches(&v, Some(&path)) {\n                    Some(DenoResult::from_dir(&path).with_version(v))\n                } else {\n                    None\n                }\n            })\n            .context(\"No installed deno version matches the request\")\n    }\n\n    async fn resolve_version(&self, req: &DenoRequest) -> Result<DenoVersion> {\n        // Latest versions come first, so we can find the latest matching version.\n        let versions = self\n            .list_remote_versions()\n            .await\n            .context(\"Failed to list remote versions\")?;\n        let version = versions\n            .into_iter()\n            .find(|version| req.matches(version, None))\n            .context(\"Version not found on remote\")?;\n        Ok(version)\n    }\n\n    /// List all versions of Deno available from the official versions endpoint.\n    ///\n    /// Uses <https://deno.com/versions.json> which is lightweight and doesn't\n    /// have rate-limit issues like the GitHub API.\n    async fn list_remote_versions(&self) -> Result<Vec<DenoVersion>> {\n        #[derive(Deserialize)]\n        struct VersionsResponse {\n            cli: Vec<String>,\n        }\n\n        let url = \"https://deno.com/versions.json\";\n        let response: VersionsResponse = REQWEST_CLIENT.get(url).send().await?.json().await?;\n\n        // Versions are already sorted in descending order (newest first)\n        let versions: Vec<DenoVersion> = response\n            .cli\n            .into_iter()\n            .filter_map(|v| DenoVersion::from_str(&v).ok())\n            .collect();\n\n        if versions.is_empty() {\n            anyhow::bail!(\"No Deno versions found\");\n        }\n\n        Ok(versions)\n    }\n\n    /// Install a specific version of Deno.\n    async fn download(&self, store: &Store, version: &DenoVersion) -> Result<DenoResult> {\n        let arch = match HOST.architecture {\n            Architecture::X86_64 => \"x86_64\",\n            Architecture::Aarch64(_) => \"aarch64\",\n            _ => anyhow::bail!(\"Unsupported architecture for Deno\"),\n        };\n\n        let os = match HOST.operating_system {\n            OperatingSystem::Darwin(_) => \"apple-darwin\",\n            OperatingSystem::Linux => \"unknown-linux-gnu\",\n            OperatingSystem::Windows => \"pc-windows-msvc\",\n            _ => anyhow::bail!(\"Unsupported OS for Deno\"),\n        };\n\n        let filename = format!(\"deno-{arch}-{os}.zip\");\n        let url = format!(\"https://dl.deno.land/release/v{version}/{filename}\");\n        let target = self.root.join(version.to_string());\n\n        download_and_extract(&url, &filename, store, async |extracted| {\n            if target.exists() {\n                debug!(target = %target.display(), \"Removing existing deno\");\n                fs_err::tokio::remove_dir_all(&target).await?;\n            }\n\n            // Deno ZIP contains just the binary at the root level.\n            // After strip_component, `extracted` may be the binary itself (if singular)\n            // or a directory containing the binary.\n            let extracted_binary = if extracted.is_file() {\n                extracted.to_path_buf()\n            } else {\n                extracted.join(\"deno\").with_extension(EXE_EXTENSION)\n            };\n\n            let target_bin_dir = bin_dir(&target);\n            fs_err::tokio::create_dir_all(&target_bin_dir).await?;\n\n            let target_binary = target_bin_dir.join(\"deno\").with_extension(EXE_EXTENSION);\n            debug!(?extracted_binary, target = %target_binary.display(), \"Moving deno to target\");\n            fs_err::tokio::rename(&extracted_binary, &target_binary).await?;\n\n            #[cfg(unix)]\n            {\n                use std::os::unix::fs::PermissionsExt;\n                let mut perms = fs_err::tokio::metadata(&target_binary).await?.permissions();\n                perms.set_mode(0o755);\n                fs_err::tokio::set_permissions(&target_binary, perms).await?;\n            }\n\n            anyhow::Ok(())\n        })\n        .await\n        .context(\"Failed to download and extract deno\")?;\n\n        Ok(DenoResult::from_dir(&target).with_version(version.clone()))\n    }\n\n    /// Find a suitable system Deno installation that matches the request.\n    async fn find_system_deno(&self, deno_request: &DenoRequest) -> Result<Option<DenoResult>> {\n        let deno_paths = match which::which_all(&*DENO_BINARY_NAME) {\n            Ok(paths) => paths,\n            Err(e) => {\n                debug!(\"No deno executables found in PATH: {}\", e);\n                return Ok(None);\n            }\n        };\n\n        // Check each deno executable for a matching version, stop early if found\n        for deno_path in deno_paths {\n            match DenoResult::from_executable(deno_path).fill_version().await {\n                Ok(deno_result) => {\n                    // Check if this version matches the request\n                    if deno_request.matches(&deno_result.version, Some(&deno_result.deno)) {\n                        trace!(\n                            %deno_result,\n                            \"Found a matching system deno\"\n                        );\n                        return Ok(Some(deno_result));\n                    }\n                    trace!(\n                        %deno_result,\n                        \"System deno does not match requested version\"\n                    );\n                }\n                Err(e) => {\n                    warn!(?e, \"Failed to get version for system deno\");\n                }\n            }\n        }\n\n        debug!(\n            ?deno_request,\n            \"No system deno matches the requested version\"\n        );\n        Ok(None)\n    }\n}\n\npub(crate) fn bin_dir(prefix: &Path) -> PathBuf {\n    prefix.join(\"bin\")\n}\n"
  },
  {
    "path": "crates/prek/src/languages/deno/mod.rs",
    "content": "#[allow(clippy::module_inception)]\nmod deno;\npub(crate) mod installer;\npub(crate) mod version;\n\npub(crate) use deno::Deno;\npub(crate) use version::DenoRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/deno/version.rs",
    "content": "use std::fmt::Display;\nuse std::ops::Deref;\nuse std::path::Path;\nuse std::str::FromStr;\n\nuse serde::Deserialize;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n#[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)]\npub(crate) struct DenoVersion(semver::Version);\n\nimpl Default for DenoVersion {\n    fn default() -> Self {\n        DenoVersion(semver::Version::new(0, 0, 0))\n    }\n}\n\nimpl Deref for DenoVersion {\n    type Target = semver::Version;\n\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl Display for DenoVersion {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\nimpl FromStr for DenoVersion {\n    type Err = semver::Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let s = s.strip_prefix('v').unwrap_or(s).trim();\n        semver::Version::parse(s).map(DenoVersion)\n    }\n}\n\n/// `language_version` field of deno can be one of the following:\n/// - `default`: Find system installed deno, or download the latest version.\n/// - `system`: Find system installed deno, or error if not found.\n/// - `deno` or `deno@latest`: Same as `default`.\n/// - `x.y` or `deno@x.y`: Install the latest version with the same major and minor version.\n/// - `x.y.z` or `deno@x.y.z`: Install the specific version.\n/// - `^x.y.z`: Install the latest version that satisfies the semver requirement.\n///   Or any other semver compatible version requirement.\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum DenoRequest {\n    Any,\n    Major(u64),\n    MajorMinor(u64, u64),\n    MajorMinorPatch(u64, u64, u64),\n    Range(semver::VersionReq),\n}\n\nimpl FromStr for DenoRequest {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        if s.is_empty() {\n            return Ok(DenoRequest::Any);\n        }\n\n        // Handle \"deno\" or \"deno@version\" format\n        if let Some(version_part) = s.strip_prefix(\"deno@\") {\n            if version_part.eq_ignore_ascii_case(\"latest\") {\n                return Ok(DenoRequest::Any);\n            }\n            return Self::parse_version_numbers(version_part, s);\n        }\n\n        if s == \"deno\" {\n            return Ok(DenoRequest::Any);\n        }\n\n        Self::parse_version_numbers(s, s).or_else(|_| {\n            semver::VersionReq::parse(s)\n                .map(DenoRequest::Range)\n                .map_err(|_| Error::InvalidVersion(s.to_string()))\n        })\n    }\n}\n\nimpl DenoRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, DenoRequest::Any)\n    }\n\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<DenoRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n\n        match parts.as_slice() {\n            [major] => Ok(DenoRequest::Major(*major)),\n            [major, minor] => Ok(DenoRequest::MajorMinor(*major, *minor)),\n            [major, minor, patch] => Ok(DenoRequest::MajorMinorPatch(*major, *minor, *patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        let version = &install_info.language_version;\n        self.matches(\n            &DenoVersion(version.clone()),\n            Some(install_info.toolchain.as_ref()),\n        )\n    }\n\n    pub(crate) fn matches(&self, version: &DenoVersion, _toolchain: Option<&Path>) -> bool {\n        match self {\n            Self::Any => true,\n            Self::Major(major) => version.major == *major,\n            Self::MajorMinor(major, minor) => version.major == *major && version.minor == *minor,\n            Self::MajorMinorPatch(major, minor, patch) => {\n                version.major == *major && version.minor == *minor && version.patch == *patch\n            }\n            Self::Range(req) => req.matches(version),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_deno_version_from_str() {\n        let v: DenoVersion = \"2.1.0\".parse().unwrap();\n        assert_eq!(v.major, 2);\n        assert_eq!(v.minor, 1);\n        assert_eq!(v.patch, 0);\n\n        let v: DenoVersion = \"v2.1.3\".parse().unwrap();\n        assert_eq!(v.major, 2);\n        assert_eq!(v.minor, 1);\n        assert_eq!(v.patch, 3);\n    }\n\n    #[test]\n    fn test_deno_request_from_str() {\n        assert_eq!(DenoRequest::from_str(\"deno\").unwrap(), DenoRequest::Any);\n        assert_eq!(\n            DenoRequest::from_str(\"deno@latest\").unwrap(),\n            DenoRequest::Any\n        );\n        assert_eq!(DenoRequest::from_str(\"\").unwrap(), DenoRequest::Any);\n\n        assert_eq!(DenoRequest::from_str(\"2\").unwrap(), DenoRequest::Major(2));\n        assert_eq!(\n            DenoRequest::from_str(\"deno@2\").unwrap(),\n            DenoRequest::Major(2)\n        );\n\n        assert_eq!(\n            DenoRequest::from_str(\"2.1\").unwrap(),\n            DenoRequest::MajorMinor(2, 1)\n        );\n        assert_eq!(\n            DenoRequest::from_str(\"deno@2.1\").unwrap(),\n            DenoRequest::MajorMinor(2, 1)\n        );\n\n        assert_eq!(\n            DenoRequest::from_str(\"2.1.0\").unwrap(),\n            DenoRequest::MajorMinorPatch(2, 1, 0)\n        );\n        assert_eq!(\n            DenoRequest::from_str(\"deno@2.1.0\").unwrap(),\n            DenoRequest::MajorMinorPatch(2, 1, 0)\n        );\n    }\n\n    #[test]\n    fn test_deno_request_range() {\n        let req = DenoRequest::from_str(\">=2.0\").unwrap();\n        assert!(matches!(req, DenoRequest::Range(_)));\n\n        let req = DenoRequest::from_str(\">=2.0, <3.0\").unwrap();\n        assert!(matches!(req, DenoRequest::Range(_)));\n    }\n\n    #[test]\n    fn test_deno_request_invalid() {\n        assert!(DenoRequest::from_str(\"2.1.0.1\").is_err());\n        assert!(DenoRequest::from_str(\"2.1a\").is_err());\n        assert!(DenoRequest::from_str(\"invalid\").is_err());\n    }\n\n    #[test]\n    fn test_deno_request_matches() {\n        let version = DenoVersion(semver::Version::new(2, 1, 4));\n\n        assert!(DenoRequest::Any.matches(&version, None));\n        assert!(DenoRequest::Major(2).matches(&version, None));\n        assert!(!DenoRequest::Major(1).matches(&version, None));\n        assert!(DenoRequest::MajorMinor(2, 1).matches(&version, None));\n        assert!(!DenoRequest::MajorMinor(2, 2).matches(&version, None));\n        assert!(DenoRequest::MajorMinorPatch(2, 1, 4).matches(&version, None));\n        assert!(!DenoRequest::MajorMinorPatch(2, 1, 5).matches(&version, None));\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/docker.rs",
    "content": "use std::borrow::Cow;\nuse std::collections::BTreeSet;\nuse std::collections::hash_map::DefaultHasher;\nuse std::fs;\nuse std::hash::{Hash, Hasher};\nuse std::path::{Path, PathBuf};\nuse std::process::{Command, Stdio};\nuse std::str::FromStr;\nuse std::sync::{Arc, LazyLock};\n\nuse anyhow::{Context, Result};\nuse lazy_regex::regex;\nuse prek_consts::env_vars::EnvVars;\nuse tracing::{trace, warn};\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::process::Cmd;\nuse crate::run::{USE_COLOR, run_by_batch};\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Docker;\n\n#[derive(Debug, thiserror::Error)]\nenum Error {\n    #[error(\"Failed to parse docker inspect output: {0}\")]\n    Serde(#[from] serde_json::Error),\n\n    #[error(\"Failed to run `docker inspect`: {0}\")]\n    Process(#[from] std::io::Error),\n}\n\n/// Check if the current process is running inside a Docker container.\n/// see <https://stackoverflow.com/questions/23513045/how-to-check-if-a-process-is-running-inside-docker-container>\nfn is_in_docker() -> bool {\n    if fs::metadata(\"/.dockerenv\").is_ok() || fs::metadata(\"/run/.containerenv\").is_ok() {\n        return true;\n    }\n    false\n}\n\n/// Get container id the process is running in.\n///\n/// There are no reliable way to get the container id inside container, see\n/// <https://stackoverflow.com/questions/20995351/how-can-i-get-docker-linux-container-information-from-within-the-container-itsel>\n/// for details.\n///\n/// Adapted from <https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/7167/files>\n/// Uses `/proc/self/cgroup` for cgroup v1,\n/// uses `/proc/self/mountinfo` for cgroup v2\nfn current_container_id() -> Result<String> {\n    current_container_id_from_paths(\"/proc/self/cgroup\", \"/proc/self/mountinfo\")\n}\n\nfn current_container_id_from_paths(\n    cgroup_path: impl AsRef<Path>,\n    mountinfo_path: impl AsRef<Path>,\n) -> Result<String> {\n    if let Ok(container_id) = container_id_from_cgroup_v1(cgroup_path) {\n        return Ok(container_id);\n    }\n    container_id_from_cgroup_v2(mountinfo_path)\n}\n\nfn container_id_from_cgroup_v1(cgroup: impl AsRef<Path>) -> Result<String> {\n    let content = fs::read_to_string(cgroup).context(\"Failed to read cgroup v1 info\")?;\n    content\n        .lines()\n        .find_map(parse_id_from_line)\n        .context(\"Failed to detect Docker container id from cgroup v1\")\n}\n\nfn parse_id_from_line(line: &str) -> Option<String> {\n    let last_slash_idx = line.rfind('/')?;\n\n    let last_section = &line[last_slash_idx + 1..];\n\n    let container_id = if let Some(colon_idx) = last_section.rfind(':') {\n        // Since containerd v1.5.0+, containerId is divided by the last colon when the\n        // cgroupDriver is systemd:\n        // https://github.com/containerd/containerd/blob/release/1.5/pkg/cri/server/helpers_linux.go#L64\n        last_section[colon_idx + 1..].to_string()\n    } else {\n        let start_idx = last_section.rfind('-').map(|i| i + 1).unwrap_or(0);\n        let end_idx = last_section.rfind('.').unwrap_or(last_section.len());\n\n        if start_idx > end_idx {\n            return None;\n        }\n\n        last_section[start_idx..end_idx].to_string()\n    };\n\n    if container_id.len() == 64 && container_id.chars().all(|c| c.is_ascii_hexdigit()) {\n        return Some(container_id);\n    }\n    None\n}\n\nfn container_id_from_cgroup_v2(mount_info: impl AsRef<Path>) -> Result<String> {\n    let content = fs::read_to_string(mount_info).context(\"Failed to read cgroup v2 mount info\")?;\n    regex!(r\".*/(containers|overlay-containers)/([0-9a-f]{64})/.*\")\n        .captures(&content)\n        .and_then(|caps| caps.get(2))\n        .map(|m| m.as_str().to_owned())\n        .context(\"Failed to find Docker container id in cgroup v2 mount info\")\n}\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\nenum RuntimeKind {\n    Auto,\n    AppleContainer,\n    Docker,\n    Podman,\n}\n\nimpl FromStr for RuntimeKind {\n    type Err = String;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s.to_ascii_lowercase().as_str() {\n            \"container\" => Ok(RuntimeKind::AppleContainer),\n            \"docker\" => Ok(RuntimeKind::Docker),\n            \"podman\" => Ok(RuntimeKind::Podman),\n            \"auto\" => Ok(RuntimeKind::Auto),\n            _ => Err(format!(\"Invalid container runtime: {s}\")),\n        }\n    }\n}\n\n#[derive(serde::Deserialize, Debug)]\nstruct Mount {\n    #[serde(rename = \"Source\")]\n    source: String,\n    #[serde(rename = \"Destination\")]\n    destination: String,\n}\n\nimpl RuntimeKind {\n    fn cmd(&self) -> &str {\n        match self {\n            RuntimeKind::AppleContainer => \"container\",\n            RuntimeKind::Docker => \"docker\",\n            RuntimeKind::Podman => \"podman\",\n            RuntimeKind::Auto => unreachable!(\"Auto should be resolved before use\"),\n        }\n    }\n\n    /// Detect if the current runtime is rootless.\n    fn detect_rootless(self) -> Result<bool> {\n        match self {\n            RuntimeKind::AppleContainer => Ok(false),\n            RuntimeKind::Docker => {\n                let output = Command::new(self.cmd())\n                    .arg(\"info\")\n                    .arg(\"--format\")\n                    .arg(\"'{{ .SecurityOptions }}'\")\n                    .output()?;\n\n                let stdout = str::from_utf8(&output.stdout)?;\n                Ok(stdout.contains(\"name=rootless\"))\n            }\n            RuntimeKind::Podman => {\n                let output = Command::new(self.cmd())\n                    .arg(\"info\")\n                    .arg(\"--format\")\n                    .arg(\"{{ .Host.Security.Rootless -}}\")\n                    .output()?;\n\n                let stdout = str::from_utf8(&output.stdout)?;\n                Ok(stdout.eq_ignore_ascii_case(\"true\"))\n            }\n            RuntimeKind::Auto => unreachable!(\"Auto should be resolved before use\"),\n        }\n    }\n\n    /// List the mounts of the current container.\n    fn list_mounts(self) -> Result<Vec<Mount>> {\n        if !is_in_docker() {\n            anyhow::bail!(\"Not in a container\");\n        }\n\n        let container_id = current_container_id()?;\n        trace!(?container_id, \"In Docker container\");\n\n        let output = Command::new(self.cmd())\n            .arg(\"inspect\")\n            .arg(\"--format\")\n            .arg(\"'{{json .Mounts}}'\")\n            .arg(&container_id)\n            .output()?\n            .stdout;\n        let stdout = String::from_utf8_lossy(&output);\n        let stdout = stdout.trim().trim_matches('\\'');\n        let mounts: Vec<Mount> = serde_json::from_str(stdout)?;\n\n        trace!(?mounts, \"Get docker mounts\");\n        Ok(mounts)\n    }\n}\n\nstruct ContainerRuntimeInfo {\n    runtime: RuntimeKind,\n    rootless: bool,\n    mounts: Vec<Mount>,\n}\n\nimpl ContainerRuntimeInfo {\n    /// Detect container runtime provider, prioritise docker over podman if\n    /// both are on the path, unless `PREK_CONTAINER_RUNTIME` is set to override detection.\n    fn resolve_runtime_kind<DF, PF, CF>(\n        env_override: Option<String>,\n        docker_available: DF,\n        podman_available: PF,\n        apple_container_available: CF,\n    ) -> RuntimeKind\n    where\n        DF: Fn() -> bool,\n        PF: Fn() -> bool,\n        CF: Fn() -> bool,\n    {\n        if let Some(val) = env_override {\n            match RuntimeKind::from_str(&val) {\n                Ok(runtime) => {\n                    if runtime != RuntimeKind::Auto {\n                        trace!(\n                            \"Container runtime overridden by {}={}\",\n                            EnvVars::PREK_CONTAINER_RUNTIME,\n                            val\n                        );\n                        return runtime;\n                    }\n                }\n                Err(_) => {\n                    warn!(\n                        \"Invalid value for {}: {}, falling back to auto detection\",\n                        EnvVars::PREK_CONTAINER_RUNTIME,\n                        val\n                    );\n                }\n            }\n        }\n\n        if docker_available() {\n            return RuntimeKind::Docker;\n        }\n        if podman_available() {\n            return RuntimeKind::Podman;\n        }\n        if apple_container_available() {\n            return RuntimeKind::AppleContainer;\n        }\n\n        trace!(\"No container runtime found on PATH, defaulting to docker\");\n        RuntimeKind::Docker\n    }\n\n    fn detect_runtime() -> Self {\n        let runtime = Self::resolve_runtime_kind(\n            EnvVars::var(EnvVars::PREK_CONTAINER_RUNTIME).ok(),\n            || which::which(\"docker\").is_ok(),\n            || which::which(\"podman\").is_ok(),\n            || which::which(\"container\").is_ok(),\n        );\n        let rootless = runtime.detect_rootless().unwrap_or_else(|e| {\n            warn!(\"Failed to detect if container runtime is rootless: {e}, defaulting to rootful\");\n            false\n        });\n        let mounts = runtime.list_mounts().unwrap_or_else(|e| {\n            warn!(\"Failed to get container mounts: {e}, assuming no mounts\");\n            vec![]\n        });\n\n        Self {\n            runtime,\n            rootless,\n            mounts,\n        }\n    }\n\n    /// Get the command name of the container runtime.\n    fn cmd(&self) -> &str {\n        self.runtime.cmd()\n    }\n\n    fn is_rootless(&self) -> bool {\n        self.rootless\n    }\n\n    fn is_podman(&self) -> bool {\n        self.runtime == RuntimeKind::Podman\n    }\n\n    fn is_apple_container(&self) -> bool {\n        self.runtime == RuntimeKind::AppleContainer\n    }\n\n    /// Get the path of the current directory in the host.\n    fn map_to_host_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {\n        for mount in &self.mounts {\n            if let Ok(suffix) = path.strip_prefix(&mount.destination) {\n                if suffix.components().next().is_none() {\n                    // Exact match\n                    return Cow::Owned(PathBuf::from(&mount.source));\n                }\n                let path = Path::new(&mount.source).join(suffix);\n                return Cow::Owned(path);\n            }\n        }\n\n        Cow::Borrowed(path)\n    }\n}\n\nstatic CONTAINER_RUNTIME: LazyLock<ContainerRuntimeInfo> =\n    LazyLock::new(ContainerRuntimeInfo::detect_runtime);\n\nimpl Docker {\n    fn docker_tag(info: &InstallInfo) -> String {\n        let mut hasher = DefaultHasher::new();\n\n        info.language.hash(&mut hasher);\n        info.language_version.hash(&mut hasher);\n        let deps = info.dependencies.iter().collect::<BTreeSet<&String>>();\n        deps.hash(&mut hasher);\n\n        let digest = hex::encode(hasher.finish().to_le_bytes());\n        format!(\"prek-{digest}\")\n    }\n\n    async fn build_docker_image(\n        hook: &Hook,\n        install_info: &InstallInfo,\n        pull: bool,\n    ) -> Result<String> {\n        let Some(src) = hook.repo_path() else {\n            anyhow::bail!(\"Language `docker` cannot work with `local` repository\");\n        };\n\n        let tag = Self::docker_tag(install_info);\n        let mut cmd = Cmd::new(CONTAINER_RUNTIME.cmd(), \"build docker image\");\n        let cmd = cmd\n            .arg(\"build\")\n            .arg(\"--tag\")\n            .arg(&tag)\n            .arg(\"--label\")\n            .arg(\"org.opencontainers.image.vendor=prek\")\n            .arg(\"--label\")\n            .arg(format!(\"org.opencontainers.image.source={}\", hook.repo()))\n            .arg(\"--label\")\n            .arg(format!(\"prek.hook.id={}\", hook.id))\n            .arg(\"--label\")\n            .arg(\"prek.managed=true\");\n\n        // Always attempt to pull all referenced images.\n        if pull {\n            cmd.arg(\"--pull\");\n        }\n\n        // This must come last for old versions of docker.\n        // see https://github.com/pre-commit/pre-commit/issues/477\n        cmd.arg(\".\");\n\n        cmd.current_dir(src).check(true).output().await?;\n\n        Ok(tag)\n    }\n\n    pub(crate) fn docker_run_cmd(work_dir: &Path) -> Cmd {\n        let mut command = Cmd::new(CONTAINER_RUNTIME.cmd(), \"run container\");\n        command.arg(\"run\").arg(\"--rm\");\n\n        if *USE_COLOR {\n            command.arg(\"--tty\");\n        }\n\n        // Run as a non-root user\n        #[cfg(unix)]\n        {\n            let add_user_args = |cmd: &mut Cmd| {\n                let uid = unsafe { libc::geteuid() };\n                let gid = unsafe { libc::getegid() };\n                cmd.arg(\"--user\").arg(format!(\"{uid}:{gid}\"));\n            };\n\n            // If runtime is rootful, set user to non-root user id matching current user id.\n            if !CONTAINER_RUNTIME.is_rootless() {\n                add_user_args(&mut command);\n            } else if CONTAINER_RUNTIME.is_podman() {\n                // For rootless podman, set user to non-root use id matching\n                // current user id and add additional `--userns` param to map the user id correctly.\n                add_user_args(&mut command);\n                command.arg(\"--userns\").arg(\"keep-id\");\n            }\n\n            // Otherwise (rootless Docker): do nothing as it will cause permission\n            // problems with bind mounted files.  In this state, `root:root` inside the container is\n            // the same as current `uid:gid` on the host - see subuid / subgid.\n        }\n\n        // https://docs.docker.com/reference/cli/docker/container/run/#volumes-from\n        // The `Z` option tells Docker to label the content with a private\n        // unshared label. Only the current container can use a private volume.\n        let work_dir = CONTAINER_RUNTIME.map_to_host_path(work_dir);\n        let z = if CONTAINER_RUNTIME.is_apple_container() {\n            \"\" // Not currently supported\n        } else {\n            \",Z\"\n        };\n        let volume = format!(\"{}:/src:rw{z}\", work_dir.display());\n\n        if !CONTAINER_RUNTIME.is_apple_container() {\n            // Run an init inside the container that forwards signals and reaps processes\n            command.arg(\"--init\");\n        }\n        command\n            .arg(\"--volume\")\n            .arg(volume)\n            .arg(\"--workdir\")\n            .arg(\"/src\");\n\n        command\n    }\n}\n\nimpl LanguageImpl for Docker {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        Docker::build_docker_image(&hook, &info, true)\n            .await\n            .context(\"Failed to build docker image\")?;\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        // Pass environment variables on the command line (they will appear in ps output).\n        let env_args: Vec<String> = hook\n            .env\n            .iter()\n            .flat_map(|(key, value)| [\"-e\".to_owned(), format!(\"{key}={value}\")])\n            .collect();\n\n        let docker_tag = Docker::build_docker_image(\n            hook,\n            hook.install_info().expect(\"Docker env must be installed\"),\n            false,\n        )\n        .await\n        .context(\"Failed to build docker image\")?;\n        let entry = hook.entry.split()?;\n\n        let run = async |batch: &[&Path]| {\n            // docker run [OPTIONS] IMAGE [COMMAND] [ARG...]\n            let mut cmd = Docker::docker_run_cmd(hook.work_dir());\n            let mut output = cmd\n                .current_dir(hook.work_dir())\n                .args(&env_args)\n                .arg(\"--entrypoint\")\n                .arg(&entry[0])\n                .arg(&docker_tag)\n                .args(&entry[1..])\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use pretty_assertions::assert_eq;\n    use std::io::Write;\n\n    const CONTAINER_ID_V1: &str =\n        \"7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d605\";\n    const CGROUP_V1_SAMPLE: &str = r\"9:cpuset:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d605.scope\n8:cpuacct:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d605.scope\n\";\n\n    const CONTAINER_ID_V2: &str =\n        \"6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0\";\n    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\n403 401 0:45 /docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755\n\";\n\n    #[test]\n    fn test_container_id_from_cgroup_v1() -> anyhow::Result<()> {\n        for (sample, expected) in [\n            // with suffix\n            (CGROUP_V1_SAMPLE, CONTAINER_ID_V1),\n            // with prefix and suffix\n            (\n                \"13:name=systemd:/podruntime/docker/kubepods/crio-dc679f8a8319c8cf7d38e1adf263bc08d234f0749ea715fb6ca3bb259db69956.stuff\",\n                \"dc679f8a8319c8cf7d38e1adf263bc08d234f0749ea715fb6ca3bb259db69956\",\n            ),\n            // just container id\n            (\n                \"13:name=systemd:/pod/d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356\",\n                \"d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356\",\n            ),\n            // with prefix\n            (\n                \"//\\n1:name=systemd:/podruntime/docker/kubepods/docker-dc579f8a8319c8cf7d38e1adf263bc08d230600179b07acfd7eaf9646778dc31\",\n                \"dc579f8a8319c8cf7d38e1adf263bc08d230600179b07acfd7eaf9646778dc31\",\n            ),\n            // with two dashes in prefix\n            (\n                \"11:perf_event:/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4415fd05_2c0f_4533_909b_f2180dca8d7c.slice/cri-containerd-713a77a26fe2a38ebebd5709604a048c3d380db1eb16aa43aca0b2499e54733c.scope\",\n                \"713a77a26fe2a38ebebd5709604a048c3d380db1eb16aa43aca0b2499e54733c\",\n            ),\n            // with colon\n            (\n                \"11:devices:/system.slice/containerd.service/kubepods-pod87a18a64_b74a_454a_b10b_a4a36059d0a3.slice:cri-containerd:05c48c82caff3be3d7f1e896981dd410e81487538936914f32b624d168de9db0\",\n                \"05c48c82caff3be3d7f1e896981dd410e81487538936914f32b624d168de9db0\",\n            ),\n        ] {\n            let mut cgroup_file = tempfile::NamedTempFile::new()?;\n            cgroup_file.write_all(sample.as_bytes())?;\n            cgroup_file.flush()?;\n\n            let actual = container_id_from_cgroup_v1(cgroup_file.path())?;\n            assert_eq!(actual, expected);\n        }\n\n        Ok(())\n    }\n\n    #[test]\n    fn invalid_container_id_from_cgroup_v1() -> anyhow::Result<()> {\n        for sample in [\n            // Too short\n            \"9:cpuset:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d60.scope\",\n            // Non-hex characters\n            \"9:cpuset:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d6g0.scope\",\n            // No container id\n            \"9:cpuset:/system.slice/docker-.scope\",\n        ] {\n            let mut cgroup_file = tempfile::NamedTempFile::new()?;\n            cgroup_file.write_all(sample.as_bytes())?;\n            cgroup_file.flush()?;\n\n            let result = container_id_from_cgroup_v1(cgroup_file.path());\n            assert!(result.is_err());\n        }\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_container_id_from_cgroup_v2() -> anyhow::Result<()> {\n        for (sample, expected) in [\n            // Docker rootful container\n            (\n                r\"402 401 0:45 /var/lib/docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755\n403 401 0:45 /var/lib/docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755\n\",\n                \"6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0\",\n            ),\n            // Docker rootless container\n            (\n                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\n403 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\n\",\n                \"6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc1\",\n            ),\n            // Podman rootful container\n            (\n                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\n1100 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\n\",\n                \"6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc2\",\n            ),\n            // Podman rootless container\n            (\n                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\n1100 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\n\",\n                \"6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc3\",\n            ),\n        ] {\n            let mut mountinfo_file = tempfile::NamedTempFile::new()?;\n            mountinfo_file.write_all(sample.as_bytes())?;\n            mountinfo_file.flush()?;\n\n            let actual = container_id_from_cgroup_v2(mountinfo_file.path())?;\n            assert_eq!(actual, expected);\n        }\n        Ok(())\n    }\n\n    #[test]\n    fn test_current_container_id_prefers_cgroup_v1() -> anyhow::Result<()> {\n        let mut cgroup_file = tempfile::NamedTempFile::new()?;\n        let mut mountinfo_file = tempfile::NamedTempFile::new()?;\n        cgroup_file.write_all(CGROUP_V1_SAMPLE.as_bytes())?;\n        mountinfo_file.write_all(MOUNTINFO_SAMPLE.as_bytes())?;\n        cgroup_file.flush()?;\n        mountinfo_file.flush()?;\n\n        let container_id =\n            current_container_id_from_paths(cgroup_file.path(), mountinfo_file.path())?;\n        assert_eq!(container_id, CONTAINER_ID_V1);\n        Ok(())\n    }\n\n    #[test]\n    fn test_current_container_id_falls_back_to_cgroup_v2() -> anyhow::Result<()> {\n        let mut cgroup_file = tempfile::NamedTempFile::new()?;\n        let mut mountinfo_file = tempfile::NamedTempFile::new()?;\n        cgroup_file.write_all(b\"0::/\\n\")?; // No cgroup v1 container id available.\n        mountinfo_file.write_all(MOUNTINFO_SAMPLE.as_bytes())?;\n        cgroup_file.flush()?;\n        mountinfo_file.flush()?;\n\n        let container_id =\n            current_container_id_from_paths(cgroup_file.path(), mountinfo_file.path())?;\n        assert_eq!(container_id, CONTAINER_ID_V2);\n        Ok(())\n    }\n\n    #[test]\n    fn test_current_container_id_errors_when_no_match() -> anyhow::Result<()> {\n        let cgroup_file = tempfile::NamedTempFile::new()?;\n        let mut mountinfo_file = tempfile::NamedTempFile::new()?;\n        mountinfo_file.write_all(b\"501 500 0:45 /proc /proc rw\\n\")?;\n        mountinfo_file.flush()?;\n\n        let result = current_container_id_from_paths(cgroup_file.path(), mountinfo_file.path());\n        assert!(result.is_err());\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_detect_container_runtime() {\n        fn runtime_with(\n            env_override: Option<&str>,\n            docker_available: bool,\n            podman_available: bool,\n            apple_container_available: bool,\n        ) -> RuntimeKind {\n            ContainerRuntimeInfo::resolve_runtime_kind(\n                env_override.map(ToString::to_string),\n                || docker_available,\n                || podman_available,\n                || apple_container_available,\n            )\n        }\n\n        assert_eq!(runtime_with(None, true, false, false), RuntimeKind::Docker);\n        assert_eq!(runtime_with(None, false, true, false), RuntimeKind::Podman);\n        assert_eq!(\n            runtime_with(None, false, false, true),\n            RuntimeKind::AppleContainer\n        );\n        assert_eq!(runtime_with(None, false, false, false), RuntimeKind::Docker);\n\n        assert_eq!(\n            runtime_with(Some(\"auto\"), true, false, false),\n            RuntimeKind::Docker\n        );\n        assert_eq!(\n            runtime_with(Some(\"auto\"), false, true, false),\n            RuntimeKind::Podman\n        );\n        assert_eq!(\n            runtime_with(Some(\"auto\"), false, false, true),\n            RuntimeKind::AppleContainer\n        );\n        assert_eq!(\n            runtime_with(Some(\"auto\"), false, false, false),\n            RuntimeKind::Docker\n        );\n\n        assert_eq!(\n            runtime_with(Some(\"docker\"), true, false, false),\n            RuntimeKind::Docker\n        );\n        assert_eq!(\n            runtime_with(Some(\"docker\"), false, true, false),\n            RuntimeKind::Docker\n        );\n        assert_eq!(\n            runtime_with(Some(\"DOCKER\"), false, false, false),\n            RuntimeKind::Docker\n        );\n        assert_eq!(\n            runtime_with(Some(\"podman\"), true, false, false),\n            RuntimeKind::Podman\n        );\n        assert_eq!(\n            runtime_with(Some(\"podman\"), false, true, false),\n            RuntimeKind::Podman\n        );\n        assert_eq!(\n            runtime_with(Some(\"podman\"), false, false, false),\n            RuntimeKind::Podman\n        );\n        assert_eq!(\n            runtime_with(Some(\"container\"), true, true, false),\n            RuntimeKind::AppleContainer\n        );\n\n        assert_eq!(\n            runtime_with(Some(\"invalid\"), false, false, false),\n            RuntimeKind::Docker\n        );\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/docker_image.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::Result;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::languages::docker::Docker;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct DockerImage;\n\nimpl LanguageImpl for DockerImage {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        _store: &Store,\n        _reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        Ok(InstalledHook::NoNeedInstall(hook))\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        // Pass environment variables on the command line (they will appear in ps output).\n        let env_args: Vec<String> = hook\n            .env\n            .iter()\n            .flat_map(|(key, value)| [\"-e\".to_owned(), format!(\"{key}={value}\")])\n            .collect();\n\n        let entry = hook.entry.split()?;\n        let run = async |batch: &[&Path]| {\n            let mut cmd = Docker::docker_run_cmd(hook.work_dir());\n            let mut output = cmd\n                .current_dir(hook.work_dir())\n                .args(&env_args)\n                .args(&entry[..])\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/fail.rs",
    "content": "use std::io::Write;\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse anyhow::Result;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Fail;\n\nimpl LanguageImpl for Fail {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        _store: &Store,\n        _reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        Ok(InstalledHook::NoNeedInstall(hook))\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        _reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let mut out = Vec::new();\n        writeln!(out, \"{}\\n\", hook.entry.raw())?;\n        for f in filenames {\n            out.extend(f.to_string_lossy().as_bytes());\n            out.push(b'\\n');\n        }\n        out.push(b'\\n');\n\n        Ok((1, out))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/golang/golang.rs",
    "content": "use std::ops::Deref;\nuse std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::Context;\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::languages::golang::GoRequest;\nuse crate::languages::golang::installer::GoInstaller;\nuse crate::languages::version::LanguageRequest;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{CacheBucket, Store, ToolBucket};\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Golang;\n\nimpl LanguageImpl for Golang {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> anyhow::Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        // 1. Install Go\n        let go_dir = store.tools_path(ToolBucket::Go);\n        let installer = GoInstaller::new(go_dir);\n\n        let (version, allows_download) = match &hook.language_request {\n            LanguageRequest::Any { system_only } => (&GoRequest::Any, !system_only),\n            LanguageRequest::Golang(version) => (version, true),\n            _ => unreachable!(),\n        };\n        let go = installer\n            .install(store, version, allows_download)\n            .await\n            .context(\"Failed to install go\")?;\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n        info.with_toolchain(go.bin().to_path_buf())\n            .with_language_version(go.version().deref().clone());\n\n        // 2. Create environment\n        fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?;\n\n        // 3. Install dependencies\n        // go: ~/.cache/prek/tools/go/1.24.0/bin/go\n        // go_root: ~/.cache/prek/tools/go/1.24.0\n        // go_cache: ~/.cache/prek/cache/go\n        // go_bin: ~/.cache/prek/hooks/envs/<hook_id>/bin\n        let go_root = go\n            .bin()\n            .parent()\n            .and_then(|p| p.parent())\n            .expect(\"Go root should exist\");\n        let go_cache = store.cache_path(CacheBucket::Go);\n\n        let go_install_cmd = || {\n            if go.is_from_system() {\n                let mut cmd = go.cmd(\"go install\");\n                cmd.arg(\"install\")\n                    .env(EnvVars::GOTOOLCHAIN, \"local\")\n                    .env(EnvVars::GOBIN, bin_dir(&info.env_path));\n                cmd\n            } else {\n                let mut cmd = go.cmd(\"go install\");\n                cmd.arg(\"install\")\n                    .env(EnvVars::GOTOOLCHAIN, \"local\")\n                    .env(EnvVars::GOROOT, go_root)\n                    .env(EnvVars::GOBIN, bin_dir(&info.env_path))\n                    .env(EnvVars::GOFLAGS, \"-modcacherw\")\n                    .env(EnvVars::GOPATH, &go_cache);\n                cmd\n            }\n        };\n\n        // GOPATH used to store downloaded source code (in $GOPATH/pkg/mod)\n        if let Some(repo) = hook.repo_path() {\n            go_install_cmd()\n                .arg(\"./...\")\n                .current_dir(repo)\n                .remove_git_envs()\n                .check(true)\n                .output()\n                .await?;\n        }\n        for dep in &hook.additional_dependencies {\n            let mut cmd = go_install_cmd();\n            if let Some(repo) = hook.repo_path() {\n                cmd.current_dir(repo);\n            }\n            cmd.arg(dep).remove_git_envs().check(true).output().await?;\n        }\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        store: &Store,\n        reporter: &HookRunReporter,\n    ) -> anyhow::Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Node hook must have env path\");\n\n        let go_bin = bin_dir(env_dir);\n        let go_tools = store.tools_path(ToolBucket::Go);\n        let go_root_bin = hook.toolchain_dir().expect(\"Go root should exist\");\n        let go_root = go_root_bin.parent().expect(\"Go root should exist\");\n        let go_cache = store.cache_path(CacheBucket::Go);\n\n        // Only set GOROOT and GOPATH if using the Go installed by prek\n        let go_envs = if go_root_bin.starts_with(go_tools) {\n            vec![(EnvVars::GOROOT, go_root), (EnvVars::GOPATH, &go_cache)]\n        } else {\n            vec![]\n        };\n        let new_path = prepend_paths(&[&go_bin, go_root_bin]).context(\"Failed to join PATH\")?;\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"go hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::GOTOOLCHAIN, \"local\")\n                .env(EnvVars::GOBIN, &go_bin)\n                .env(EnvVars::GOFLAGS, \"-modcacherw\")\n                .envs(go_envs.iter().copied())\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\npub(crate) fn bin_dir(env_path: &Path) -> PathBuf {\n    env_path.join(\"bin\")\n}\n"
  },
  {
    "path": "crates/prek/src/languages/golang/gomod.rs",
    "content": "use std::io;\nuse std::path::Path;\n\nuse anyhow::Result;\nuse tracing::trace;\n\nuse crate::config::Language;\nuse crate::hook::Hook;\nuse crate::languages::version::LanguageRequest;\n\nfn parse_go_mod_directives(contents: &str) -> (Option<String>, Option<String>) {\n    let mut go_version: Option<String> = None;\n    let mut toolchain: Option<String> = None;\n\n    for line in contents.lines() {\n        let mut line = line.trim();\n        if line.is_empty() {\n            continue;\n        }\n\n        // Strip `//` comments.\n        if let Some((before, _)) = line.split_once(\"//\") {\n            line = before.trim();\n            if line.is_empty() {\n                continue;\n            }\n        }\n\n        let mut tokens = line.split_whitespace();\n        let Some(directive) = tokens.next() else {\n            continue;\n        };\n        let value = tokens.next();\n\n        // `go 1.22.0`\n        if go_version.is_none() && directive == \"go\" {\n            if let Some(version) = value {\n                go_version = Some(version.to_string());\n            }\n            continue;\n        }\n\n        // `toolchain go1.22.1`\n        if toolchain.is_none() && directive == \"toolchain\" {\n            if let Some(version) = value {\n                // `toolchain` in go.mod does not accept `default`.\n                if version != \"default\" {\n                    toolchain = Some(version.to_string());\n                }\n            }\n        }\n    }\n\n    (go_version, toolchain)\n}\n\nfn normalize_go_semver_min(version: &str) -> String {\n    // `go.mod` commonly uses `1.23` (no patch). The semver range parser is happier when\n    // we provide a full `MAJOR.MINOR.PATCH` minimum.\n    let mut parts = version.split('.').collect::<Vec<_>>();\n    if parts.is_empty() {\n        return version.to_string();\n    }\n\n    // If any part isn't a pure integer (e.g., `1.23rc1`), keep it as-is.\n    // TODO: support pre-release versions properly.\n    if parts.iter().any(|p| p.parse::<u64>().is_err()) {\n        return version.to_string();\n    }\n\n    match parts.len() {\n        1 => {\n            parts.push(\"0\");\n            parts.push(\"0\");\n        }\n        2 => {\n            parts.push(\"0\");\n        }\n        _ => {}\n    }\n\n    parts.join(\".\")\n}\n\nfn choose_language_version_from_go_mod(contents: &str) -> Option<String> {\n    let (go_version, toolchain) = parse_go_mod_directives(contents);\n\n    // Prefer `go` to maximize cache reuse: it's typically stable across patch updates.\n    let go_version = go_version.or(toolchain)?;\n    let stripped = go_version.strip_prefix(\"go\").unwrap_or(&go_version);\n    let normalized = normalize_go_semver_min(stripped);\n    Some(format!(\">= {normalized}\"))\n}\n\nasync fn extract_go_mod_language_request(repo_path: &Path) -> Result<Option<String>> {\n    let go_mod = repo_path.join(\"go.mod\");\n    let contents = match fs_err::tokio::read(&go_mod).await {\n        Ok(bytes) => bytes,\n        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),\n        Err(err) => return Err(err.into()),\n    };\n    let contents = str::from_utf8(&contents)?;\n\n    Ok(choose_language_version_from_go_mod(contents))\n}\n\npub(crate) async fn extract_go_mod_metadata(hook: &mut Hook) -> Result<()> {\n    // Respect an explicitly configured `language_version`.\n    if !hook.language_request.is_any() {\n        trace!(hook = %hook, \"Skipping go.mod metadata extraction because language_version is already configured\");\n        return Ok(());\n    }\n\n    let Some(repo_path) = hook.repo_path() else {\n        return Ok(());\n    };\n\n    let Some(req_str) = extract_go_mod_language_request(repo_path).await? else {\n        trace!(hook = %hook, \"No go or toolchain directive found in go.mod\");\n        return Ok(());\n    };\n\n    let req = match LanguageRequest::parse(Language::Golang, &req_str) {\n        Ok(req) => req,\n        Err(err) => {\n            trace!(%req_str, error = %err, \"Ignoring invalid go.mod-derived language_version\");\n            return Ok(());\n        }\n    };\n\n    trace!(hook = %hook, version = %req_str, \"Using go.mod-derived language_version\");\n    hook.language_request = req;\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn go_line_is_used_when_only_go_present() {\n        let contents = r\"module example.com/foo\n\ngo 1.22.0\n\";\n        assert_eq!(\n            choose_language_version_from_go_mod(contents).as_deref(),\n            Some(\">= 1.22.0\")\n        );\n    }\n\n    #[test]\n    fn go_is_preferred_over_toolchain() {\n        let contents = r\"module example.com/foo\n\ngo 1.22.0\ntoolchain go1.22.3\n\";\n        assert_eq!(\n            choose_language_version_from_go_mod(contents).as_deref(),\n            Some(\">= 1.22.0\")\n        );\n    }\n\n    #[test]\n    fn invalid_toolchain_value_is_ignored() {\n        let contents = r\"module example.com/foo\n\ntoolchain default\n\";\n        assert_eq!(\n            choose_language_version_from_go_mod(contents).as_deref(),\n            None\n        );\n    }\n\n    #[test]\n    fn comments_and_whitespace_are_ignored() {\n        let contents = \"// header\n\n// go 1.22\ngo 1.20.4 // ignored\n// trailing\n\";\n        assert_eq!(\n            choose_language_version_from_go_mod(contents).as_deref(),\n            Some(\">= 1.20.4\")\n        );\n    }\n\n    #[test]\n    fn toolchain_is_used_when_no_go_present() {\n        let contents = r\"module example.com/foo\n\ntoolchain go1.23.10\n\";\n        assert_eq!(\n            choose_language_version_from_go_mod(contents).as_deref(),\n            Some(\">= 1.23.10\")\n        );\n    }\n\n    #[test]\n    fn go_minor_is_normalized_to_patch() {\n        let contents = r\"module example.com/foo\n\ngo 1.23\n\";\n        assert_eq!(\n            choose_language_version_from_go_mod(contents).as_deref(),\n            Some(\">= 1.23.0\")\n        );\n    }\n\n    #[tokio::test]\n    async fn extract_language_request_from_repo_go_line() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"go.mod\"),\n            \"module example.com/foo\\n\\ngo 1.22\\n\",\n        )\n        .await?;\n\n        let Some(req) = extract_go_mod_language_request(dir.path()).await? else {\n            anyhow::bail!(\"Expected a language request\");\n        };\n        assert_eq!(req, \">= 1.22.0\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn extract_language_request_from_repo_toolchain_when_no_go() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"go.mod\"),\n            \"module example.com/foo\\n\\ntoolchain go1.23.10\\n\",\n        )\n        .await?;\n\n        let Some(req) = extract_go_mod_language_request(dir.path()).await? else {\n            anyhow::bail!(\"Expected a language request\");\n        };\n\n        assert_eq!(req, \">= 1.23.10\");\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn extract_language_request_ignores_invalid_toolchain_value() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"go.mod\"),\n            \"module example.com/foo\\n\\ntoolchain default\\n\",\n        )\n        .await?;\n\n        let req = extract_go_mod_language_request(dir.path()).await?;\n        assert!(req.is_none());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn extract_language_request_missing_go_mod_is_none() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        let req = extract_go_mod_language_request(dir.path()).await?;\n        assert!(req.is_none());\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/golang/installer.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\nuse std::sync::LazyLock;\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::env_vars::EnvVars;\nuse target_lexicon::{Architecture, HOST, OperatingSystem};\nuse tracing::{debug, trace, warn};\n\nuse crate::fs::LockedFile;\nuse crate::git;\nuse crate::http::download_and_extract;\nuse crate::languages::golang::GoRequest;\nuse crate::languages::golang::golang::bin_dir;\nuse crate::languages::golang::version::GoVersion;\nuse crate::process::Cmd;\nuse crate::store::Store;\n\npub(crate) struct GoResult {\n    path: PathBuf,\n    version: GoVersion,\n    from_system: bool,\n}\n\nimpl Display for GoResult {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}@{}\", self.path.display(), self.version)?;\n        Ok(())\n    }\n}\n\n/// Override the Go binary name for testing.\nstatic GO_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {\n    if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__GO_BINARY_NAME) {\n        name\n    } else {\n        \"go\".to_string()\n    }\n});\n\nimpl GoResult {\n    fn from_executable(path: PathBuf, from_system: bool) -> Self {\n        Self {\n            path,\n            from_system,\n            version: GoVersion::default(),\n        }\n    }\n\n    pub(crate) fn from_dir(dir: &Path, from_system: bool) -> Self {\n        let go = bin_dir(dir).join(\"go\").with_extension(EXE_EXTENSION);\n        Self::from_executable(go, from_system)\n    }\n\n    pub(crate) fn bin(&self) -> &Path {\n        &self.path\n    }\n\n    pub(crate) fn version(&self) -> &GoVersion {\n        &self.version\n    }\n\n    pub(crate) fn is_from_system(&self) -> bool {\n        self.from_system\n    }\n\n    pub(crate) fn cmd(&self, summary: &str) -> Cmd {\n        Cmd::new(&self.path, summary)\n    }\n\n    pub(crate) fn with_version(mut self, version: GoVersion) -> Self {\n        self.version = version;\n        self\n    }\n\n    pub(crate) async fn fill_version(mut self) -> Result<Self> {\n        let output = self\n            .cmd(\"go version\")\n            .arg(\"version\")\n            .env(EnvVars::GOTOOLCHAIN, \"local\")\n            .check(true)\n            .output()\n            .await?;\n        // e.g. \"go version go1.24.5 darwin/arm64\"\n        let version_str = String::from_utf8(output.stdout)?;\n        let version_str = version_str\n            .split_ascii_whitespace()\n            .nth(2)\n            .with_context(|| format!(\"Failed to parse Go version from output: {version_str}\"))?;\n\n        let version = GoVersion::from_str(version_str)?;\n\n        self.version = version;\n\n        Ok(self)\n    }\n}\n\npub(crate) struct GoInstaller {\n    root: PathBuf,\n}\n\nimpl GoInstaller {\n    pub(crate) fn new(root: PathBuf) -> Self {\n        Self { root }\n    }\n\n    pub(crate) async fn install(\n        &self,\n        store: &Store,\n        request: &GoRequest,\n        allows_download: bool,\n    ) -> Result<GoResult> {\n        fs_err::tokio::create_dir_all(&self.root).await?;\n\n        let _lock = LockedFile::acquire(self.root.join(\".lock\"), \"go\").await?;\n\n        if let Ok(go) = self.find_installed(request) {\n            trace!(%go, \"Found installed go\");\n            return Ok(go);\n        }\n\n        if let Some(go) = self.find_system_go(request).await? {\n            trace!(%go, \"Using system go\");\n            return Ok(go);\n        }\n\n        if !allows_download {\n            anyhow::bail!(\"No suitable system Go version found and downloads are disabled\");\n        }\n\n        let resolved_version = self\n            .resolve_version(request)\n            .await\n            .with_context(|| format!(\"Failed to resolve go version `{request}`\"))?;\n        trace!(version = %resolved_version, \"Installing go\");\n\n        self.download(store, &resolved_version).await\n    }\n\n    fn find_installed(&self, request: &GoRequest) -> Result<GoResult> {\n        let mut installed = fs_err::read_dir(&self.root)\n            .ok()\n            .into_iter()\n            .flatten()\n            .filter_map(|entry| match entry {\n                Ok(entry) => Some(entry),\n                Err(e) => {\n                    warn!(?e, \"Failed to read entry\");\n                    None\n                }\n            })\n            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))\n            .filter_map(|entry| {\n                let dir_name = entry.file_name();\n                let version = GoVersion::from_str(&dir_name.to_string_lossy()).ok()?;\n                Some((version, entry.path()))\n            })\n            .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))\n            .rev();\n\n        installed\n            .find_map(|(version, path)| {\n                if request.matches(&version, Some(&path)) {\n                    trace!(%version, \"Found matching installed go\");\n                    Some(GoResult::from_dir(&path, false).with_version(version))\n                } else {\n                    trace!(%version, \"Installed go does not match request\");\n                    None\n                }\n            })\n            .context(\"No installed go version matches the request\")\n    }\n\n    async fn resolve_version(&self, req: &GoRequest) -> Result<GoVersion> {\n        let output = git::git_cmd(\"list go tags\")?\n            .arg(\"ls-remote\")\n            .arg(\"--tags\")\n            .arg(\"https://github.com/golang/go\")\n            .output()\n            .await?\n            .stdout;\n        let output_str = str::from_utf8(&output)?;\n        let versions: Vec<GoVersion> = output_str\n            .lines()\n            .filter_map(|line| {\n                let tag = line.split('\\t').nth(1)?;\n                let tag = tag.strip_prefix(\"refs/tags/go\")?;\n                GoVersion::from_str(tag).ok()\n            })\n            .sorted_unstable_by(|a, b| b.cmp(a))\n            .collect();\n\n        let version = versions\n            .into_iter()\n            .find(|version| req.matches(version, None))\n            .with_context(|| format!(\"Version `{req}` not found on remote\"))?;\n        Ok(version)\n    }\n\n    async fn download(&self, store: &Store, version: &GoVersion) -> Result<GoResult> {\n        let arch = match HOST.architecture {\n            Architecture::X86_32(_) => \"386\",\n            Architecture::X86_64 => \"amd64\",\n            Architecture::Aarch64(_) => \"arm64\",\n            Architecture::S390x => \"s390x\",\n            Architecture::Powerpc => \"ppc64\",\n            Architecture::Powerpc64le => \"ppc64le\",\n            _ => anyhow::bail!(\"Unsupported architecture\"),\n        };\n        let os = match HOST.operating_system {\n            OperatingSystem::Darwin(_) => \"darwin\",\n            OperatingSystem::Linux => \"linux\",\n            OperatingSystem::Windows => \"windows\",\n            OperatingSystem::Aix => \"aix\",\n            OperatingSystem::Netbsd => \"netbsd\",\n            OperatingSystem::Openbsd => \"openbsd\",\n            OperatingSystem::Solaris => \"solaris\",\n            OperatingSystem::Dragonfly => \"dragonfly\",\n            OperatingSystem::Illumos => \"illumos\",\n            _ => anyhow::bail!(\"Unsupported OS\"),\n        };\n\n        let ext = if cfg!(windows) { \"zip\" } else { \"tar.gz\" };\n        let filename = format!(\"go{version}.{os}-{arch}.{ext}\");\n        let url = format!(\"https://go.dev/dl/{filename}\");\n        let target = self.root.join(version.to_string());\n\n        download_and_extract(&url, &filename, store, async |extracted| {\n            if target.exists() {\n                debug!(target = %target.display(), \"Removing existing go\");\n                fs_err::tokio::remove_dir_all(&target).await?;\n            }\n\n            debug!(?extracted, target = %target.display(), \"Moving go to target\");\n            // TODO: retry on Windows\n            fs_err::tokio::rename(extracted, &target).await?;\n\n            anyhow::Ok(())\n        })\n        .await\n        .context(\"Failed to download and extract go\")?;\n\n        Ok(GoResult::from_dir(&target, false).with_version(version.clone()))\n    }\n\n    async fn find_system_go(&self, go_request: &GoRequest) -> Result<Option<GoResult>> {\n        let go_paths = match which::which_all(&*GO_BINARY_NAME) {\n            Ok(paths) => paths,\n            Err(e) => {\n                debug!(\"No go executables found in PATH: {}\", e);\n                return Ok(None);\n            }\n        };\n\n        for go_path in go_paths {\n            match GoResult::from_executable(go_path, true)\n                .fill_version()\n                .await\n            {\n                Ok(go) => {\n                    // Check if this version matches the request\n                    if go_request.matches(&go.version, Some(&go.path)) {\n                        trace!(\n                            %go,\n                            \"Found matching system go\"\n                        );\n                        return Ok(Some(go));\n                    }\n                    trace!(\n                        %go,\n                        \"System go does not match requested version\"\n                    );\n                }\n                Err(e) => {\n                    warn!(?e, \"Failed to get version for system go\");\n                }\n            }\n        }\n\n        debug!(?go_request, \"No system go matches the requested version\");\n        Ok(None)\n    }\n}\n\n#[cfg(all(test, unix))]\nmod tests {\n    use std::os::unix::fs::PermissionsExt;\n\n    use super::*;\n\n    #[tokio::test]\n    async fn fill_version_uses_local_gotoolchain() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let fake_go = temp_dir.path().join(\"go\");\n        fs_err::write(\n            &fake_go,\n            indoc::indoc! {r#\"#!/bin/sh\n                if [ \"$1\" = \"version\" ]; then\n                  if [ \"${GOTOOLCHAIN:-}\" = \"local\" ]; then\n                    printf 'go version go1.24.13 linux/amd64\\n'\n                  else\n                    printf 'go version go1.26.0 linux/amd64\\n'\n                  fi\n                  exit 0\n                fi\n\n                printf 'unexpected args: %s\\n' \"$*\" >&2\n                exit 1\n            \"#},\n        )?;\n\n        let mut permissions = fs_err::metadata(&fake_go)?.permissions();\n        permissions.set_mode(0o755);\n        fs_err::set_permissions(&fake_go, permissions)?;\n\n        let go = GoResult::from_executable(fake_go, true)\n            .fill_version()\n            .await?;\n\n        assert_eq!(go.version().to_string(), \"1.24.13\");\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/golang/mod.rs",
    "content": "#[allow(clippy::module_inception)]\nmod golang;\nmod gomod;\nmod installer;\nmod version;\n\npub(crate) use golang::Golang;\npub(crate) use gomod::extract_go_mod_metadata;\npub(crate) use version::GoRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/golang/version.rs",
    "content": "use std::fmt::Display;\nuse std::ops::Deref;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\n\nuse serde::Deserialize;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n#[derive(Debug, Clone, Deserialize)]\npub(crate) struct GoVersion(semver::Version);\n\nimpl Default for GoVersion {\n    fn default() -> Self {\n        GoVersion(semver::Version::new(0, 0, 0))\n    }\n}\n\nimpl Deref for GoVersion {\n    type Target = semver::Version;\n\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl Display for GoVersion {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\nimpl FromStr for GoVersion {\n    type Err = semver::Error;\n\n    // TODO: go1.20.0b1, go1.20.0rc1?\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let s = s.strip_prefix(\"go\").unwrap_or(s).trim();\n        semver::Version::parse(s).map(GoVersion)\n    }\n}\n\n/// `language_version` field of golang can be one of the following:\n/// `default`\n/// `system`\n/// `go`\n/// `go1.20` or `1.20`\n/// `go1.20.3` or `1.20.3`\n/// `go1.20.0b1` or `1.20.0b1`\n/// `go1.20rc1` or `1.20rc1`\n/// `go1.18beta1` or `1.18beta1`\n/// `>= 1.20, < 1.22`\n/// `local/path/to/go`\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum GoRequest {\n    Any,\n    Major(u64),\n    MajorMinor(u64, u64),\n    MajorMinorPatch(u64, u64, u64),\n    Path(PathBuf),\n    Range(semver::VersionReq, String),\n    // TODO: support prerelease versions like `go1.20.0b1`, `go1.20rc1`\n    // MajorMinorPrerelease(u64, u64, String),\n}\n\nimpl Display for GoRequest {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            GoRequest::Any => write!(f, \"any\"),\n            GoRequest::Major(major) => write!(f, \"go{major}\"),\n            GoRequest::MajorMinor(major, minor) => write!(f, \"go{major}.{minor}\"),\n            GoRequest::MajorMinorPatch(major, minor, patch) => {\n                write!(f, \"go{major}.{minor}.{patch}\")\n            }\n            GoRequest::Path(path) => write!(f, \"path: {}\", path.display()),\n            GoRequest::Range(_, raw) => write!(f, \"{raw}\"),\n        }\n    }\n}\n\nimpl FromStr for GoRequest {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        if s.is_empty() {\n            return Ok(GoRequest::Any);\n        }\n\n        // Check if it starts with \"go\" - parse as specific version\n        if let Some(version_part) = s.strip_prefix(\"go\") {\n            if version_part.is_empty() {\n                return Ok(GoRequest::Any);\n            }\n\n            return Self::parse_version_numbers(version_part, s);\n        }\n\n        Self::parse_version_numbers(s, s)\n            .or_else(|_| {\n                semver::VersionReq::parse(s)\n                    .map(|version_req| GoRequest::Range(version_req, s.into()))\n                    .map_err(|_| Error::InvalidVersion(s.to_string()))\n            })\n            .or_else(|_| {\n                let path = PathBuf::from(s);\n                if path.exists() {\n                    Ok(GoRequest::Path(path))\n                } else {\n                    // TODO: better error message\n                    Err(Error::InvalidVersion(s.to_string()))\n                }\n            })\n    }\n}\n\nimpl GoRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, GoRequest::Any)\n    }\n\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<GoRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n\n        match parts.as_slice() {\n            [major] => Ok(GoRequest::Major(*major)),\n            [major, minor] => Ok(GoRequest::MajorMinor(*major, *minor)),\n            [major, minor, patch] => Ok(GoRequest::MajorMinorPatch(*major, *minor, *patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        let version = &install_info.language_version;\n\n        self.matches(\n            &GoVersion(version.clone()),\n            Some(install_info.toolchain.as_ref()),\n        )\n    }\n\n    pub(crate) fn matches(&self, version: &GoVersion, toolchain: Option<&Path>) -> bool {\n        match self {\n            GoRequest::Any => true,\n            GoRequest::Major(major) => version.0.major == *major,\n            GoRequest::MajorMinor(major, minor) => {\n                version.0.major == *major && version.0.minor == *minor\n            }\n            GoRequest::MajorMinorPatch(major, minor, patch) => {\n                version.0.major == *major && version.0.minor == *minor && version.0.patch == *patch\n            }\n            // FIXME: consider resolving symlinks and normalizing paths before comparison\n            GoRequest::Path(path) => toolchain.is_some_and(|t| t == path),\n            GoRequest::Range(req, _) => req.matches(&version.0),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_go_request_from_str() {\n        let cases = vec![\n            (\"\", GoRequest::Any),\n            (\"go\", GoRequest::Any),\n            (\"go1\", GoRequest::Major(1)),\n            (\"1\", GoRequest::Major(1)),\n            (\"go1.20\", GoRequest::MajorMinor(1, 20)),\n            (\"1.20\", GoRequest::MajorMinor(1, 20)),\n            (\"go1.20.3\", GoRequest::MajorMinorPatch(1, 20, 3)),\n            (\"1.20.3\", GoRequest::MajorMinorPatch(1, 20, 3)),\n            (\n                \">= 1.20, < 1.22\",\n                GoRequest::Range(\n                    semver::VersionReq::parse(\">= 1.20, < 1.22\").unwrap(),\n                    \">= 1.20, < 1.22\".into(),\n                ),\n            ),\n        ];\n\n        for (input, expected) in cases {\n            let req = GoRequest::from_str(input).unwrap();\n            assert_eq!(req, expected, \"Input: {input}\");\n        }\n    }\n\n    #[test]\n    fn test_go_request_invalid() {\n        let invalid_cases = vec![\"go1.20.3.4\", \"go1.beta\", \"invalid_version\"];\n        for input in invalid_cases {\n            let req = GoRequest::from_str(input);\n            assert!(req.is_err(), \"Input: {input}\");\n        }\n    }\n\n    #[test]\n    fn test_go_request_matches() {\n        let version = GoVersion(semver::Version::new(1, 20, 3));\n        let cases = vec![\n            (GoRequest::Any, true),\n            (GoRequest::Major(1), true),\n            (GoRequest::Major(2), false),\n            (GoRequest::MajorMinor(1, 20), true),\n            (GoRequest::MajorMinor(1, 21), false),\n            (GoRequest::MajorMinorPatch(1, 20, 3), true),\n            (GoRequest::MajorMinorPatch(1, 20, 4), false),\n            (\n                GoRequest::Range(\n                    semver::VersionReq::parse(\">= 1.19, < 1.21\").unwrap(),\n                    \">= 1.19, < 1.21\".into(),\n                ),\n                true,\n            ),\n            (\n                GoRequest::Range(\n                    semver::VersionReq::parse(\">= 1.21\").unwrap(),\n                    \">= 1.21\".into(),\n                ),\n                false,\n            ),\n        ];\n\n        for (req, expected) in cases {\n            let result = req.matches(&version, None);\n            assert_eq!(result, expected, \"Request: {req}\");\n        }\n    }\n\n    #[test]\n    fn test_go_request_display() {\n        let cases = vec![\n            (GoRequest::Any, \"any\"),\n            (GoRequest::Major(1), \"go1\"),\n            (GoRequest::MajorMinor(1, 20), \"go1.20\"),\n            (GoRequest::MajorMinorPatch(1, 20, 3), \"go1.20.3\"),\n            (\n                GoRequest::Range(\n                    semver::VersionReq::parse(\">= 1.20, < 1.22\").unwrap(),\n                    \">= 1.20, < 1.22\".into(),\n                ),\n                \">= 1.20, < 1.22\",\n            ),\n        ];\n        for (req, expected) in cases {\n            let req_str = req.to_string();\n            assert_eq!(req_str, expected, \"Request: {req:?}\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/haskell.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::{Arc, LazyLock};\n\nuse anyhow::{Context, Result};\nuse mea::once::OnceCell;\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\nstatic CABAL_UPDATE_ONCE: OnceCell<()> = OnceCell::new();\nstatic SKIP_CABAL_UPDATE: LazyLock<bool> =\n    LazyLock::new(|| EnvVars::var(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE).is_ok());\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Haskell;\n\nimpl LanguageImpl for Haskell {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        debug!(%hook, target = %info.env_path.display(), \"Installing Haskell environment\");\n\n        let bin_dir = info.env_path.join(\"bin\");\n        fs_err::tokio::create_dir_all(&bin_dir).await?;\n\n        // Identify packages: *.cabal files in repo + additional_dependencies\n        let search_path = hook.repo_path().unwrap_or(hook.project().path());\n        let pkgs = fs_err::read_dir(search_path)?\n            .flatten()\n            .filter_map(|entry| {\n                let path = entry.path();\n                if path.is_file()\n                    && path\n                        .extension()\n                        .is_some_and(|ext| ext.eq_ignore_ascii_case(\"cabal\"))\n                {\n                    path.file_name()\n                        .map(|name| name.to_string_lossy().to_string())\n                } else {\n                    None\n                }\n            })\n            .chain(hook.additional_dependencies.iter().cloned())\n            .collect::<Vec<_>>();\n\n        if pkgs.is_empty() {\n            anyhow::bail!(\"Expected .cabal files or additional_dependencies\");\n        }\n\n        // Run `cabal update` unless explicitly skipped via PREK_INTERNAL__SKIP_CABAL_UPDATE (e.g., in CI)\n        if !*SKIP_CABAL_UPDATE {\n            // `cabal update` is slow, so only run it once per process.\n            CABAL_UPDATE_ONCE\n                .get_or_try_init(async || {\n                    Cmd::new(\"cabal\", \"update cabal package database\")\n                        .arg(\"update\")\n                        .check(true)\n                        .output()\n                        .await\n                        .context(\"Failed to run `cabal update`\")\n                        .map(|_| ())\n                })\n                .await?;\n        }\n\n        // cabal v2-install --installdir <bindir> <pkgs> (default install-method is copy)\n        Cmd::new(\"cabal\", \"install haskell dependencies\")\n            .current_dir(search_path)\n            .arg(\"v2-install\")\n            .arg(\"--installdir\")\n            .arg(&bin_dir)\n            .args(pkgs)\n            .check(true)\n            .output()\n            .await\n            .context(\"Failed to install haskell dependencies\")?;\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Haskell must have env path\");\n        let bin_dir = env_dir.join(\"bin\");\n        let new_path = prepend_paths(&[&bin_dir]).context(\"Failed to join PATH\")?;\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"run haskell hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/julia.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Julia;\n\nimpl LanguageImpl for Julia {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        debug!(%hook, target = %info.env_path.display(), \"Installing Julia environment\");\n\n        fs_err::tokio::create_dir_all(&info.env_path).await?;\n        let search_path = hook.repo_path().unwrap_or_else(|| hook.work_dir());\n\n        let find_src = |names: &[&str]| {\n            names\n                .iter()\n                .map(|n| search_path.join(n))\n                .find(|p| p.exists())\n        };\n\n        // Copy Project.toml if exists\n        let project_dest = info.env_path.join(\"Project.toml\");\n        if let Some(src) = find_src(&[\"JuliaProject.toml\", \"Project.toml\"]) {\n            fs_err::tokio::copy(src, project_dest).await?;\n        } else {\n            // Create an empty file to ensure this is a Julia project\n            fs_err::tokio::File::create(project_dest).await?;\n        }\n\n        // Copy Manifest.toml (lock) if exists\n        if let Some(src) = find_src(&[\"JuliaManifest.toml\", \"Manifest.toml\"]) {\n            fs_err::tokio::copy(src, info.env_path.join(\"Manifest.toml\")).await?;\n        }\n\n        let julia_code = indoc::indoc! {r\"\n            using Pkg\n            Pkg.instantiate()\n            if !isempty(ARGS)\n                Pkg.add(ARGS)\n            end\n        \"};\n\n        Cmd::new(\"julia\", \"instantiate julia environment\")\n            .current_dir(search_path)\n            .arg(\"--startup-file=no\")\n            .arg(format!(\"--project={}\", info.env_path.display()))\n            .arg(\"-e\")\n            .arg(julia_code)\n            .arg(\"--\")\n            .args(&hook.additional_dependencies)\n            .check(true)\n            .output()\n            .await\n            .context(\"Failed to instantiate Julia environment\")?;\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Cmd::new(\"julia\", \"check julia version\")\n            .arg(\"--version\")\n            .check(true)\n            .output()\n            .await\n            .context(\"Julia is not available\")?;\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Julia must have env path\");\n\n        let mut entry = hook.entry.split()?;\n        if let Some(repo_path) = hook.repo_path() {\n            let jl_path = repo_path.join(&entry[0]);\n            if jl_path.exists() {\n                entry[0] = jl_path.to_string_lossy().to_string();\n            }\n        }\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(\"julia\", \"run julia hook\")\n                .current_dir(hook.work_dir())\n                .arg(\"--startup-file=no\")\n                .arg(format!(\"--project={}\", env_dir.display()))\n                .args(&entry)\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/lua.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse semver::Version;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Lua;\n\npub(crate) struct LuaInfo {\n    pub(crate) version: Version,\n    pub(crate) executable: std::path::PathBuf,\n}\n\npub(crate) async fn query_lua_info() -> Result<LuaInfo> {\n    let stdout = Cmd::new(\"lua\", \"get lua version\")\n        .arg(\"-v\")\n        .check(true)\n        .output()\n        .await?\n        .stdout;\n    // Lua 5.4.8  Copyright (C) 1994-2025 Lua.org, PUC-Rio\n    let version = String::from_utf8_lossy(&stdout)\n        .split_whitespace()\n        .nth(1)\n        .context(\"Failed to get Lua version\")?\n        .parse::<Version>()\n        .context(\"Failed to parse Lua version\")?;\n\n    let stdout = Cmd::new(\"luarocks\", \"get lua executable\")\n        .arg(\"config\")\n        .arg(\"variables.LUA\")\n        .check(true)\n        .output()\n        .await?\n        .stdout;\n\n    let executable = PathBuf::from(String::from_utf8_lossy(&stdout).trim());\n\n    Ok(LuaInfo {\n        version,\n        executable,\n    })\n}\n\nimpl LanguageImpl for Lua {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        debug!(%hook, target = %info.env_path.display(), \"Installing Lua environment\");\n\n        // Check lua and luarocks are installed.\n        let lua_info = query_lua_info().await.context(\"Failed to query Lua info\")?;\n\n        // Install dependencies for the remote repository.\n        if let Some(repo_path) = hook.repo_path() {\n            if let Some(rockspec) = Self::get_rockspec_file(repo_path) {\n                Self::install_rockspec(&info.env_path, repo_path, &rockspec).await?;\n            }\n        }\n\n        // Install additional dependencies.\n        for dep in &hook.additional_dependencies {\n            Self::install_dependency(&info.env_path, dep).await?;\n        }\n\n        info.with_toolchain(lua_info.executable)\n            .with_language_version(lua_info.version);\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        let current_lua_info = query_lua_info()\n            .await\n            .context(\"Failed to query current Lua info\")?;\n\n        if current_lua_info.version != info.language_version {\n            anyhow::bail!(\n                \"Lua version mismatch: expected `{}`, found `{}`\",\n                info.language_version,\n                current_lua_info.version\n            );\n        }\n\n        if current_lua_info.executable != info.toolchain {\n            anyhow::bail!(\n                \"Lua executable mismatch: expected `{}`, found `{}`\",\n                info.toolchain.display(),\n                current_lua_info.executable.display()\n            );\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Lua must have env path\");\n        let new_path = prepend_paths(&[&env_dir.join(\"bin\")]).context(\"Failed to join PATH\")?;\n        let entry = hook.entry.resolve(Some(&new_path))?;\n\n        let version = &hook\n            .install_info()\n            .expect(\"Lua must have install info\")\n            .language_version;\n        // version without patch, e.g. 5.4\n        let version = format!(\"{}.{}\", version.major, version.minor);\n        let lua_path = Lua::get_lua_path(env_dir, &version);\n        let lua_cpath = Lua::get_lua_cpath(env_dir, &version);\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"run lua command\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::LUA_PATH, &lua_path)\n                .env(EnvVars::LUA_CPATH, &lua_cpath)\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\nimpl Lua {\n    async fn install_rockspec(env_path: &Path, root_path: &Path, rockspec: &Path) -> Result<()> {\n        Cmd::new(\"luarocks\", \"luarocks make rockspec\")\n            .current_dir(root_path)\n            .arg(\"--tree\")\n            .arg(env_path)\n            .arg(\"make\")\n            .arg(rockspec)\n            .check(true)\n            .output()\n            .await\n            .context(\"Failed to install dependency with rockspec\")?;\n        Ok(())\n    }\n\n    async fn install_dependency(env_path: &Path, dependency: &str) -> Result<()> {\n        Cmd::new(\"luarocks\", \"luarocks install dependency\")\n            .arg(\"--tree\")\n            .arg(env_path)\n            .arg(\"install\")\n            .arg(dependency)\n            .check(true)\n            .output()\n            .await\n            .context(\"Failed to install Lua dependency\")?;\n        Ok(())\n    }\n\n    fn get_rockspec_file(root_path: &Path) -> Option<PathBuf> {\n        if let Ok(entries) = std::fs::read_dir(root_path) {\n            for entry in entries.flatten() {\n                let path = entry.path();\n                if path.extension().and_then(|s| s.to_str()) == Some(\"rockspec\") {\n                    return Some(path);\n                }\n            }\n        }\n        None\n    }\n\n    fn get_lua_path(env_dir: &Path, version: &str) -> String {\n        let share_dir = env_dir.join(\"share\");\n        format!(\n            \"{};{};;\",\n            share_dir.join(\"lua\").join(version).join(\"?.lua\").display(),\n            share_dir\n                .join(\"lua\")\n                .join(version)\n                .join(\"?\")\n                .join(\"init.lua\")\n                .display()\n        )\n    }\n\n    fn get_lua_cpath(env_dir: &Path, version: &str) -> String {\n        let lib_dir = env_dir.join(\"lib\");\n        let so_ext = if cfg!(windows) { \"dll\" } else { \"so\" };\n        format!(\n            \"{};;\",\n            lib_dir\n                .join(\"lua\")\n                .join(version)\n                .join(format!(\"?.{so_ext}\"))\n                .display()\n        )\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/mod.rs",
    "content": "use std::ffi::OsStr;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\nuse std::sync::Arc;\n\nuse anyhow::Result;\nuse prek_consts::env_vars::EnvVars;\nuse prek_identify::parse_shebang;\nuse tracing::{instrument, trace};\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::config::Language;\nuse crate::fs::CWD;\nuse crate::hook::{Hook, InstallInfo, InstalledHook, Repo};\nuse crate::hooks;\nuse crate::store::{CacheBucket, Store, ToolBucket};\n\nmod bun;\nmod deno;\nmod docker;\nmod docker_image;\nmod fail;\nmod golang;\nmod haskell;\nmod julia;\nmod lua;\nmod node;\nmod pygrep;\nmod python;\nmod ruby;\nmod rust;\nmod script;\nmod swift;\nmod system;\npub mod version;\n\nstatic BUN: bun::Bun = bun::Bun;\nstatic DENO: deno::Deno = deno::Deno;\nstatic DOCKER: docker::Docker = docker::Docker;\nstatic DOCKER_IMAGE: docker_image::DockerImage = docker_image::DockerImage;\nstatic FAIL: fail::Fail = fail::Fail;\nstatic GOLANG: golang::Golang = golang::Golang;\nstatic HASKELL: haskell::Haskell = haskell::Haskell;\nstatic JULIA: julia::Julia = julia::Julia;\nstatic LUA: lua::Lua = lua::Lua;\nstatic NODE: node::Node = node::Node;\nstatic PYGREP: pygrep::Pygrep = pygrep::Pygrep;\nstatic PYTHON: python::Python = python::Python;\nstatic RUBY: ruby::Ruby = ruby::Ruby;\nstatic RUST: rust::Rust = rust::Rust;\nstatic SCRIPT: script::Script = script::Script;\nstatic SWIFT: swift::Swift = swift::Swift;\nstatic SYSTEM: system::System = system::System;\nstatic UNIMPLEMENTED: Unimplemented = Unimplemented;\n\ntrait LanguageImpl {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook>;\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()>;\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)>;\n}\n\n#[derive(thiserror::Error, Debug)]\n#[error(\"Language `{0}` is not implemented yet\")]\nstruct UnimplementedError(String);\n\nstruct Unimplemented;\n\nimpl LanguageImpl for Unimplemented {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        _store: &Store,\n        _reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        Ok(InstalledHook::NoNeedInstall(hook))\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        _filenames: &[&Path],\n        _store: &Store,\n        _reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        anyhow::bail!(UnimplementedError(format!(\"{}\", hook.language)))\n    }\n}\n\n// `pre-commit` language support:\n// bun: install requested version, support env, support additional deps\n// conda: only system version, support env, support additional deps\n// coursier: only system version, support env, support additional deps\n// dart: only system version, support env, support additional deps\n// docker_image: only system version, no env, no additional deps\n// docker: only system version, support env, no additional deps\n// dotnet: only system version, support env, no additional deps\n// fail: only system version, no env, no additional deps\n// golang: install requested version, support env, support additional deps\n// haskell: only system version, support env, support additional deps\n// lua: only system version, support env, support additional deps\n// node: install requested version, support env, support additional deps (delegated to nodeenv)\n// perl: only system version, support env, support additional deps\n// pygrep: only system version, no env, no additional deps\n// python: install requested version, support env, support additional deps (delegated to virtualenv)\n// r: only system version, support env, support additional deps\n// ruby: install requested version, support env, support additional deps (delegated to rbenv)\n// rust: install requested version, support env, support additional deps (delegated to rustup and cargo)\n// script: only system version, no env, no additional deps\n// swift: only system version, support env, no additional deps\n// system: only system version, no env, no additional deps\n\nimpl Language {\n    pub fn supported(lang: Language) -> bool {\n        matches!(\n            lang,\n            Self::Bun\n                | Self::Deno\n                | Self::Docker\n                | Self::DockerImage\n                | Self::Fail\n                | Self::Golang\n                | Self::Haskell\n                | Self::Julia\n                | Self::Lua\n                | Self::Node\n                | Self::Pygrep\n                | Self::Python\n                | Self::Ruby\n                | Self::Rust\n                | Self::Script\n                | Self::Swift\n                | Self::System\n        )\n    }\n\n    pub fn supports_install_env(self) -> bool {\n        !matches!(\n            self,\n            Self::DockerImage | Self::Fail | Self::Script | Self::System\n        )\n    }\n\n    pub fn tool_buckets(self) -> &'static [ToolBucket] {\n        match self {\n            Self::Bun => &[ToolBucket::Bun],\n            Self::Deno => &[ToolBucket::Deno],\n            Self::Golang => &[ToolBucket::Go],\n            Self::Node => &[ToolBucket::Node],\n            Self::Python | Self::Pygrep => &[ToolBucket::Uv, ToolBucket::Python],\n            Self::Ruby => &[ToolBucket::Ruby],\n            Self::Rust => &[ToolBucket::Rustup],\n            _ => &[],\n        }\n    }\n\n    pub fn cache_buckets(self) -> &'static [CacheBucket] {\n        match self {\n            Self::Deno => &[CacheBucket::Deno],\n            Self::Golang => &[CacheBucket::Go],\n            Self::Python | Self::Pygrep => &[CacheBucket::Uv, CacheBucket::Python],\n            Self::Rust => &[CacheBucket::Cargo],\n            _ => &[],\n        }\n    }\n\n    /// Return whether the language allows specifying the version, e.g. we can install a specific\n    /// requested language version.\n    /// See <https://pre-commit.com/#overriding-language-version>\n    pub fn supports_language_version(self) -> bool {\n        matches!(\n            self,\n            Self::Bun\n                | Self::Deno\n                | Self::Golang\n                | Self::Node\n                | Self::Python\n                | Self::Ruby\n                | Self::Rust\n        )\n    }\n\n    /// Whether the language supports installing dependencies.\n    ///\n    /// For example, Python and Node.js support installing dependencies, while\n    /// System and Fail do not.\n    pub fn supports_dependency(self) -> bool {\n        !matches!(\n            self,\n            Self::DockerImage\n                | Self::Fail\n                | Self::Pygrep\n                | Self::Script\n                | Self::System\n                | Self::Docker\n                | Self::Dotnet\n                | Self::Swift\n        )\n    }\n\n    pub async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        match self {\n            Self::Bun => BUN.install(hook, store, reporter).await,\n            Self::Deno => DENO.install(hook, store, reporter).await,\n            Self::Docker => DOCKER.install(hook, store, reporter).await,\n            Self::DockerImage => DOCKER_IMAGE.install(hook, store, reporter).await,\n            Self::Fail => FAIL.install(hook, store, reporter).await,\n            Self::Golang => GOLANG.install(hook, store, reporter).await,\n            Self::Haskell => HASKELL.install(hook, store, reporter).await,\n            Self::Julia => JULIA.install(hook, store, reporter).await,\n            Self::Lua => LUA.install(hook, store, reporter).await,\n            Self::Node => NODE.install(hook, store, reporter).await,\n            Self::Pygrep => PYGREP.install(hook, store, reporter).await,\n            Self::Python => PYTHON.install(hook, store, reporter).await,\n            Self::Ruby => RUBY.install(hook, store, reporter).await,\n            Self::Rust => RUST.install(hook, store, reporter).await,\n            Self::Script => SCRIPT.install(hook, store, reporter).await,\n            Self::Swift => SWIFT.install(hook, store, reporter).await,\n            Self::System => SYSTEM.install(hook, store, reporter).await,\n            _ => UNIMPLEMENTED.install(hook, store, reporter).await,\n        }\n    }\n\n    pub async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        match self {\n            Self::Bun => BUN.check_health(info).await,\n            Self::Deno => DENO.check_health(info).await,\n            Self::Docker => DOCKER.check_health(info).await,\n            Self::DockerImage => DOCKER_IMAGE.check_health(info).await,\n            Self::Fail => FAIL.check_health(info).await,\n            Self::Golang => GOLANG.check_health(info).await,\n            Self::Haskell => HASKELL.check_health(info).await,\n            Self::Julia => JULIA.check_health(info).await,\n            Self::Lua => LUA.check_health(info).await,\n            Self::Node => NODE.check_health(info).await,\n            Self::Pygrep => PYGREP.check_health(info).await,\n            Self::Python => PYTHON.check_health(info).await,\n            Self::Ruby => RUBY.check_health(info).await,\n            Self::Rust => RUST.check_health(info).await,\n            Self::Script => SCRIPT.check_health(info).await,\n            Self::Swift => SWIFT.check_health(info).await,\n            Self::System => SYSTEM.check_health(info).await,\n            _ => UNIMPLEMENTED.check_health(info).await,\n        }\n    }\n\n    #[instrument(level = \"trace\", skip_all, fields(hook_id = %hook.id, language = %hook.language))]\n    pub async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        match hook.repo() {\n            Repo::Meta { .. } => {\n                return hooks::MetaHooks::from_str(&hook.id)\n                    .unwrap()\n                    .run(store, hook, filenames, reporter)\n                    .await;\n            }\n            Repo::Builtin { .. } => {\n                return hooks::BuiltinHooks::from_str(&hook.id)\n                    .unwrap()\n                    .run(store, hook, filenames, reporter)\n                    .await;\n            }\n            Repo::Remote { .. } => {\n                // Fast path for hooks implemented in Rust\n                if hooks::check_fast_path(hook) {\n                    return hooks::run_fast_path(store, hook, filenames, reporter).await;\n                }\n            }\n            Repo::Local { .. } => {}\n        }\n\n        match self {\n            Self::Bun => BUN.run(hook, filenames, store, reporter).await,\n            Self::Deno => DENO.run(hook, filenames, store, reporter).await,\n            Self::Docker => DOCKER.run(hook, filenames, store, reporter).await,\n            Self::DockerImage => DOCKER_IMAGE.run(hook, filenames, store, reporter).await,\n            Self::Fail => FAIL.run(hook, filenames, store, reporter).await,\n            Self::Golang => GOLANG.run(hook, filenames, store, reporter).await,\n            Self::Haskell => HASKELL.run(hook, filenames, store, reporter).await,\n            Self::Julia => JULIA.run(hook, filenames, store, reporter).await,\n            Self::Lua => LUA.run(hook, filenames, store, reporter).await,\n            Self::Node => NODE.run(hook, filenames, store, reporter).await,\n            Self::Pygrep => PYGREP.run(hook, filenames, store, reporter).await,\n            Self::Python => PYTHON.run(hook, filenames, store, reporter).await,\n            Self::Ruby => RUBY.run(hook, filenames, store, reporter).await,\n            Self::Rust => RUST.run(hook, filenames, store, reporter).await,\n            Self::Script => SCRIPT.run(hook, filenames, store, reporter).await,\n            Self::Swift => SWIFT.run(hook, filenames, store, reporter).await,\n            Self::System => SYSTEM.run(hook, filenames, store, reporter).await,\n            _ => UNIMPLEMENTED.run(hook, filenames, store, reporter).await,\n        }\n    }\n}\n\n/// Try to extract metadata from the given hook.\npub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> {\n    match hook.language {\n        Language::Python => python::extract_metadata(hook).await,\n        Language::Golang => golang::extract_go_mod_metadata(hook).await,\n        _ => Ok(()),\n    }\n}\n\n/// Resolve the actual process invocation, honoring shebangs and PATH lookups.\npub(crate) fn resolve_command(mut cmds: Vec<String>, paths: Option<&OsStr>) -> Vec<String> {\n    let env_path = if paths.is_none() {\n        EnvVars::var_os(EnvVars::PATH)\n    } else {\n        None\n    };\n    let paths = paths.or(env_path.as_deref());\n\n    let candidate = &cmds[0];\n    let resolved_binary = match which::which_in(candidate, paths, &*CWD) {\n        Ok(p) => p,\n        Err(_) => PathBuf::from(candidate),\n    };\n    trace!(\"Resolved command: {}\", resolved_binary.display());\n\n    if let Ok(mut shebang_argv) = parse_shebang(&resolved_binary) {\n        trace!(\"Found shebang: {:?}\", shebang_argv);\n        #[allow(unused_mut)]\n        let mut interpreter = shebang_argv[0].as_str();\n        #[cfg(windows)]\n        {\n            let interpreter_path = Path::new(interpreter);\n            // Git for Windows behavior: if a shebang points to a Unix-style absolute\n            // interpreter path (e.g. `/bin/sh`) that does not exist on Windows,\n            // fall back to PATH lookup of its basename (`sh`).\n            if !interpreter_path.exists()\n                // Restrict this fallback to path-like interpreter values so plain\n                // commands (like `python`) keep their normal resolution path below.\n                && (interpreter_path.has_root() || interpreter.contains(['/', '\\\\']))\n                // Extract basename from shebang path (`/bin/sh` -> `sh`) and resolve it.\n                && let Some(file_name) = interpreter_path.file_name().and_then(OsStr::to_str)\n            {\n                interpreter = file_name;\n            }\n        }\n        // Resolve the interpreter path, convert \"python3\" to \"python3.exe\" on Windows\n        if let Ok(p) = which::which_in(interpreter, paths, &*CWD) {\n            shebang_argv[0] = p.to_string_lossy().to_string();\n            trace!(\"Resolved interpreter: {}\", shebang_argv[0]);\n        }\n        shebang_argv.push(resolved_binary.to_string_lossy().to_string());\n        shebang_argv.extend_from_slice(&cmds[1..]);\n        shebang_argv\n    } else {\n        cmds[0] = resolved_binary.to_string_lossy().to_string();\n        cmds\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::ffi::OsString;\n    use std::path::Path;\n\n    use tempfile::tempdir;\n\n    use super::resolve_command;\n\n    fn write_file(path: &Path, contents: &str) {\n        fs_err::write(path, contents).expect(\"write test file\");\n    }\n\n    #[cfg(unix)]\n    fn make_executable(path: &Path) {\n        use std::os::unix::fs::PermissionsExt;\n\n        let metadata = fs_err::metadata(path).expect(\"stat test file\");\n        let mut perms = metadata.permissions();\n        perms.set_mode(perms.mode() | 0o111);\n        fs_err::set_permissions(path, perms).expect(\"set executable bit\");\n    }\n\n    #[cfg(windows)]\n    fn make_executable(_path: &Path) {}\n\n    #[test]\n    fn resolve_command_passthrough_when_not_found() {\n        let cmd = \"__prek_nonexistent_command__\".to_string();\n        let resolved = resolve_command(vec![cmd.clone()], None);\n        assert_eq!(resolved, vec![cmd]);\n    }\n\n    #[test]\n    fn resolve_command_resolves_shebang_interpreter_from_path() {\n        let dir = tempdir().expect(\"create temp dir\");\n        let script_path = dir.path().join(\"hook-script\");\n        write_file(\n            &script_path,\n            \"#!/usr/bin/env prek-test-interpreter\\necho hi\\n\",\n        );\n\n        #[cfg(windows)]\n        let interpreter_path = dir.path().join(\"prek-test-interpreter.exe\");\n        #[cfg(not(windows))]\n        let interpreter_path = dir.path().join(\"prek-test-interpreter\");\n\n        write_file(&interpreter_path, \"\");\n        make_executable(&interpreter_path);\n\n        let paths = OsString::from(dir.path().as_os_str());\n        let resolved = resolve_command(\n            vec![script_path.to_string_lossy().into_owned()],\n            Some(paths.as_os_str()),\n        );\n\n        assert_eq!(resolved[0], interpreter_path.to_string_lossy());\n        assert_eq!(resolved[1], script_path.to_string_lossy());\n    }\n\n    #[cfg(windows)]\n    #[test]\n    fn resolve_command_windows_rewrites_bin_sh_to_path_sh() {\n        let dir = tempdir().expect(\"create temp dir\");\n        let script_path = dir.path().join(\"legacy-hook\");\n        write_file(&script_path, \"#!/bin/sh\\necho legacy\\n\");\n\n        let sh_path = dir.path().join(\"sh.exe\");\n        write_file(&sh_path, \"\");\n\n        let paths = OsString::from(dir.path().as_os_str());\n        let resolved = resolve_command(\n            vec![script_path.to_string_lossy().into_owned()],\n            Some(paths.as_os_str()),\n        );\n\n        assert_eq!(resolved[0], sh_path.to_string_lossy());\n        assert_eq!(resolved[1], script_path.to_string_lossy());\n    }\n\n    #[cfg(windows)]\n    #[test]\n    fn resolve_command_windows_keeps_existing_absolute_interpreter_path() {\n        let dir = tempdir().expect(\"create temp dir\");\n\n        let interp_dir = dir.path().join(\"bin\");\n        fs_err::create_dir_all(&interp_dir).expect(\"create interpreter dir\");\n        let interp_path = interp_dir.join(\"sh.exe\");\n        write_file(&interp_path, \"\");\n        let shebang_interpreter = interp_path.to_string_lossy().replace('\\\\', \"/\");\n\n        let script_path = dir.path().join(\"legacy-hook\");\n        write_file(\n            &script_path,\n            &format!(\"#!{shebang_interpreter}\\necho legacy\\n\"),\n        );\n\n        let paths = OsString::from(dir.path().as_os_str());\n        let resolved = resolve_command(\n            vec![script_path.to_string_lossy().into_owned()],\n            Some(paths.as_os_str()),\n        );\n\n        let resolved_interp = Path::new(&resolved[0]);\n        assert_eq!(resolved_interp, interp_path.as_path());\n        assert_eq!(resolved[1], script_path.to_string_lossy());\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/node/installer.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\nuse std::string::ToString;\nuse std::sync::LazyLock;\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::env_vars::EnvVars;\nuse target_lexicon::{Architecture, HOST, OperatingSystem};\nuse tracing::{debug, trace, warn};\n\nuse crate::fs::LockedFile;\nuse crate::http::{REQWEST_CLIENT, download_and_extract};\nuse crate::languages::node::NodeRequest;\nuse crate::languages::node::version::NodeVersion;\nuse crate::process::Cmd;\nuse crate::store::Store;\n\n#[derive(Debug)]\npub(crate) struct NodeResult {\n    node: PathBuf,\n    npm: PathBuf,\n    version: NodeVersion,\n}\n\nimpl Display for NodeResult {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}@{}\", self.node.display(), self.version)?;\n        Ok(())\n    }\n}\n\n/// Override the Node binary name for testing.\nstatic NODE_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {\n    if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME) {\n        name\n    } else {\n        \"node\".to_string()\n    }\n});\n\nimpl NodeResult {\n    pub(crate) fn from_executables(node: PathBuf, npm: PathBuf) -> Self {\n        Self {\n            node,\n            npm,\n            version: NodeVersion::default(),\n        }\n    }\n\n    pub(crate) fn from_dir(dir: &Path) -> Self {\n        let node = bin_dir(dir).join(\"node\").with_extension(EXE_EXTENSION);\n        let npm = bin_dir(dir)\n            .join(\"npm\")\n            .with_extension(if cfg!(windows) { \"cmd\" } else { \"\" });\n        Self::from_executables(node, npm)\n    }\n\n    pub(crate) fn with_version(mut self, version: NodeVersion) -> Self {\n        self.version = version;\n        self\n    }\n\n    pub(crate) async fn fill_version(mut self) -> Result<Self> {\n        // https://nodejs.org/api/process.html#processrelease\n        let output = Cmd::new(&self.node, \"node -p\")\n            .arg(\"-p\")\n            .arg(\"JSON.stringify({version: process.version, lts: process.release.lts || false})\")\n            .check(true)\n            .output()\n            .await?;\n        let output_str = String::from_utf8_lossy(&output.stdout);\n        let version: NodeVersion =\n            serde_json::from_str(&output_str).context(\"Failed to parse node version\")?;\n\n        self.version = version;\n\n        Ok(self)\n    }\n\n    pub(crate) fn node(&self) -> &Path {\n        &self.node\n    }\n\n    pub(crate) fn npm(&self) -> &Path {\n        &self.npm\n    }\n\n    pub(crate) fn version(&self) -> &NodeVersion {\n        &self.version\n    }\n}\n\npub(crate) struct NodeInstaller {\n    root: PathBuf,\n}\n\nimpl NodeInstaller {\n    pub(crate) fn new(root: PathBuf) -> Self {\n        Self { root }\n    }\n\n    /// Install a version of Node.js.\n    pub(crate) async fn install(\n        &self,\n        store: &Store,\n        request: &NodeRequest,\n        allows_download: bool,\n    ) -> Result<NodeResult> {\n        fs_err::tokio::create_dir_all(&self.root).await?;\n\n        let _lock = LockedFile::acquire(self.root.join(\".lock\"), \"node\").await?;\n\n        if let Ok(node_result) = self.find_installed(request) {\n            trace!(%node_result, \"Found installed node\");\n            return Ok(node_result);\n        }\n\n        // Find all node and npm executables in PATH and check their versions\n        if let Some(node_result) = self.find_system_node(request).await? {\n            trace!(%node_result, \"Using system node\");\n            return Ok(node_result);\n        }\n\n        if !allows_download {\n            anyhow::bail!(\"No suitable system Node version found and downloads are disabled\");\n        }\n\n        let resolved_version = self.resolve_version(request).await?;\n        trace!(version = %resolved_version, \"Downloading node\");\n\n        self.download(store, &resolved_version).await\n    }\n\n    /// Get the installed version of Node.js.\n    fn find_installed(&self, req: &NodeRequest) -> Result<NodeResult> {\n        let mut installed = fs_err::read_dir(&self.root)\n            .ok()\n            .into_iter()\n            .flatten()\n            .filter_map(|entry| match entry {\n                Ok(entry) => Some(entry),\n                Err(err) => {\n                    warn!(?err, \"Failed to read entry\");\n                    None\n                }\n            })\n            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))\n            .filter_map(|entry| {\n                let dir_name = entry.file_name();\n                let version = NodeVersion::from_str(&dir_name.to_string_lossy()).ok()?;\n                Some((version, entry.path()))\n            })\n            .sorted_unstable_by(|(a, _), (b, _)| a.version.cmp(&b.version))\n            .rev();\n\n        installed\n            .find_map(|(v, path)| {\n                if req.matches(&v, Some(&path)) {\n                    Some(NodeResult::from_dir(&path).with_version(v))\n                } else {\n                    None\n                }\n            })\n            .context(\"No installed node version matches the request\")\n    }\n\n    async fn resolve_version(&self, req: &NodeRequest) -> Result<NodeVersion> {\n        // Latest versions come first, so we can find the latest matching version.\n        let versions = self\n            .list_remote_versions()\n            .await\n            .context(\"Failed to list remote versions\")?;\n        let version = versions\n            .into_iter()\n            .find(|version| req.matches(version, None))\n            .context(\"Version not found on remote\")?;\n        Ok(version)\n    }\n\n    /// List all versions of Node.js available on the Node.js website.\n    async fn list_remote_versions(&self) -> Result<Vec<NodeVersion>> {\n        let url = \"https://nodejs.org/dist/index.json\";\n        let versions: Vec<NodeVersion> = REQWEST_CLIENT.get(url).send().await?.json().await?;\n        Ok(versions)\n    }\n\n    // TODO: support mirror?\n    /// Install a specific version of Node.js.\n    async fn download(&self, store: &Store, version: &NodeVersion) -> Result<NodeResult> {\n        let mut arch = match HOST.architecture {\n            Architecture::X86_32(_) => \"x86\",\n            Architecture::X86_64 => \"x64\",\n            Architecture::Aarch64(_) => \"arm64\",\n            Architecture::Arm(_) => \"armv7l\",\n            Architecture::S390x => \"s390x\",\n            Architecture::Powerpc => \"ppc64\",\n            Architecture::Powerpc64le => \"ppc64le\",\n            _ => anyhow::bail!(\"Unsupported architecture\"),\n        };\n        let os = match HOST.operating_system {\n            OperatingSystem::Darwin(_) => \"darwin\",\n            OperatingSystem::Linux => \"linux\",\n            OperatingSystem::Windows => \"win\",\n            OperatingSystem::Aix => \"aix\",\n            _ => anyhow::bail!(\"Unsupported OS\"),\n        };\n        if os == \"darwin\" && arch == \"arm64\" && version.major() < 16 {\n            // Node.js 16 and later are required for arm64 on macOS.\n            arch = \"x64\";\n        }\n        let ext = if cfg!(windows) { \"zip\" } else { \"tar.xz\" };\n\n        let filename = format!(\"node-v{}-{os}-{arch}.{ext}\", version.version());\n        let url = format!(\"https://nodejs.org/dist/v{}/{filename}\", version.version());\n        let target = self.root.join(version.to_string());\n\n        download_and_extract(&url, &filename, store, async |extracted| {\n            if target.exists() {\n                debug!(target = %target.display(), \"Removing existing node\");\n                fs_err::tokio::remove_dir_all(&target).await?;\n            }\n\n            debug!(?extracted, target = %target.display(), \"Moving node to target\");\n            // TODO: retry on Windows\n            fs_err::tokio::rename(extracted, &target).await?;\n\n            anyhow::Ok(())\n        })\n        .await\n        .context(\"Failed to download and extract node\")?;\n\n        Ok(NodeResult::from_dir(&target).with_version(version.clone()))\n    }\n\n    /// Find a suitable system Node.js installation that matches the request.\n    async fn find_system_node(&self, node_request: &NodeRequest) -> Result<Option<NodeResult>> {\n        let node_paths = match which::which_all(&*NODE_BINARY_NAME) {\n            Ok(paths) => paths,\n            Err(e) => {\n                debug!(\"No node executables found in PATH: {}\", e);\n                return Ok(None);\n            }\n        };\n\n        // Check each node executable for a matching version, stop early if found\n        for node_path in node_paths {\n            if let Some(npm_path) = Self::find_npm_in_same_directory(&node_path)? {\n                match NodeResult::from_executables(node_path, npm_path)\n                    .fill_version()\n                    .await\n                {\n                    Ok(node_result) => {\n                        // Check if this version matches the request\n                        if node_request.matches(&node_result.version, Some(&node_result.node)) {\n                            trace!(\n                                %node_result,\n                                \"Found a matching system node\"\n                            );\n                            return Ok(Some(node_result));\n                        }\n                        trace!(\n                            %node_result,\n                            \"System node does not match requested version\"\n                        );\n                    }\n                    Err(e) => {\n                        warn!(?e, \"Failed to get version for system node\");\n                    }\n                }\n            } else {\n                trace!(\n                    node = %node_path.display(),\n                    \"No npm found in same directory as node executable\"\n                );\n            }\n        }\n\n        debug!(\n            ?node_request,\n            \"No system node matches the requested version\"\n        );\n        Ok(None)\n    }\n\n    /// Find npm executable in the same directory as the given node executable.\n    fn find_npm_in_same_directory(node_path: &Path) -> Result<Option<PathBuf>> {\n        let node_dir = node_path\n            .parent()\n            .context(\"Node executable has no parent directory\")?;\n\n        for name in [\"npm\", \"npm.cmd\", \"npm.bat\"] {\n            let npm_path = node_dir.join(name);\n            if npm_path.try_exists()? && is_executable(&npm_path) {\n                trace!(\n                    node = %node_path.display(),\n                    npm = %npm_path.display(),\n                    \"Found npm in same directory as node\"\n                );\n                return Ok(Some(npm_path));\n            }\n        }\n        trace!(\n            node = %node_path.display(),\n            \"npm not found in same directory as node\"\n        );\n        Ok(None)\n    }\n}\n\npub(crate) fn bin_dir(prefix: &Path) -> PathBuf {\n    if cfg!(windows) {\n        prefix.to_path_buf()\n    } else {\n        prefix.join(\"bin\")\n    }\n}\n\npub(crate) fn lib_dir(prefix: &Path) -> PathBuf {\n    if cfg!(windows) {\n        prefix.join(\"node_modules\")\n    } else {\n        prefix.join(\"lib\").join(\"node_modules\")\n    }\n}\n\nfn is_executable(path: &Path) -> bool {\n    #[cfg(windows)]\n    {\n        path.extension()\n            .is_some_and(|ext| ext == EXE_EXTENSION || ext == \"cmd\" || ext == \"bat\")\n    }\n    #[cfg(not(windows))]\n    {\n        use std::os::unix::fs::MetadataExt;\n        path.is_file() && fs_err::metadata(path).is_ok_and(|m| m.mode() & 0o111 != 0)\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/node/mod.rs",
    "content": "mod installer;\n#[allow(clippy::module_inception)]\nmod node;\nmod version;\n\npub(crate) use node::Node;\npub(crate) use version::NodeRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/node/node.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::InstalledHook;\nuse crate::hook::{Hook, InstallInfo};\nuse crate::languages::LanguageImpl;\nuse crate::languages::node::NodeRequest;\nuse crate::languages::node::installer::{NodeInstaller, NodeResult, bin_dir, lib_dir};\nuse crate::languages::node::version::EXTRA_KEY_LTS;\nuse crate::languages::version::LanguageRequest;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{Store, ToolBucket};\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Node;\n\nimpl LanguageImpl for Node {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        // 1. Install node\n        //   1) Find from `$PREK_HOME/tools/node`\n        //   2) Find from system\n        //   3) Download from remote\n        // 2. Create env\n        // 3. Install dependencies\n\n        // 1. Install node\n        let node_dir = store.tools_path(ToolBucket::Node);\n        let installer = NodeInstaller::new(node_dir);\n\n        let (node_request, allows_download) = match &hook.language_request {\n            LanguageRequest::Any { system_only } => (&NodeRequest::Any, !system_only),\n            LanguageRequest::Node(node_request) => (node_request, true),\n            _ => unreachable!(),\n        };\n        let node = installer\n            .install(store, node_request, allows_download)\n            .await\n            .context(\"Failed to install node\")?;\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        let lts = serde_json::to_string(&node.version().lts).context(\"Failed to serialize LTS\")?;\n        info.with_toolchain(node.node().to_path_buf());\n        info.with_language_version(node.version().version.clone());\n        info.with_extra(EXTRA_KEY_LTS, &lts);\n\n        // 2. Create env\n        let bin_dir = bin_dir(&info.env_path);\n        let lib_dir = lib_dir(&info.env_path);\n        fs_err::tokio::create_dir_all(&bin_dir).await?;\n        fs_err::tokio::create_dir_all(&lib_dir).await?;\n\n        // 3. Install dependencies\n        let deps = hook.install_dependencies();\n        if deps.is_empty() {\n            debug!(\"No dependencies to install\");\n        } else {\n            // npm install <folder>:\n            // If <folder> sits inside the root of your project, its dependencies will be installed\n            // and may be hoisted to the top-level node_modules as they would for other types of dependencies.\n            // If <folder> sits outside the root of your project, npm will not install the package dependencies\n            // in the directory <folder>, but it will create a symlink to <folder>.\n            //\n            // NOTE: If you want to install the content of a directory like a package from the registry\n            // instead of creating a link, you would need to use the --install-links option.\n\n            // `npm` is a script that uses `/usr/bin/env node`, so we need to add the\n            // node toolchain directory to PATH so that `npm` can find `node`.\n            let node_bin = node.node().parent().expect(\"Node binary must have parent\");\n            let new_path = prepend_paths(&[&bin_dir, node_bin]).context(\"Failed to join PATH\")?;\n\n            Cmd::new(node.npm(), \"npm install\")\n                .arg(\"install\")\n                .arg(\"-g\")\n                .arg(\"--no-progress\")\n                .arg(\"--no-save\")\n                .arg(\"--no-fund\")\n                .arg(\"--no-audit\")\n                .arg(\"--install-links\")\n                .args(&*deps)\n                .env(EnvVars::PATH, new_path)\n                .env(EnvVars::NPM_CONFIG_PREFIX, &info.env_path)\n                .env_remove(EnvVars::NPM_CONFIG_USERCONFIG)\n                .env(EnvVars::NODE_PATH, &lib_dir)\n                .check(true)\n                .output()\n                .await?;\n        }\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        let node = NodeResult::from_executables(info.toolchain.clone(), PathBuf::new())\n            .fill_version()\n            .await\n            .context(\"Failed to query node version\")?;\n\n        if node.version().version != info.language_version {\n            anyhow::bail!(\n                \"Node version mismatch: expected {}, found {}\",\n                info.language_version,\n                node.version().version\n            );\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Node must have env path\");\n        let node_bin = hook.toolchain_dir().expect(\"Node binary must have parent\");\n        let new_path =\n            prepend_paths(&[&bin_dir(env_dir), node_bin]).context(\"Failed to join PATH\")?;\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"node hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::NPM_CONFIG_PREFIX, env_dir)\n                .env_remove(EnvVars::NPM_CONFIG_USERCONFIG)\n                .env(EnvVars::NODE_PATH, lib_dir(env_dir))\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/node/version.rs",
    "content": "use std::fmt::Display;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\n\nuse serde::{Deserialize, Deserializer, Serialize};\nuse serde_json::Value;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n#[derive(Debug, Clone)]\npub(crate) enum Lts {\n    NotLts,\n    Codename(String),\n}\n\nimpl Lts {\n    pub(crate) fn code_name(&self) -> Option<&str> {\n        match self {\n            Self::NotLts => None,\n            Self::Codename(name) => Some(name),\n        }\n    }\n}\n\nimpl<'de> Deserialize<'de> for Lts {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        let value = Value::deserialize(deserializer)?;\n        match value {\n            Value::String(s) => Ok(Lts::Codename(s)),\n            Value::Bool(false) => Ok(Lts::NotLts),\n            Value::Null => Ok(Lts::NotLts),\n            _ => Ok(Lts::NotLts),\n        }\n    }\n}\n\nimpl Serialize for Lts {\n    fn serialize<S>(&self, serializer: S) -> anyhow::Result<S::Ok, S::Error>\n    where\n        S: serde::Serializer,\n    {\n        match self {\n            Lts::Codename(name) => serializer.serialize_str(name),\n            Lts::NotLts => serializer.serialize_bool(false),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct NodeVersion {\n    pub version: semver::Version,\n    pub lts: Lts,\n}\n\nimpl Default for NodeVersion {\n    fn default() -> Self {\n        NodeVersion {\n            version: semver::Version::new(0, 0, 0),\n            lts: Lts::NotLts,\n        }\n    }\n}\n\nimpl<'de> Deserialize<'de> for NodeVersion {\n    fn deserialize<D>(deserializer: D) -> anyhow::Result<NodeVersion, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        #[derive(Deserialize)]\n        struct _Version {\n            version: String,\n            lts: Lts,\n        }\n\n        let raw = _Version::deserialize(deserializer)?;\n        let version_str = raw.version.strip_prefix('v').unwrap_or(&raw.version).trim();\n        let version = semver::Version::parse(version_str).map_err(serde::de::Error::custom)?;\n        Ok(NodeVersion {\n            version,\n            lts: raw.lts,\n        })\n    }\n}\n\nimpl Display for NodeVersion {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.version)?;\n        if let Some(name) = self.lts.code_name() {\n            write!(f, \"-{name}\")?;\n        }\n        Ok(())\n    }\n}\n\nimpl FromStr for NodeVersion {\n    type Err = semver::Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        // Split on the first '-' to separate version and codename\n        let (version_part, lts) = match s.split_once('-') {\n            Some((ver, codename)) => (ver, Lts::Codename(codename.to_string())),\n            None => (s, Lts::NotLts),\n        };\n        let version = semver::Version::parse(version_part)?;\n        Ok(NodeVersion { version, lts })\n    }\n}\n\nimpl NodeVersion {\n    pub fn major(&self) -> u64 {\n        self.version.major\n    }\n    pub fn minor(&self) -> u64 {\n        self.version.minor\n    }\n    pub fn patch(&self) -> u64 {\n        self.version.patch\n    }\n    pub fn version(&self) -> &semver::Version {\n        &self.version\n    }\n}\n\n/// The `language_version` field of node language, can be one of the following:\n/// - `default`: Find the system installed node, or download the latest version.\n/// - `system`: Find the system installed node, or return an error if not found.\n/// - `x.y.z`: Install the specific version of node.\n/// - `x.y`: Install the latest version of node with the same major and minor version.\n/// - `x`: Install the latest version of node with the same major version.\n/// - `^x.y.z`: Install the latest version of node that satisfies the version requirement.\n///   Or any other semver compatible version requirement.\n/// - `lts/<codename>`: Install the latest version of node with the specified code name.\n/// - `local/path/to/node`: Use the node executable at the specified path.\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum NodeRequest {\n    Any,\n    Major(u64),\n    MajorMinor(u64, u64),\n    MajorMinorPatch(u64, u64, u64),\n    Path(PathBuf),\n    Range(semver::VersionReq),\n    // A bare `lts` request is interpreted as the latest LTS version.\n    Lts,\n    // A request like `lts/Argon` is interpreted as the LTS version with the code name \"Argon\".\n    CodeName(String),\n}\n\nimpl FromStr for NodeRequest {\n    type Err = Error;\n\n    fn from_str(request: &str) -> Result<Self, Self::Err> {\n        if request.is_empty() {\n            return Ok(Self::Any);\n        }\n\n        if let Some(version_part) = request.strip_prefix(\"node\") {\n            if version_part.is_empty() {\n                return Ok(Self::Any);\n            }\n            Self::parse_version_numbers(version_part, request)\n        } else if request.eq_ignore_ascii_case(\"lts\") {\n            Ok(NodeRequest::Lts)\n        } else if let Some(code_name) = request.strip_prefix(\"lts/\") {\n            if code_name\n                .chars()\n                .all(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9'))\n            {\n                Ok(NodeRequest::CodeName(code_name.to_string()))\n            } else {\n                Err(Error::InvalidVersion(request.to_string()))\n            }\n        } else {\n            Self::parse_version_numbers(request, request)\n                .or_else(|_| {\n                    semver::VersionReq::parse(request)\n                        .map(NodeRequest::Range)\n                        .map_err(|_| Error::InvalidVersion(request.to_string()))\n                })\n                .or_else(|_| {\n                    let path = PathBuf::from(request);\n                    if path.exists() {\n                        Ok(NodeRequest::Path(path))\n                    } else {\n                        Err(Error::InvalidVersion(request.to_string()))\n                    }\n                })\n        }\n    }\n}\n\npub(crate) const EXTRA_KEY_LTS: &str = \"lts\";\n\nimpl NodeRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, NodeRequest::Any)\n    }\n\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<NodeRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n\n        match parts.as_slice() {\n            [major] => Ok(NodeRequest::Major(*major)),\n            [major, minor] => Ok(NodeRequest::MajorMinor(*major, *minor)),\n            [major, minor, patch] => Ok(NodeRequest::MajorMinorPatch(*major, *minor, *patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        let version = &install_info.language_version;\n        let tls = install_info\n            .get_extra(EXTRA_KEY_LTS)\n            .and_then(|s| serde_json::from_str(s).ok())\n            .unwrap_or(Lts::NotLts);\n\n        self.matches(\n            &NodeVersion {\n                version: version.clone(),\n                lts: tls,\n            },\n            Some(install_info.toolchain.as_ref()),\n        )\n    }\n\n    pub(crate) fn matches(&self, version: &NodeVersion, toolchain: Option<&Path>) -> bool {\n        match self {\n            NodeRequest::Any => true,\n            NodeRequest::Major(major) => version.major() == *major,\n            NodeRequest::MajorMinor(major, minor) => {\n                version.major() == *major && version.minor() == *minor\n            }\n            NodeRequest::MajorMinorPatch(major, minor, patch) => {\n                version.major() == *major && version.minor() == *minor && version.patch() == *patch\n            }\n            // FIXME: consider resolving symlinks and normalizing paths before comparison\n            NodeRequest::Path(path) => toolchain.is_some_and(|t| t == path),\n            NodeRequest::Range(req) => req.matches(version.version()),\n            NodeRequest::Lts => version.lts.code_name().is_some(),\n            NodeRequest::CodeName(name) => version\n                .lts\n                .code_name()\n                .is_some_and(|n| n.eq_ignore_ascii_case(name)),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{EXTRA_KEY_LTS, NodeRequest};\n    use crate::config::Language;\n    use crate::hook::InstallInfo;\n    use rustc_hash::FxHashSet;\n    use std::path::PathBuf;\n    use std::str::FromStr;\n\n    #[test]\n    fn test_node_request_from_str() {\n        assert_eq!(NodeRequest::from_str(\"node\").unwrap(), NodeRequest::Any);\n        assert_eq!(\n            NodeRequest::from_str(\"node12\").unwrap(),\n            NodeRequest::Major(12)\n        );\n        assert_eq!(\n            NodeRequest::from_str(\"node12.18\").unwrap(),\n            NodeRequest::MajorMinor(12, 18)\n        );\n        assert_eq!(\n            NodeRequest::from_str(\"node12.18.3\").unwrap(),\n            NodeRequest::MajorMinorPatch(12, 18, 3)\n        );\n        assert_eq!(NodeRequest::from_str(\"lts\").unwrap(), NodeRequest::Lts);\n        assert_eq!(\n            NodeRequest::from_str(\"lts/Argon\").unwrap(),\n            NodeRequest::CodeName(\"Argon\".to_string())\n        );\n        assert_eq!(NodeRequest::from_str(\"\").unwrap(), NodeRequest::Any);\n        assert_eq!(NodeRequest::from_str(\"12\").unwrap(), NodeRequest::Major(12));\n        assert_eq!(\n            NodeRequest::from_str(\"12.18\").unwrap(),\n            NodeRequest::MajorMinor(12, 18)\n        );\n        assert_eq!(\n            NodeRequest::from_str(\"12.18.3\").unwrap(),\n            NodeRequest::MajorMinorPatch(12, 18, 3)\n        );\n        assert_eq!(\n            NodeRequest::from_str(\">=12.18\").unwrap(),\n            NodeRequest::Range(semver::VersionReq::parse(\">=12.18\").unwrap())\n        );\n    }\n\n    #[test]\n    fn test_node_request_invalid() {\n        assert!(NodeRequest::from_str(\"node12.18.3.4\").is_err());\n        assert!(NodeRequest::from_str(\"node12.18.3a\").is_err());\n        assert!(NodeRequest::from_str(\"node12.18.x\").is_err());\n        assert!(NodeRequest::from_str(\"node^12.18.3\").is_err());\n        assert!(NodeRequest::from_str(\"invalid\").is_err());\n        assert!(NodeRequest::from_str(\"lts/$$$\").is_err());\n    }\n\n    #[test]\n    fn test_node_request_satisfied_by() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Node, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(12, 18, 3))\n            .with_toolchain(PathBuf::from(\"/usr/bin/node\"))\n            .with_extra(EXTRA_KEY_LTS, \"\\\"Argon\\\"\");\n\n        let request = NodeRequest::Major(12);\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::MajorMinor(12, 18);\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::MajorMinorPatch(12, 18, 3);\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::Lts;\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::CodeName(\"Argon\".to_string());\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::CodeName(\"argon\".to_string());\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::CodeName(\"Boron\".to_string());\n        assert!(!request.satisfied_by(&install_info));\n\n        let request = NodeRequest::Path(PathBuf::from(\"/usr/bin/node\"));\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::Path(PathBuf::from(\"/usr/bin/nodejs\"));\n        assert!(!request.satisfied_by(&install_info));\n\n        let request = NodeRequest::Range(semver::VersionReq::parse(\">=12.18\").unwrap());\n        assert!(request.satisfied_by(&install_info));\n\n        let request = NodeRequest::Range(semver::VersionReq::parse(\">=13.0\").unwrap());\n        assert!(!request.satisfied_by(&install_info));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/pygrep/mod.rs",
    "content": "#[allow(clippy::module_inception)]\nmod pygrep;\n\npub(crate) use pygrep::Pygrep;\n"
  },
  {
    "path": "crates/prek/src/languages/pygrep/pygrep.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse tokio::io::AsyncWriteExt;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::languages::python::{Uv, python_exec, query_python_info_cached};\nuse crate::process::Cmd;\nuse crate::run::CONCURRENCY;\nuse crate::store::{CacheBucket, Store, ToolBucket};\n\n#[derive(Debug, Default)]\nstruct Args {\n    ignore_case: bool,\n    multiline: bool,\n    negate: bool,\n}\n\nimpl Args {\n    fn parse(args: &[String]) -> Result<Self> {\n        let mut parsed = Args::default();\n\n        for arg in args {\n            match arg.as_str() {\n                \"--ignore-case\" | \"-i\" => parsed.ignore_case = true,\n                \"--multiline\" => parsed.multiline = true,\n                \"--negate\" => parsed.negate = true,\n                _ => anyhow::bail!(\"Unknown argument: {arg}\"),\n            }\n        }\n\n        Ok(parsed)\n    }\n\n    fn to_args(&self) -> Vec<&'static str> {\n        fn as_str(value: bool) -> &'static str {\n            if value { \"1\" } else { \"0\" }\n        }\n        vec![\n            as_str(self.ignore_case),\n            as_str(self.multiline),\n            as_str(self.negate),\n        ]\n    }\n}\n\n#[derive(serde::Deserialize, thiserror::Error, Debug)]\n#[serde(tag = \"type\")]\nenum Error {\n    #[error(\"Failed to parse regex: {message}\")]\n    Regex { message: String },\n    #[error(\"IO error: {message}\")]\n    IO { message: String },\n    #[error(\"Unknown error: {message}\")]\n    Unknown { message: String },\n}\n\n// We have to implement `pygrep` in Python, because Python `re` module has many differences\n// from Rust `regex` crate.\nstatic SCRIPT: &str = include_str!(\"script.py\");\n\nconst INSTALL_PYTHON_VERSION: &str = \"3.13\";\n\npub(crate) struct Pygrep;\n\nfn find_installed_python(python_dir: &Path) -> Option<PathBuf> {\n    fs_err::read_dir(python_dir)\n        .ok()\n        .into_iter()\n        .flatten()\n        .flatten()\n        .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))\n        // Ignore any `.` prefixed directories\n        .filter(|path| {\n            path.file_name()\n                .to_str()\n                .map(|name| !name.starts_with('.'))\n                .unwrap_or(true)\n        })\n        .map(|entry| python_exec(&entry.path()))\n        .next()\n}\n\nimpl LanguageImpl for Pygrep {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let uv_dir = store.tools_path(ToolBucket::Uv);\n        let uv = Uv::install(store, &uv_dir).await?;\n        let python_dir = store.tools_path(ToolBucket::Python);\n\n        // Find or download a Python interpreter.\n        let mut python = None;\n\n        // 1) Try to find one from `prek` managed Python versions.\n        if let Some(installed) = find_installed_python(&python_dir) {\n            python = Some(installed);\n        } else {\n            // 2) If not found, try to find a system installed Python (system or system uv managed).\n            debug!(\"No managed Python interpreter found, trying to find a system installed one\");\n            let mut output = uv\n                .cmd(\"uv python find\", store)\n                .arg(\"python\")\n                .arg(\"find\")\n                .arg(\"--python-preference\")\n                .arg(\"managed\")\n                .arg(\"--no-python-downloads\")\n                .arg(\"--no-config\")\n                .arg(\"--no-project\")\n                // `--managed_python` conflicts with `--python-preference`, ignore any user setting\n                .env_remove(EnvVars::UV_MANAGED_PYTHON)\n                .env_remove(EnvVars::UV_NO_MANAGED_PYTHON)\n                .check(false)\n                .output()\n                .await?;\n            if output.status.success() {\n                python = Some(PathBuf::from(\n                    String::from_utf8_lossy(&output.stdout).trim(),\n                ));\n            } else {\n                // 3) If still not found, try to download a Python interpreter.\n                debug!(\"No Python interpreter found, trying to install one\");\n                output = uv\n                    .cmd(\"uv python install\", store)\n                    .arg(\"python\")\n                    .arg(\"install\")\n                    .arg(INSTALL_PYTHON_VERSION)\n                    .arg(\"--no-config\")\n                    .arg(\"--no-project\")\n                    .env(EnvVars::UV_PYTHON_INSTALL_DIR, &python_dir)\n                    .check(false)\n                    .output()\n                    .await?;\n                if output.status.success() {\n                    if let Some(installed) = find_installed_python(&python_dir) {\n                        python = Some(installed);\n                    }\n                }\n            }\n        }\n\n        let Some(python) = python else {\n            anyhow::bail!(\"Failed to find or install a Python interpreter for `pygrep`.\");\n        };\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n        info.with_toolchain(python);\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        query_python_info_cached(&info.toolchain)\n            .await\n            .context(\"Failed to query Python info\")?;\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let info = hook.install_info().expect(\"Pygrep hook must be installed\");\n\n        let cache = store.cache_path(CacheBucket::Python);\n        fs_err::tokio::create_dir_all(&cache).await?;\n\n        let py_script = tempfile::NamedTempFile::new_in(cache)?;\n        fs_err::tokio::write(&py_script, SCRIPT)\n            .await\n            .context(\"Failed to write Python script\")?;\n\n        let args = Args::parse(&hook.args).context(\"Failed to parse `args`\")?;\n        let mut cmd = Cmd::new(&info.toolchain, \"python script\")\n            .current_dir(hook.work_dir())\n            .envs(&hook.env)\n            .arg(\"-I\") // Isolate mode.\n            .arg(\"-B\") // Don't write bytecode.\n            .arg(py_script.path())\n            .args(args.to_args())\n            .arg(CONCURRENCY.to_string())\n            .arg(hook.entry.raw())\n            .stdin(Stdio::piped())\n            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .check(false)\n            .spawn()?;\n\n        let mut stdin = cmd.stdin.take().context(\"Failed to take stdin\")?;\n        // TODO: avoid this clone if possible.\n        let filenames: Vec<_> = filenames.iter().map(PathBuf::from).collect();\n\n        let write_task = tokio::spawn(async move {\n            for filename in filenames {\n                stdin\n                    .write_all(format!(\"{}\\n\", filename.display()).as_bytes())\n                    .await?;\n            }\n            let _ = stdin.shutdown().await;\n            anyhow::Ok(())\n        });\n\n        let output = cmd\n            .wait_with_output()\n            .await\n            .context(\"Failed to wait for command output\")?;\n        write_task.await.context(\"Failed to write stdin\")??;\n\n        reporter.on_run_complete(progress);\n\n        if output.status.success() {\n            // When successful, the Python script writes status code JSON to stderr\n            // and grep results to stdout\n            let stderr_str = String::from_utf8_lossy(&output.stderr);\n            let code_output: serde_json::Value =\n                serde_json::from_str(&stderr_str).with_context(|| {\n                    format!(\n                        \"Failed to parse status code JSON from stderr. Stderr content: '{stderr_str}'\",\n                    )\n                })?;\n            let code = code_output\n                .get(\"code\")\n                .and_then(serde_json::Value::as_i64)\n                .unwrap_or(0);\n            let code = i32::try_from(code).unwrap_or(0);\n            Ok((code, output.stdout))\n        } else {\n            // When there's an error, try to parse error JSON from stderr\n            let stderr_str = String::from_utf8_lossy(&output.stderr);\n\n            if stderr_str.trim().is_empty() {\n                // No stderr output - create a generic error\n                anyhow::bail!(\n                    \"Python script failed with exit code {} but produced no error output\",\n                    output.status.code().unwrap_or(-1)\n                );\n            }\n\n            // Try to parse as JSON first\n            match serde_json::from_str::<Error>(&stderr_str) {\n                Ok(err) => Err(err.into()),\n                Err(_) => {\n                    // Not JSON - treat as plain text error message\n                    anyhow::bail!(\n                        \"Python script failed with exit code {}: {}\",\n                        output.status.code().unwrap_or(-1),\n                        stderr_str.trim()\n                    )\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/pygrep/script.py",
    "content": "from __future__ import annotations\n\nimport json\nimport io\nimport re\nimport sys\nfrom concurrent.futures import ThreadPoolExecutor\nfrom queue import Queue\nfrom re import Pattern\nfrom threading import Thread\n\n\ndef process_file(\n    filename: str, pattern: Pattern[bytes], multiline: bool, negate: bool, queue: Queue\n) -> None:\n    try:\n        if multiline:\n            if negate:\n                ret, output = _process_filename_at_once_negated(pattern, filename)\n            else:\n                ret, output = _process_filename_at_once(pattern, filename)\n        else:\n            if negate:\n                ret, output = _process_filename_by_line_negated(pattern, filename)\n            else:\n                ret, output = _process_filename_by_line(pattern, filename)\n        queue.put((ret, output))\n    except Exception as e:\n        # Put error result in queue so consumer can handle it\n        queue.put((1, f\"Error processing {filename}: {e}\\n\".encode()))\n\n\ndef _process_filename_by_line(\n    pattern: Pattern[bytes], filename: str\n) -> tuple[int, bytes]:\n    retv = 0\n    output = io.BytesIO()\n    with open(filename, \"rb\") as f:\n        for line_no, line in enumerate(f, start=1):\n            if pattern.search(line):\n                retv = 1\n                output.write(f\"{filename}:{line_no}:\".encode())\n                output.write(line.rstrip(b\"\\r\\n\"))\n                output.write(b\"\\n\")\n    return retv, output.getvalue()\n\n\ndef _process_filename_at_once(\n    pattern: Pattern[bytes], filename: str\n) -> tuple[int, bytes]:\n    retv = 0\n    output = io.BytesIO()\n    with open(filename, \"rb\") as f:\n        contents = f.read()\n        match = pattern.search(contents)\n        if match:\n            retv = 1\n            line_no = contents[: match.start()].count(b\"\\n\")\n            output.write(f\"{filename}:{line_no + 1}:\".encode())\n\n            matched_lines = match[0].split(b\"\\n\")\n            matched_lines[0] = contents.split(b\"\\n\")[line_no]\n\n            output.write(b\"\\n\".join(matched_lines))\n            output.write(b\"\\n\")\n    return retv, output.getvalue()\n\n\ndef _process_filename_by_line_negated(\n    pattern: Pattern[bytes], filename: str\n) -> tuple[int, bytes]:\n    with open(filename, \"rb\") as f:\n        for line in f:\n            if pattern.search(line):\n                return 0, b\"\"\n        else:\n            return 1, filename.encode() + b\"\\n\"\n\n\ndef _process_filename_at_once_negated(\n    pattern: Pattern[bytes], filename: str\n) -> tuple[int, bytes]:\n    with open(filename, \"rb\") as f:\n        contents = f.read()\n    match = pattern.search(contents)\n    if match:\n        return 0, b\"\"\n    else:\n        return 1, filename.encode() + b\"\\n\"\n\n\ndef run(\n    ignore_case: bool, multiline: bool, negate: bool, concurrency: int, pattern: bytes\n):\n    flags = re.IGNORECASE if ignore_case else 0\n    if multiline:\n        flags |= re.MULTILINE | re.DOTALL\n    pattern = re.compile(pattern, flags)\n\n    queue = Queue()\n    pool = ThreadPoolExecutor(max_workers=concurrency)\n\n    # Use a sentinel value to signal completion\n    SENTINEL = (None, None)\n\n    def producer():\n        try:\n            for line in sys.stdin:\n                line = line.strip()\n                if not line:\n                    break\n                pool.submit(\n                    process_file, line.strip(), pattern, multiline, negate, queue\n                )\n\n            # Wait for all tasks to complete\n            pool.shutdown(wait=True)\n        finally:\n            # Ensure sentinel is sent even if there's an error\n            queue.put(SENTINEL)\n\n    def consumer():\n        retv = 0\n        try:\n            while True:\n                ret, output = queue.get()\n\n                # Check for sentinel value\n                if ret is None and output is None:\n                    queue.task_done()\n                    break\n\n                retv |= ret\n                if output:\n                    sys.stdout.buffer.write(output)\n                    sys.stdout.buffer.flush()\n\n                queue.task_done()\n        except Exception:\n            pass\n\n        # Write final return code\n        sys.stderr.buffer.write(f'{{\"code\": {retv}}}\\n'.encode())\n        sys.stderr.buffer.flush()\n\n    t1 = Thread(target=producer)\n    t2 = Thread(target=consumer)\n    t1.start()\n    t2.start()\n\n    # Wait for both threads to complete\n    t1.join()\n    t2.join()\n\n\ndef main():\n    ignore_case = sys.argv[1] == \"1\"\n    multiline = sys.argv[2] == \"1\"\n    negate = sys.argv[3] == \"1\"\n    concurrency = int(sys.argv[4])\n    pattern = sys.argv[5].encode()\n\n    try:\n        run(ignore_case, multiline, negate, concurrency, pattern)\n    except re.error as e:\n        error = {\"type\": \"Regex\", \"message\": str(e)}\n        sys.stderr.buffer.write(json.dumps(error).encode())\n        sys.stderr.flush()\n        sys.exit(1)\n    except OSError as e:\n        error = {\"type\": \"IO\", \"message\": str(e)}\n        sys.stderr.buffer.write(json.dumps(error).encode())\n        sys.stderr.flush()\n        sys.exit(1)\n    except Exception as e:\n        error = {\"type\": \"Unknown\", \"message\": repr(e)}\n        sys.stderr.buffer.write(json.dumps(error).encode())\n        sys.stderr.flush()\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "crates/prek/src/languages/python/mod.rs",
    "content": "use anyhow::Result;\n\nuse crate::hook::Hook;\n\nmod pep723;\nmod pyproject;\n#[allow(clippy::module_inception)]\nmod python;\nmod uv;\nmod version;\n\n/// Extract Python hook metadata with explicit precedence:\n/// PEP 723 > user-configured `language_version` > pyproject.toml > default.\npub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> {\n    pyproject::extract_pyproject_metadata(hook).await?;\n    pep723::extract_pep723_metadata(hook).await\n}\n\npub(crate) use python::Python;\npub(crate) use python::{python_exec, query_python_info_cached};\npub(crate) use uv::Uv;\npub(crate) use version::PythonRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/python/pep723.rs",
    "content": "// MIT License\n//\n// Copyright (c) 2025 Astral Software Inc.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nuse std::path::Path;\nuse std::str::FromStr;\nuse std::sync::LazyLock;\n\nuse anyhow::Result;\nuse memchr::memmem::Finder;\nuse serde::Deserialize;\nuse tracing::trace;\n\nuse crate::hook::Hook;\nuse crate::languages::version::LanguageRequest;\n\nstatic FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b\"# /// script\"));\n\n/// A PEP 723 script, including its [`Pep723Metadata`].\n#[derive(Debug, Clone)]\npub struct Pep723Script {\n    /// The parsed [`Pep723Metadata`] table from the script.\n    pub metadata: Pep723Metadata,\n    /// The content of the script before the metadata table.\n    pub prelude: String,\n    /// The content of the script after the metadata table.\n    pub postlude: String,\n}\n\nimpl Pep723Script {\n    /// Read the PEP 723 `script` metadata from a Python file, if it exists.\n    ///\n    /// Returns `None` if the file is missing a PEP 723 metadata block.\n    ///\n    /// See: <https://peps.python.org/pep-0723/>\n    pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {\n        let contents = fs_err::tokio::read(&file).await?;\n\n        // Extract the `script` tag.\n        let ScriptTag {\n            prelude,\n            metadata,\n            postlude,\n        } = match ScriptTag::parse(&contents) {\n            Ok(Some(tag)) => tag,\n            Ok(None) => return Ok(None),\n            Err(err) => return Err(err),\n        };\n\n        // Parse the metadata.\n        let metadata = Pep723Metadata::from_str(&metadata)?;\n\n        Ok(Some(Self {\n            metadata,\n            prelude,\n            postlude,\n        }))\n    }\n}\n\n/// PEP 723 metadata as parsed from a `script` comment block.\n///\n/// See: <https://peps.python.org/pep-0723/>\n#[derive(Debug, Deserialize, Clone)]\n#[serde(rename_all = \"kebab-case\")]\npub struct Pep723Metadata {\n    pub dependencies: Option<Vec<String>>,\n    pub requires_python: Option<String>,\n}\n\nimpl FromStr for Pep723Metadata {\n    type Err = toml::de::Error;\n\n    /// Parse `Pep723Metadata` from a raw TOML string.\n    fn from_str(raw: &str) -> Result<Self, Self::Err> {\n        let metadata = toml::from_str(raw)?;\n        Ok(metadata)\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum Pep723Error {\n    #[error(\n        \"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 `#`.\"\n    )]\n    UnclosedBlock,\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    Utf8(#[from] std::str::Utf8Error),\n    #[error(transparent)]\n    Toml(#[from] toml::de::Error),\n}\n\n#[derive(Debug, Clone, Eq, PartialEq)]\npub struct ScriptTag {\n    /// The content of the script before the metadata block.\n    prelude: String,\n    /// The metadata block.\n    metadata: String,\n    /// The content of the script after the metadata block.\n    postlude: String,\n}\n\nimpl ScriptTag {\n    /// Given the contents of a Python file, extract the `script` metadata block with leading\n    /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python\n    /// script.\n    ///\n    /// Given the following input string representing the contents of a Python script:\n    ///\n    /// ```python\n    /// #!/usr/bin/env python3\n    /// # /// script\n    /// # requires-python = '>=3.11'\n    /// # dependencies = [\n    /// #   'requests<3',\n    /// #   'rich',\n    /// # ]\n    /// # ///\n    ///\n    /// import requests\n    ///\n    /// print(\"Hello, World!\")\n    /// ```\n    ///\n    /// This function would return:\n    ///\n    /// - Preamble: `#!/usr/bin/env python3\\n`\n    /// - Metadata: `requires-python = '>=3.11'\\ndependencies = [\\n  'requests<3',\\n  'rich',\\n]`\n    /// - Postlude: `import requests\\n\\nprint(\"Hello, World!\")\\n`\n    ///\n    /// See: <https://peps.python.org/pep-0723/>\n    pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {\n        // Identify the opening pragma.\n        let Some(index) = FINDER.find(contents) else {\n            return Ok(None);\n        };\n\n        // The opening pragma must be the first line, or immediately preceded by a newline.\n        if !(index == 0 || matches!(contents[index - 1], b'\\r' | b'\\n')) {\n            return Ok(None);\n        }\n\n        // Extract the preceding content.\n        let prelude = std::str::from_utf8(&contents[..index])?;\n\n        // Decode as UTF-8.\n        let contents = &contents[index..];\n        let contents = std::str::from_utf8(contents)?;\n\n        let mut lines = contents.lines();\n\n        // Ensure that the first line is exactly `# /// script`.\n        if lines.next().is_none_or(|line| line != \"# /// script\") {\n            return Ok(None);\n        }\n\n        // > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting\n        // > with #. If there are characters after the # then the first character MUST be a space. The\n        // > embedded content is formed by taking away the first two characters of each line if the\n        // > second character is a space, otherwise just the first character (which means the line\n        // > consists of only a single #).\n        let mut toml = vec![];\n\n        for line in lines {\n            // Remove the leading `#`.\n            let Some(line) = line.strip_prefix('#') else {\n                break;\n            };\n\n            // If the line is empty, continue.\n            if line.is_empty() {\n                toml.push(\"\");\n                continue;\n            }\n\n            // Otherwise, the line _must_ start with ` `.\n            let Some(line) = line.strip_prefix(' ') else {\n                break;\n            };\n\n            toml.push(line);\n        }\n\n        // Find the closing `# ///`. The precedence is such that we need to identify the _last_ such\n        // line.\n        //\n        // For example, given:\n        // ```python\n        // # /// script\n        // #\n        // # ///\n        // #\n        // # ///\n        // ```\n        //\n        // The latter `///` is the closing pragma\n        let Some(index) = toml.iter().rev().position(|line| *line == \"///\") else {\n            return Err(Pep723Error::UnclosedBlock);\n        };\n        let index = toml.len() - index;\n\n        // Discard any lines after the closing `# ///`.\n        //\n        // For example, given:\n        // ```python\n        // # /// script\n        // #\n        // # ///\n        // #\n        // #\n        // ```\n        //\n        // We need to discard the last two lines.\n        toml.truncate(index - 1);\n\n        // Join the lines into a single string.\n        let prelude = prelude.to_string();\n        let metadata = toml.join(\"\\n\") + \"\\n\";\n        let postlude = contents\n            .lines()\n            .skip(index + 1)\n            .collect::<Vec<_>>()\n            .join(\"\\n\")\n            + \"\\n\";\n\n        Ok(Some(Self {\n            prelude,\n            metadata,\n            postlude,\n        }))\n    }\n}\n\n/// Extract PEP 723 inline metadata for `python` hooks.\n/// First part of `entry` must be a file path to the Python script.\n/// Effectively, we are implementing a new `python-script` language which works like `script`.\n/// But we don't want to introduce a new language just for this for now.\npub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> {\n    if !hook.additional_dependencies.is_empty() {\n        trace!(\n            \"Skipping reading PEP 723 metadata for hook `{hook}` because it already has `additional_dependencies`\",\n        );\n        return Ok(());\n    }\n\n    let repo_path = hook.repo_path().unwrap_or(hook.work_dir());\n\n    let split = hook.entry.split()?;\n    let file = repo_path.join(&split[0]);\n\n    let Some(script) = Pep723Script::read(&file).await? else {\n        return Ok(());\n    };\n\n    if let Some(dependencies) = script.metadata.dependencies {\n        hook.additional_dependencies = dependencies.into_iter().collect();\n    }\n    if let Some(language_request) = script.metadata.requires_python {\n        if !hook.language_request.is_any() {\n            trace!(\n                \"`language_version` is ignored because `requires_python` is specified in the PEP 723 metadata\"\n            );\n        }\n        hook.language_request = LanguageRequest::parse(hook.language, &language_request)?;\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/src/languages/python/pyproject.rs",
    "content": "use std::io;\nuse std::path::Path;\n\nuse anyhow::Result;\nuse serde::Deserialize;\nuse tracing::trace;\n\nuse crate::config::Language;\nuse crate::hook::Hook;\nuse crate::languages::version::LanguageRequest;\n\n#[derive(Debug, Deserialize)]\nstruct PyProjectToml {\n    project: Option<ProjectTable>,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"kebab-case\")]\nstruct ProjectTable {\n    requires_python: Option<String>,\n}\n\nasync fn extract_pyproject_requires_python(repo_path: &Path) -> Result<Option<String>> {\n    let pyproject = repo_path.join(\"pyproject.toml\");\n    let contents = match fs_err::tokio::read_to_string(&pyproject).await {\n        Ok(contents) => contents,\n        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),\n        Err(err) => return Err(err.into()),\n    };\n\n    let parsed = match toml::from_str::<PyProjectToml>(&contents) {\n        Ok(parsed) => parsed,\n        Err(err) => {\n            trace!(error = %err, \"Ignoring unparsable pyproject.toml\");\n            return Ok(None);\n        }\n    };\n\n    Ok(parsed.project.and_then(|project| project.requires_python))\n}\n\n/// Extract `requires-python` from the hook repo's `pyproject.toml`.\n///\n/// Only acts when `language_request` is still `Any` (i.e. no explicit\n/// `language_version` was configured by the user).\npub(crate) async fn extract_pyproject_metadata(hook: &mut Hook) -> Result<()> {\n    if !hook.language_request.is_any() {\n        trace!(\n            hook = %hook,\n            \"Skipping pyproject.toml metadata extraction because language_version is already configured\",\n        );\n        return Ok(());\n    }\n\n    let Some(repo_path) = hook.repo_path() else {\n        return Ok(());\n    };\n\n    let Some(req_str) = extract_pyproject_requires_python(repo_path).await? else {\n        trace!(hook = %hook, \"No requires-python found in pyproject.toml\");\n        return Ok(());\n    };\n\n    let req = match LanguageRequest::parse(Language::Python, &req_str) {\n        Ok(req) => req,\n        Err(err) => {\n            trace!(%req_str, error = %err, \"Ignoring invalid pyproject.toml requires-python\");\n            return Ok(());\n        }\n    };\n\n    trace!(hook = %hook, version = %req_str, \"Using pyproject.toml-derived language_version\");\n    hook.language_request = req;\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn valid_requires_python() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"pyproject.toml\"),\n            \"[project]\\nrequires-python = \\\">=3.10\\\"\\n\",\n        )\n        .await?;\n\n        let req = extract_pyproject_requires_python(dir.path()).await?;\n        assert_eq!(req.as_deref(), Some(\">=3.10\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn missing_file_returns_none() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        let req = extract_pyproject_requires_python(dir.path()).await?;\n        assert!(req.is_none());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn missing_project_table_returns_none() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"pyproject.toml\"),\n            \"[build-system]\\nrequires = [\\\"setuptools\\\"]\\n\",\n        )\n        .await?;\n\n        let req = extract_pyproject_requires_python(dir.path()).await?;\n        assert!(req.is_none());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn missing_requires_python_returns_none() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"pyproject.toml\"),\n            \"[project]\\nname = \\\"my-project\\\"\\n\",\n        )\n        .await?;\n\n        let req = extract_pyproject_requires_python(dir.path()).await?;\n        assert!(req.is_none());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn unparsable_toml_returns_none() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"pyproject.toml\"),\n            \"this is not valid toml {{{\\n\",\n        )\n        .await?;\n\n        let req = extract_pyproject_requires_python(dir.path()).await?;\n        assert!(req.is_none());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn invalid_version_specifier_is_ignored() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        fs_err::tokio::write(\n            dir.path().join(\"pyproject.toml\"),\n            \"[project]\\nrequires-python = \\\"not a valid specifier\\\"\\n\",\n        )\n        .await?;\n\n        let req = extract_pyproject_requires_python(dir.path()).await?;\n        assert_eq!(req.as_deref(), Some(\"not a valid specifier\"));\n\n        // The string is returned, but LanguageRequest::parse would reject it.\n        // extract_pyproject_metadata handles that gracefully (trace + return Ok(())).\n        let parse_result = LanguageRequest::parse(Language::Python, \"not a valid specifier\");\n        assert!(parse_result.is_err());\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/python/python.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::{Arc, LazyLock};\n\nuse anyhow::{Context, Result};\nuse mea::once::OnceMap;\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse rustc_hash::FxBuildHasher;\nuse serde::Deserialize;\nuse tracing::{debug, trace};\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::InstalledHook;\nuse crate::hook::{Hook, InstallInfo};\nuse crate::languages::LanguageImpl;\nuse crate::languages::python::PythonRequest;\nuse crate::languages::python::uv::Uv;\nuse crate::languages::version::LanguageRequest;\nuse crate::process;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{Store, ToolBucket};\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Python;\n\npub(crate) struct PythonInfo {\n    pub(crate) version: semver::Version,\n    pub(crate) python_exec: PathBuf,\n}\n\n#[derive(Debug, Clone, thiserror::Error)]\npub(crate) enum PythonInfoError {\n    #[error(\"Failed to parse Python info JSON: {0}\")]\n    Parse(String),\n    #[error(\"Failed to query Python info: {0}\")]\n    Query(String),\n    #[error(\"{0}\")]\n    Message(String),\n}\n\nstatic PYTHON_INFO_CACHE: LazyLock<OnceMap<PathBuf, Arc<PythonInfo>, FxBuildHasher>> =\n    LazyLock::new(|| OnceMap::with_hasher(FxBuildHasher));\n\nasync fn query_python_info(python: &Path) -> Result<PythonInfo, PythonInfoError> {\n    #[derive(Deserialize)]\n    struct QueryPythonInfo {\n        version: semver::Version,\n        base_exec_prefix: PathBuf,\n    }\n\n    static QUERY_PYTHON_INFO: &str = indoc::indoc! {r#\"\n    import sys, json\n    info = {\n        \"version\": \".\".join(map(str, sys.version_info[:3])),\n        \"base_exec_prefix\": sys.base_exec_prefix,\n    }\n    print(json.dumps(info))\n    \"#};\n\n    let stdout = Cmd::new(python, \"python -c\")\n        .arg(\"-I\")\n        .arg(\"-c\")\n        .arg(QUERY_PYTHON_INFO)\n        .check(true)\n        .output()\n        .await\n        .map_err(|err| PythonInfoError::Query(err.to_string()))?\n        .stdout;\n\n    let info: QueryPythonInfo =\n        serde_json::from_slice(&stdout).map_err(|err| PythonInfoError::Parse(err.to_string()))?;\n    let python_exec = python_exec(&info.base_exec_prefix);\n\n    Ok(PythonInfo {\n        version: info.version,\n        python_exec,\n    })\n}\n\npub(crate) async fn query_python_info_cached(\n    python: &Path,\n) -> Result<Arc<PythonInfo>, PythonInfoError> {\n    let python = fs::canonicalize(python).unwrap_or_else(|_| python.to_path_buf());\n    PYTHON_INFO_CACHE\n        .try_compute(python.clone(), async move || {\n            let info = query_python_info(&python).await?;\n            Ok(Arc::new(info))\n        })\n        .await\n}\n\nimpl LanguageImpl for Python {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let uv_dir = store.tools_path(ToolBucket::Uv);\n        let uv = Uv::install(store, &uv_dir)\n            .await\n            .context(\"Failed to install uv\")?;\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        debug!(%hook, target = %info.env_path.display(), \"Installing environment\");\n\n        // Create venv (auto download Python if needed)\n        Self::create_venv(&uv, store, &info, &hook.language_request)\n            .await\n            .context(\"Failed to create Python virtual environment\")?;\n\n        // Install dependencies\n        let mut pip_install = Self::pip_install_command(&uv, store, &info.env_path);\n\n        if let Some(repo_path) = hook.repo_path() {\n            trace!(\n                \"Installing dependencies from repo path: {}\",\n                repo_path.display()\n            );\n            pip_install\n                .arg(\"--directory\")\n                .arg(repo_path)\n                .arg(\".\")\n                .args(&hook.additional_dependencies)\n                .output()\n                .await?;\n        } else if !hook.additional_dependencies.is_empty() {\n            trace!(\n                \"Installing additional dependencies: {:?}\",\n                hook.additional_dependencies\n            );\n            pip_install\n                .args(&hook.additional_dependencies)\n                .output()\n                .await?;\n        } else {\n            debug!(\"No dependencies to install\");\n        }\n\n        let python = python_exec(&info.env_path);\n        let python_info = query_python_info(&python)\n            .await\n            .context(\"Failed to query Python info\")?;\n\n        info.with_language_version(python_info.version)\n            .with_toolchain(python_info.python_exec);\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        let python = python_exec(&info.env_path);\n        let python_info = query_python_info_cached(&python)\n            .await\n            .context(\"Failed to query Python info\")?;\n\n        if python_info.version != info.language_version {\n            anyhow::bail!(\n                \"Python version mismatch: expected {}, found {}\",\n                info.language_version,\n                python_info.version\n            );\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Python must have env path\");\n        let new_path = prepend_paths(&[&bin_dir(env_dir)]).context(\"Failed to join PATH\")?;\n        let entry = hook.entry.resolve(Some(&new_path))?;\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"python hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::VIRTUAL_ENV, env_dir)\n                .env(EnvVars::PATH, &new_path)\n                .env_remove(EnvVars::PYTHONHOME)\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\nfn to_uv_python_request(request: &LanguageRequest) -> Option<String> {\n    match request {\n        LanguageRequest::Any { .. } => None,\n        LanguageRequest::Python(request) => match request {\n            PythonRequest::Any => None,\n            PythonRequest::Major(major) => Some(format!(\"{major}\")),\n            PythonRequest::MajorMinor(major, minor) => Some(format!(\"{major}.{minor}\")),\n            PythonRequest::MajorMinorPatch(major, minor, patch) => {\n                Some(format!(\"{major}.{minor}.{patch}\"))\n            }\n            PythonRequest::Range(_, raw) => Some(raw.clone()),\n            PythonRequest::Path(path) => Some(path.to_string_lossy().to_string()),\n        },\n        _ => unreachable!(),\n    }\n}\n\nimpl Python {\n    fn remove_uv_python_override_envs(cmd: &mut Cmd) -> &mut Cmd {\n        // Ensure uv selects the hook virtualenv interpreter.\n        cmd.env_remove(EnvVars::UV_PYTHON)\n            .env_remove(EnvVars::UV_SYSTEM_PYTHON)\n            // `--managed-python` and `--no-managed-python` conflict with our explicit preference.\n            .env_remove(EnvVars::UV_MANAGED_PYTHON)\n            .env_remove(EnvVars::UV_NO_MANAGED_PYTHON)\n    }\n\n    fn pip_install_command(uv: &Uv, store: &Store, env_path: &Path) -> Cmd {\n        let mut cmd = uv.cmd(\"uv pip\", store);\n        cmd.arg(\"pip\")\n            .arg(\"install\")\n            // Explicitly set project to root to avoid uv searching for project-level configs.\n            // `--project` has no other effect on `uv pip` subcommands.\n            .args([\"--project\", \"/\"])\n            .env(EnvVars::VIRTUAL_ENV, env_path);\n        Self::remove_uv_python_override_envs(&mut cmd)\n            // Remove GIT environment variables that may leak from git hooks (e.g., in worktrees).\n            // These can break packages using setuptools_scm for file discovery.\n            .remove_git_envs()\n            .check(true);\n        cmd\n    }\n\n    async fn create_venv(\n        uv: &Uv,\n        store: &Store,\n        info: &InstallInfo,\n        python_request: &LanguageRequest,\n    ) -> Result<()> {\n        // Try creating venv without downloads first\n        match Self::create_venv_command(uv, store, info, python_request, false, false)\n            .check(true)\n            .output()\n            .await\n        {\n            Ok(_) => {\n                debug!(\n                    \"Venv created successfully with no downloads: `{}`\",\n                    info.env_path.display()\n                );\n                Ok(())\n            }\n            Err(e @ process::Error::Status { .. }) => {\n                // Check if we can retry with downloads\n                if Self::can_retry_with_downloads(&e) {\n                    if !python_request.allows_download() {\n                        anyhow::bail!(\n                            \"No suitable system Python version found and downloads are disabled\"\n                        );\n                    }\n\n                    debug!(\n                        \"Retrying venv creation with managed Python downloads: `{}`\",\n                        info.env_path.display()\n                    );\n                    Self::create_venv_command(uv, store, info, python_request, true, true)\n                        .check(true)\n                        .output()\n                        .await?;\n                    return Ok(());\n                }\n                // If we can't retry, return the original error\n                Err(e.into())\n            }\n            Err(e) => {\n                debug!(\"Failed to create venv `{}`: {e}\", info.env_path.display());\n                Err(e.into())\n            }\n        }\n    }\n\n    fn create_venv_command(\n        uv: &Uv,\n        store: &Store,\n        info: &InstallInfo,\n        python_request: &LanguageRequest,\n        set_install_dir: bool,\n        allow_downloads: bool,\n    ) -> Cmd {\n        let mut cmd = uv.cmd(\"create venv\", store);\n        cmd.arg(\"venv\")\n            .arg(&info.env_path)\n            .args([\"--python-preference\", \"managed\"])\n            // Avoid discovering a project or workspace\n            .arg(\"--no-project\")\n            // Explicitly set project to root to avoid uv searching for project-level configs\n            .args([\"--project\", \"/\"]);\n        Self::remove_uv_python_override_envs(&mut cmd);\n        if set_install_dir {\n            cmd.env(\n                EnvVars::UV_PYTHON_INSTALL_DIR,\n                store.tools_path(ToolBucket::Python),\n            );\n        }\n        if allow_downloads {\n            cmd.arg(\"--allow-python-downloads\");\n        } else {\n            cmd.arg(\"--no-python-downloads\");\n        }\n\n        if let Some(python) = to_uv_python_request(python_request) {\n            cmd.arg(\"--python\").arg(python);\n        }\n\n        cmd\n    }\n\n    fn can_retry_with_downloads(error: &process::Error) -> bool {\n        let process::Error::Status {\n            error:\n                process::StatusError {\n                    output: Some(output),\n                    ..\n                },\n            ..\n        } = error\n        else {\n            return false;\n        };\n\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        stderr.contains(\"A managed Python download is available\")\n    }\n}\n\nfn bin_dir(venv: &Path) -> PathBuf {\n    if cfg!(windows) {\n        venv.join(\"Scripts\")\n    } else {\n        venv.join(\"bin\")\n    }\n}\n\npub(crate) fn python_exec(venv: &Path) -> PathBuf {\n    bin_dir(venv).join(\"python\").with_extension(EXE_EXTENSION)\n}\n\n#[cfg(test)]\nmod tests {\n    use std::collections::HashMap;\n    use std::path::PathBuf;\n\n    use prek_consts::env_vars::EnvVars;\n    use rustc_hash::FxHashSet;\n\n    use super::Python;\n    use crate::config::Language;\n    use crate::hook::InstallInfo;\n    use crate::languages::python::uv::Uv;\n    use crate::languages::version::LanguageRequest;\n    use crate::store::Store;\n\n    fn setup_test_install() -> (tempfile::TempDir, Uv, Store, InstallInfo) {\n        let temp = tempfile::tempdir().expect(\"create tempdir\");\n        let hooks_dir = temp.path().join(\"hooks\");\n        fs_err::create_dir_all(&hooks_dir).expect(\"create hooks dir\");\n\n        let info = InstallInfo::new(Language::Python, FxHashSet::default(), &hooks_dir)\n            .expect(\"create install info\");\n        let store = Store::from_path(temp.path().join(\"store\"));\n        let uv = Uv::new(PathBuf::from(\"uv\"));\n\n        (temp, uv, store, info)\n    }\n\n    fn env_map(cmd: &crate::process::Cmd) -> HashMap<String, Option<String>> {\n        cmd.get_envs()\n            .map(|(key, val)| {\n                (\n                    key.to_string_lossy().into_owned(),\n                    val.map(|v| v.to_string_lossy().into_owned()),\n                )\n            })\n            .collect()\n    }\n\n    #[test]\n    fn create_venv_command_removes_uv_system_python_override() {\n        let (_temp, uv, store, info) = setup_test_install();\n        let request = LanguageRequest::Any { system_only: false };\n        let cmd = Python::create_venv_command(&uv, &store, &info, &request, false, false);\n        let envs = env_map(&cmd);\n\n        assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None));\n        assert_eq!(envs.get(EnvVars::UV_PYTHON), Some(&None));\n        assert_eq!(envs.get(EnvVars::UV_MANAGED_PYTHON), Some(&None));\n        assert_eq!(envs.get(EnvVars::UV_NO_MANAGED_PYTHON), Some(&None));\n    }\n\n    #[test]\n    fn pip_install_command_removes_uv_system_python_override() {\n        let (_temp, uv, store, info) = setup_test_install();\n        let cmd = Python::pip_install_command(&uv, &store, &info.env_path);\n        let envs = env_map(&cmd);\n\n        assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None));\n        assert_eq!(envs.get(EnvVars::UV_PYTHON), Some(&None));\n        assert_eq!(envs.get(EnvVars::UV_MANAGED_PYTHON), Some(&None));\n        assert_eq!(envs.get(EnvVars::UV_NO_MANAGED_PYTHON), Some(&None));\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/python/uv.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\nuse std::sync::LazyLock;\nuse std::time::Duration;\n\nuse anyhow::{Context, Result, bail};\nuse http::header::ACCEPT;\nuse semver::{Version, VersionReq};\nuse target_lexicon::{Architecture, ArmArchitecture, Environment, HOST, OperatingSystem};\nuse tokio::task::JoinSet;\nuse tracing::{debug, trace, warn};\n\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::fs::LockedFile;\nuse crate::http::{REQWEST_CLIENT, download_and_extract};\nuse crate::process::Cmd;\nuse crate::store::{CacheBucket, Store};\nuse crate::version;\n\n// The version range of `uv` we will install. Should update periodically.\nconst CUR_UV_VERSION: &str = \"0.10.9\";\nstatic UV_VERSION_RANGE: LazyLock<VersionReq> =\n    LazyLock::new(|| VersionReq::parse(\">=0.7.0\").unwrap());\n\nfn wheel_platform_tag_for_host(\n    operating_system: OperatingSystem,\n    architecture: Architecture,\n    environment: Environment,\n) -> Result<&'static str> {\n    let platform_tag = match (operating_system, architecture, environment) {\n        // Linux platforms\n        (OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => \"musllinux_1_1_x86_64\",\n        (OperatingSystem::Linux, Architecture::X86_64, _) => {\n            \"manylinux_2_17_x86_64.manylinux2014_x86_64\"\n        }\n        (OperatingSystem::Linux, Architecture::Aarch64(_), _) => {\n            \"manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64\"\n        }\n        (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), Environment::Musl) => {\n            \"manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l\"\n        }\n        (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), _) => {\n            \"manylinux_2_17_armv7l.manylinux2014_armv7l\"\n        }\n        (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv6), _) => \"linux_armv6l\", // Raspberry Pi Zero/1\n        (OperatingSystem::Linux, Architecture::X86_32(_), Environment::Musl) => {\n            \"musllinux_1_1_i686\"\n        }\n        (OperatingSystem::Linux, Architecture::X86_32(_), _) => {\n            \"manylinux_2_17_i686.manylinux2014_i686\"\n        }\n        (OperatingSystem::Linux, Architecture::Powerpc64, _) => {\n            \"manylinux_2_17_ppc64.manylinux2014_ppc64\"\n        }\n        (OperatingSystem::Linux, Architecture::Powerpc64le, _) => {\n            \"manylinux_2_17_ppc64le.manylinux2014_ppc64le\"\n        }\n        (OperatingSystem::Linux, Architecture::S390x, _) => {\n            \"manylinux_2_17_s390x.manylinux2014_s390x\"\n        }\n        (OperatingSystem::Linux, Architecture::Riscv64(_), _) => \"manylinux_2_31_riscv64\",\n\n        // macOS platforms\n        (OperatingSystem::Darwin(_), Architecture::X86_64, _) => \"macosx_10_12_x86_64\",\n        (OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => \"macosx_11_0_arm64\",\n\n        // Windows platforms\n        (OperatingSystem::Windows, Architecture::X86_64, _) => \"win_amd64\",\n        (OperatingSystem::Windows, Architecture::X86_32(_), _) => \"win32\",\n        (OperatingSystem::Windows, Architecture::Aarch64(_), _) => \"win_arm64\",\n\n        _ => bail!(\n            \"Unsupported platform: operating_system={operating_system:?}, architecture={architecture:?}, environment={environment:?}\"\n        ),\n    };\n\n    Ok(platform_tag)\n}\n\n// Get the uv wheel platform tag for the current host.\nfn get_wheel_platform_tag() -> Result<String> {\n    wheel_platform_tag_for_host(HOST.operating_system, HOST.architecture, HOST.environment)\n        .map(ToString::to_string)\n}\n\nfn get_uv_version(uv_path: &Path) -> Result<Version> {\n    let output = Command::new(uv_path)\n        .arg(\"--version\")\n        .output()\n        .context(\"Failed to execute uv\")?;\n\n    if !output.status.success() {\n        bail!(\"Failed to get uv version\");\n    }\n\n    let version_output = String::from_utf8_lossy(&output.stdout);\n    let version_str = version_output\n        .split_whitespace()\n        .nth(1)\n        .context(\"Invalid version output format\")?;\n\n    Version::parse(version_str).map_err(Into::into)\n}\n\nfn validate_uv_binary(uv_path: &Path) -> Result<Version> {\n    let version = get_uv_version(uv_path)?;\n    if !UV_VERSION_RANGE.matches(&version) {\n        bail!(\n            \"uv version `{version}` does not satisfy required range `{}`\",\n            &*UV_VERSION_RANGE\n        );\n    }\n    Ok(version)\n}\n\nasync fn replace_uv_binary(source: &Path, target_path: &Path) -> Result<()> {\n    if let Some(parent) = target_path.parent() {\n        fs_err::tokio::create_dir_all(parent).await?;\n    }\n\n    if target_path.exists() {\n        debug!(target = %target_path.display(), \"Removing existing uv binary\");\n        fs_err::tokio::remove_file(target_path).await?;\n    }\n\n    fs_err::tokio::rename(source, target_path).await?;\n    Ok(())\n}\n\nstatic UV_EXE: LazyLock<Option<(PathBuf, Version)>> = LazyLock::new(|| {\n    for uv_path in which::which_all(\"uv\").ok()? {\n        debug!(\"Found uv in PATH: {}\", uv_path.display());\n\n        match validate_uv_binary(&uv_path) {\n            Ok(version) => return Some((uv_path, version)),\n            Err(err) => warn!(uv = %uv_path.display(), error = %err, \"Skipping incompatible uv\"),\n        }\n    }\n\n    None\n});\n\n#[derive(Debug)]\nenum PyPiMirror {\n    Pypi,\n    Tuna,\n    Aliyun,\n    Tencent,\n    Custom(String),\n}\n\n// TODO: support reading pypi source user config, or allow user to set mirror\n// TODO: allow opt-out uv\n\nimpl PyPiMirror {\n    fn url(&self) -> &str {\n        match self {\n            Self::Pypi => \"https://pypi.org/simple/\",\n            Self::Tuna => \"https://pypi.tuna.tsinghua.edu.cn/simple/\",\n            Self::Aliyun => \"https://mirrors.aliyun.com/pypi/simple/\",\n            Self::Tencent => \"https://mirrors.cloud.tencent.com/pypi/simple/\",\n            Self::Custom(url) => url,\n        }\n    }\n\n    fn iter() -> impl Iterator<Item = Self> {\n        vec![Self::Pypi, Self::Tuna, Self::Aliyun, Self::Tencent].into_iter()\n    }\n}\n\n#[derive(Debug)]\nenum InstallSource {\n    /// Download uv from GitHub releases.\n    GitHub,\n    /// Download uv from `PyPi`.\n    PyPi(PyPiMirror),\n    /// Install uv by running `pip install uv`.\n    Pip,\n}\n\nimpl InstallSource {\n    async fn install(&self, store: &Store, target: &Path) -> Result<()> {\n        match self {\n            Self::GitHub => self.install_from_github(store, target).await,\n            Self::PyPi(source) => self.install_from_pypi(store, target, source).await,\n            Self::Pip => self.install_from_pip(target).await,\n        }\n    }\n\n    async fn install_from_github(&self, store: &Store, target: &Path) -> Result<()> {\n        let ext = if cfg!(windows) { \"zip\" } else { \"tar.gz\" };\n        let archive_name = format!(\"uv-{HOST}.{ext}\");\n        let download_url = format!(\n            \"https://github.com/astral-sh/uv/releases/download/{CUR_UV_VERSION}/{archive_name}\"\n        );\n\n        download_and_extract(&download_url, &archive_name, store, async |extracted| {\n            let source = extracted.join(\"uv\").with_extension(EXE_EXTENSION);\n            let target_path = target.join(\"uv\").with_extension(EXE_EXTENSION);\n\n            debug!(?source, target = %target_path.display(), \"Moving uv to target\");\n            // TODO: retry on Windows\n            replace_uv_binary(&source, &target_path).await?;\n\n            anyhow::Ok(())\n        })\n        .await\n        .context(\"Failed to download and extract uv\")?;\n\n        Ok(())\n    }\n\n    async fn install_from_pypi(\n        &self,\n        store: &Store,\n        target: &Path,\n        source: &PyPiMirror,\n    ) -> Result<()> {\n        let platform_tag = get_wheel_platform_tag()?;\n        let wheel_name = format!(\"uv-{CUR_UV_VERSION}-py3-none-{platform_tag}.whl\");\n\n        // Use PyPI JSON API instead of parsing HTML\n        let api_url = match source {\n            PyPiMirror::Pypi => format!(\"https://pypi.org/pypi/uv/{CUR_UV_VERSION}/json\"),\n            // For mirrors, we'll fall back to simple API approach\n            _ => return self.install_from_simple_api(store, target, source).await,\n        };\n\n        debug!(\"Fetching uv metadata from: {}\", api_url);\n        let response = REQWEST_CLIENT\n            .get(&api_url)\n            .header(\"Accept\", \"*/*\")\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            bail!(\n                \"Failed to fetch uv metadata from PyPI: {}\",\n                response.status()\n            );\n        }\n\n        let metadata: serde_json::Value = response.json().await?;\n        let files = metadata[\"urls\"]\n            .as_array()\n            .context(\"Invalid PyPI response: missing urls\")?;\n\n        let wheel_file = files\n            .iter()\n            .find(|file| {\n                file[\"filename\"].as_str() == Some(&wheel_name)\n                    && file[\"packagetype\"].as_str() == Some(\"bdist_wheel\")\n                    && file[\"yanked\"].as_bool() != Some(true)\n            })\n            .with_context(|| format!(\"Could not find wheel for {wheel_name} in PyPI response\"))?;\n\n        let download_url = wheel_file[\"url\"]\n            .as_str()\n            .context(\"Missing download URL in PyPI response\")?;\n\n        self.download_and_extract_wheel(store, target, &wheel_name, download_url)\n            .await\n    }\n\n    async fn install_from_simple_api(\n        &self,\n        store: &Store,\n        target: &Path,\n        source: &PyPiMirror,\n    ) -> Result<()> {\n        // Fallback for mirrors that don't support JSON API\n        let platform_tag = get_wheel_platform_tag()?;\n        let wheel_name = format!(\"uv-{CUR_UV_VERSION}-py3-none-{platform_tag}.whl\");\n\n        let simple_url = format!(\"{}uv/\", source.url());\n\n        debug!(\"Fetching from simple API: {}\", simple_url);\n        let response = REQWEST_CLIENT\n            .get(&simple_url)\n            .header(ACCEPT, \"*/*\")\n            .send()\n            .await?;\n        let html = response.text().await?;\n\n        // Simple string search to find the wheel download link\n        let search_pattern = r#\"href=\"\"#.to_string();\n\n        let download_path = html\n            .lines()\n            .find(|line| line.contains(&wheel_name))\n            .and_then(|line| {\n                if let Some(start) = line.find(&search_pattern) {\n                    let start = start + search_pattern.len();\n                    if let Some(end) = line[start..].find('\"') {\n                        return Some(&line[start..start + end]);\n                    }\n                }\n                None\n            })\n            .with_context(|| {\n                format!(\n                    \"Could not find wheel download link for {wheel_name} in simple API response\"\n                )\n            })?;\n\n        // Resolve relative URLs\n        let download_url = if download_path.starts_with(\"http\") {\n            download_path.to_string()\n        } else {\n            format!(\"{simple_url}{download_path}\")\n        };\n\n        self.download_and_extract_wheel(store, target, &wheel_name, &download_url)\n            .await\n    }\n\n    async fn download_and_extract_wheel(\n        &self,\n        store: &Store,\n        target: &Path,\n        filename: &str,\n        download_url: &str,\n    ) -> Result<()> {\n        download_and_extract(download_url, filename, store, async |extracted| {\n            // Find the uv binary in the extracted contents\n            let data_dir = format!(\"uv-{CUR_UV_VERSION}.data\");\n            let extracted_uv = extracted\n                .join(data_dir)\n                .join(\"scripts\")\n                .join(\"uv\")\n                .with_extension(EXE_EXTENSION);\n\n            // Copy the binary to the target location\n            let target_path = target.join(\"uv\").with_extension(EXE_EXTENSION);\n\n            debug!(?extracted_uv, target = %target_path.display(), \"Moving uv to target\");\n            replace_uv_binary(&extracted_uv, &target_path).await?;\n\n            // Set executable permissions on Unix\n            #[cfg(unix)]\n            {\n                use std::os::unix::fs::PermissionsExt;\n                let metadata = fs_err::tokio::metadata(&target_path).await?;\n                let mut perms = metadata.permissions();\n                perms.set_mode(0o755);\n                fs_err::tokio::set_permissions(&target_path, perms).await?;\n            }\n\n            Ok(())\n        })\n        .await\n        .context(\"Failed to download and extract uv wheel\")?;\n\n        Ok(())\n    }\n\n    async fn install_from_pip(&self, target: &Path) -> Result<()> {\n        // When running `pip install` in multiple threads, it can fail\n        // without extracting files properly.\n        Cmd::new(\"python3\", \"pip install uv\")\n            .arg(\"-m\")\n            .arg(\"pip\")\n            .arg(\"install\")\n            .arg(\"--prefix\")\n            .arg(target)\n            .arg(\"--only-binary=:all:\")\n            .arg(\"--progress-bar=off\")\n            .arg(\"--disable-pip-version-check\")\n            .arg(format!(\"uv=={CUR_UV_VERSION}\"))\n            .check(true)\n            .output()\n            .await?;\n\n        let local_dir = target.join(\"local\");\n        let uv_src = if local_dir.is_dir() {\n            &local_dir\n        } else {\n            target\n        };\n\n        let bin_dir = uv_src.join(if cfg!(windows) { \"Scripts\" } else { \"bin\" });\n        let lib_dir = uv_src.join(if cfg!(windows) { \"Lib\" } else { \"lib\" });\n\n        let uv = uv_src\n            .join(&bin_dir)\n            .join(\"uv\")\n            .with_extension(EXE_EXTENSION);\n        fs_err::tokio::rename(&uv, target.join(\"uv\").with_extension(EXE_EXTENSION)).await?;\n        fs_err::tokio::remove_dir_all(bin_dir).await?;\n        fs_err::tokio::remove_dir_all(lib_dir).await?;\n\n        Ok(())\n    }\n}\n\npub(crate) struct Uv {\n    path: PathBuf,\n}\n\nimpl Uv {\n    pub(crate) fn new(path: PathBuf) -> Self {\n        Self { path }\n    }\n\n    pub(crate) fn cmd(&self, summary: &str, store: &Store) -> Cmd {\n        let mut cmd = Cmd::new(&self.path, summary);\n        cmd.env(EnvVars::UV_CACHE_DIR, store.cache_path(CacheBucket::Uv));\n        cmd\n    }\n\n    async fn select_source() -> Result<InstallSource> {\n        async fn check_github() -> Result<bool> {\n            let url = format!(\n                \"https://github.com/astral-sh/uv/releases/download/{CUR_UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz\"\n            );\n            let response = REQWEST_CLIENT\n                .head(url)\n                .timeout(Duration::from_secs(3))\n                .send()\n                .await?;\n            trace!(?response, \"Checked GitHub\");\n            Ok(response.status().is_success())\n        }\n\n        async fn select_best_pypi() -> Result<PyPiMirror> {\n            let mut best = PyPiMirror::Pypi;\n            let mut tasks = PyPiMirror::iter()\n                .map(|source| {\n                    let client = REQWEST_CLIENT.clone();\n                    async move {\n                        let url = format!(\"{}uv/\", source.url());\n                        let response = client\n                            .head(&url)\n                            .header(\"User-Agent\", format!(\"prek/{}\", version::version().version))\n                            .header(\"Accept\", \"*/*\")\n                            .timeout(Duration::from_secs(2))\n                            .send()\n                            .await;\n                        (source, response)\n                    }\n                })\n                .collect::<JoinSet<_>>();\n\n            while let Some(result) = tasks.join_next().await {\n                if let Ok((source, response)) = result {\n                    if let Ok(resp) = response\n                        && resp.status().is_success()\n                    {\n                        best = source;\n                        break;\n                    }\n                }\n            }\n\n            Ok(best)\n        }\n\n        let source = tokio::select! {\n                Ok(true) = check_github() => InstallSource::GitHub,\n                Ok(source) = select_best_pypi() => InstallSource::PyPi(source),\n                else => {\n                    warn!(\"Failed to check uv source availability, falling back to pip install\");\n                    InstallSource::Pip\n                }\n\n        };\n\n        trace!(?source, \"Selected uv source\");\n        Ok(source)\n    }\n\n    pub(crate) async fn install(store: &Store, uv_dir: &Path) -> Result<Self> {\n        // 1) Check `uv` alongside `prek` binary (e.g. `uv tool install prek --with uv`)\n        let prek_exe = std::env::current_exe()?.canonicalize()?;\n        if let Some(prek_dir) = prek_exe.parent() {\n            let uv_path = prek_dir.join(\"uv\").with_extension(EXE_EXTENSION);\n            if uv_path.is_file() {\n                match validate_uv_binary(&uv_path) {\n                    Ok(_) => {\n                        trace!(uv = %uv_path.display(), \"Found compatible uv alongside prek binary\");\n                        return Ok(Self::new(uv_path));\n                    }\n                    Err(err) => {\n                        warn!(uv = %uv_path.display(), error = %err, \"Skipping incompatible uv\");\n                    }\n                }\n            }\n        }\n\n        // 2) Check if system `uv` meets minimum version requirement\n        if let Some((uv_path, version)) = UV_EXE.as_ref() {\n            trace!(\n                \"Using system uv version {} at {}\",\n                version,\n                uv_path.display()\n            );\n            return Ok(Self::new(uv_path.clone()));\n        }\n\n        // 3) Use or install managed `uv`\n        let uv_path = uv_dir.join(\"uv\").with_extension(EXE_EXTENSION);\n\n        if uv_path.is_file() {\n            match validate_uv_binary(&uv_path) {\n                Ok(_) => {\n                    trace!(uv = %uv_path.display(), \"Found compatible managed uv\");\n                    return Ok(Self::new(uv_path));\n                }\n                Err(err) => {\n                    warn!(uv = %uv_path.display(), error = %err, \"Skipping incompatible managed uv\");\n                }\n            }\n        }\n\n        // Install new managed uv with proper locking\n        fs_err::tokio::create_dir_all(&uv_dir).await?;\n        let _lock = LockedFile::acquire(uv_dir.join(\".lock\"), \"uv\").await?;\n\n        if uv_path.is_file() {\n            match validate_uv_binary(&uv_path) {\n                Ok(_) => {\n                    trace!(uv = %uv_path.display(), \"Found compatible managed uv\");\n                    return Ok(Self::new(uv_path));\n                }\n                Err(err) => {\n                    warn!(uv = %uv_path.display(), error = %err, \"Skipping incompatible managed uv\");\n                }\n            }\n        }\n\n        let source = if let Some(uv_source) = uv_source_from_env() {\n            uv_source\n        } else {\n            Self::select_source().await?\n        };\n        source.install(store, uv_dir).await?;\n\n        // Downloaded `uv` binaries can be present on disk but still fail to execute in the\n        // current runtime environment, such as when the libc variant or dynamic loader path\n        // does not match the host. Validate immediately so we can surface a clear error here.\n        match validate_uv_binary(&uv_path) {\n            Ok(version) => trace!(version = %version, \"Successfully installed uv\"),\n            Err(err) => bail!(\n                \"Installed uv at `{}` failed validation: {err}. \\\n                This usually means the downloaded uv binary is incompatible with the \\\n                current runtime environment, for example due to a libc mismatch or a \\\n                missing dynamic loader path. If this keeps happening, please report it \\\n                with details about your environment and the full error output.\",\n                uv_path.display()\n            ),\n        }\n\n        Ok(Self::new(uv_path))\n    }\n}\n\nfn uv_source_from_env() -> Option<InstallSource> {\n    let var = EnvVars::var(EnvVars::PREK_UV_SOURCE).ok()?;\n    match var.as_str() {\n        \"github\" => Some(InstallSource::GitHub),\n        \"pypi\" => Some(InstallSource::PyPi(PyPiMirror::Pypi)),\n        \"tuna\" => Some(InstallSource::PyPi(PyPiMirror::Tuna)),\n        \"aliyun\" => Some(InstallSource::PyPi(PyPiMirror::Aliyun)),\n        \"tencent\" => Some(InstallSource::PyPi(PyPiMirror::Tencent)),\n        \"pip\" => Some(InstallSource::Pip),\n        custom if custom.starts_with(\"http\") => Some(InstallSource::PyPi(PyPiMirror::Custom(var))),\n        _ => {\n            warn!(\"Invalid UV_SOURCE value: {}\", var);\n            None\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn ensure_cur_uv_version_in_range() {\n        let version = Version::parse(CUR_UV_VERSION).expect(\"Invalid CUR_UV_VERSION\");\n        assert!(\n            UV_VERSION_RANGE.matches(&version),\n            \"CUR_UV_VERSION {CUR_UV_VERSION} does not satisfy the version requirement {}\",\n            &*UV_VERSION_RANGE\n        );\n    }\n\n    #[test]\n    fn wheel_platform_tag_x86_64_linux_gnu() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::X86_64,\n            Environment::Gnu,\n        )?;\n        assert_eq!(tag, \"manylinux_2_17_x86_64.manylinux2014_x86_64\");\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_x86_64_linux_musl() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::X86_64,\n            Environment::Musl,\n        )?;\n        assert_eq!(tag, \"musllinux_1_1_x86_64\");\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_i686_linux_gnu() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::X86_32(target_lexicon::X86_32Architecture::I686),\n            Environment::Gnu,\n        )?;\n        assert_eq!(tag, \"manylinux_2_17_i686.manylinux2014_i686\");\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_i686_linux_musl() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::X86_32(target_lexicon::X86_32Architecture::I686),\n            Environment::Musl,\n        )?;\n        assert_eq!(tag, \"musllinux_1_1_i686\");\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_aarch64_linux_gnu() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),\n            Environment::Gnu,\n        )?;\n        assert_eq!(\n            tag,\n            \"manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64\"\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_aarch64_linux_musl() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),\n            Environment::Musl,\n        )?;\n        // aarch64 uses a single dual-tagged wheel for both glibc and musl\n        assert_eq!(\n            tag,\n            \"manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64\"\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_armv7_linux_gnu() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::Arm(ArmArchitecture::Armv7),\n            Environment::Gnu,\n        )?;\n        assert_eq!(tag, \"manylinux_2_17_armv7l.manylinux2014_armv7l\");\n        Ok(())\n    }\n\n    #[test]\n    fn wheel_platform_tag_armv7_linux_musl() -> Result<()> {\n        let tag = wheel_platform_tag_for_host(\n            OperatingSystem::Linux,\n            Architecture::Arm(ArmArchitecture::Armv7),\n            Environment::Musl,\n        )?;\n        assert_eq!(\n            tag,\n            \"manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l\"\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn replace_uv_binary_overwrites_existing_file() -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let source = temp.path().join(\"source-uv\");\n        let target_dir = temp.path().join(\"tools\").join(\"uv\");\n        let target_path = target_dir.join(\"uv\").with_extension(EXE_EXTENSION);\n\n        fs_err::create_dir_all(&target_dir)?;\n        fs_err::write(&source, b\"new\")?;\n        fs_err::write(&target_path, b\"old\")?;\n\n        replace_uv_binary(&source, &target_path).await?;\n\n        assert!(!source.exists());\n        assert_eq!(fs_err::read(&target_path)?, b\"new\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn replace_uv_binary_recreates_missing_parent_dir() -> Result<()> {\n        let temp = tempfile::tempdir()?;\n        let source = temp.path().join(\"source-uv\");\n        let target_dir = temp.path().join(\"tools\").join(\"uv\");\n        let target_path = target_dir.join(\"uv\").with_extension(EXE_EXTENSION);\n\n        fs_err::create_dir_all(&target_dir)?;\n        fs_err::write(&target_path, b\"old\")?;\n        fs_err::remove_dir_all(&target_dir)?;\n        fs_err::write(&source, b\"new\")?;\n\n        replace_uv_binary(&source, &target_path).await?;\n\n        assert!(target_dir.exists());\n        assert_eq!(fs_err::read(&target_path)?, b\"new\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/python/version.rs",
    "content": "//! Implement `-p <python_spec>` argument parser of `virutualenv` from\n//! <https://github.com/pypa/virtualenv/blob/216dc9f3592aa1f3345290702f0e7ba3432af3ce/src/virtualenv/discovery/py_spec.py>\nuse std::path::PathBuf;\nuse std::str::FromStr;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) enum PythonRequest {\n    Any,\n    Major(u64),\n    MajorMinor(u64, u64),\n    MajorMinorPatch(u64, u64, u64),\n    Path(PathBuf),\n    Range(semver::VersionReq, String),\n}\n\n/// Represents a request for a specific Python version or path.\n/// example formats:\n/// - `python`\n/// - `python3`\n/// - `python3.12`\n/// - `python3.13.2`\n/// - `python311`\n/// - `3`\n/// - `3.12`\n/// - `3.12.3`\n/// - `>=3.12`\n/// - `>=3.8, <3.12`\n/// - `/path/to/python`\n/// - `/path/to/python3.12`\n// TODO: support version like `3.8b1`, `3.8rc2`, `python3.8t`, `python3.8-64`, `pypy3.8`.\nimpl FromStr for PythonRequest {\n    type Err = Error;\n\n    fn from_str(request: &str) -> Result<Self, Self::Err> {\n        if request.is_empty() {\n            return Ok(Self::Any);\n        }\n\n        // Check if it starts with \"python\" - parse as specific version\n        if let Some(version_part) = request.strip_prefix(\"python\") {\n            if version_part.is_empty() {\n                return Ok(Self::Any);\n            }\n\n            Self::parse_version_numbers(version_part, request)\n        } else {\n            Self::parse_version_numbers(request, request)\n                .or_else(|_| {\n                    // Try to parse as a VersionReq (like \">= 3.12\" or \">=3.8, <3.12\")\n                    semver::VersionReq::parse(request)\n                        .map(|version_req| PythonRequest::Range(version_req, request.into()))\n                        .map_err(|_| Error::InvalidVersion(request.to_string()))\n                })\n                .or_else(|_| {\n                    // If it doesn't match any known format, treat it as a path\n                    let path = PathBuf::from(request);\n                    if path.exists() {\n                        Ok(PythonRequest::Path(path))\n                    } else {\n                        Err(Error::InvalidVersion(request.to_string()))\n                    }\n                })\n        }\n    }\n}\n\nimpl PythonRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, PythonRequest::Any)\n    }\n\n    /// Parse version numbers into appropriate `PythonRequest` variants\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<PythonRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n        let parts = split_wheel_tag_version(parts);\n\n        match parts[..] {\n            [major] => Ok(PythonRequest::Major(major)),\n            [major, minor] => Ok(PythonRequest::MajorMinor(major, minor)),\n            [major, minor, patch] => Ok(PythonRequest::MajorMinorPatch(major, minor, patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        let version = &install_info.language_version;\n        match self {\n            PythonRequest::Any => true,\n            PythonRequest::Major(major) => version.major == *major,\n            PythonRequest::MajorMinor(major, minor) => {\n                version.major == *major && version.minor == *minor\n            }\n            PythonRequest::MajorMinorPatch(major, minor, patch) => {\n                version.major == *major && version.minor == *minor && version.patch == *patch\n            }\n            // FIXME: consider resolving symlinks and normalizing paths before comparison\n            PythonRequest::Path(path) => path == &install_info.toolchain,\n            PythonRequest::Range(req, _) => req.matches(version),\n        }\n    }\n}\n\n/// Convert a wheel tag formatted version (e.g., `38`) to multiple components (e.g., `3.8`).\n///\n/// The major version is always assumed to be a single digit 0-9. The minor version is all\n/// the following content.\n///\n/// If not a wheel tag formatted version, the input is returned unchanged.\nfn split_wheel_tag_version(mut version: Vec<u64>) -> Vec<u64> {\n    if version.len() != 1 {\n        return version;\n    }\n\n    let release = version[0].to_string();\n    let mut chars = release.chars();\n    let Some(major) = chars.next().and_then(|c| c.to_digit(10)) else {\n        return version;\n    };\n\n    let Ok(minor) = chars.as_str().parse::<u32>() else {\n        return version;\n    };\n\n    version[0] = u64::from(major);\n    version.push(u64::from(minor));\n    version\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::Language;\n    use rustc_hash::FxHashSet;\n\n    #[test]\n    fn test_parse_python_request() {\n        // Empty request\n        assert_eq!(PythonRequest::from_str(\"\").unwrap(), PythonRequest::Any);\n        assert_eq!(\n            PythonRequest::from_str(\"python\").unwrap(),\n            PythonRequest::Any\n        );\n\n        assert_eq!(\n            PythonRequest::from_str(\"python3\").unwrap(),\n            PythonRequest::Major(3)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"python3.12\").unwrap(),\n            PythonRequest::MajorMinor(3, 12)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"python3.13.2\").unwrap(),\n            PythonRequest::MajorMinorPatch(3, 13, 2)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"3\").unwrap(),\n            PythonRequest::Major(3)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"3.12\").unwrap(),\n            PythonRequest::MajorMinor(3, 12)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"3.12.3\").unwrap(),\n            PythonRequest::MajorMinorPatch(3, 12, 3)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"312\").unwrap(),\n            PythonRequest::MajorMinor(3, 12)\n        );\n        assert_eq!(\n            PythonRequest::from_str(\"python312\").unwrap(),\n            PythonRequest::MajorMinor(3, 12)\n        );\n\n        // VersionReq\n        assert_eq!(\n            PythonRequest::from_str(\">=3.12\").unwrap(),\n            PythonRequest::Range(\n                semver::VersionReq::parse(\">=3.12\").unwrap(),\n                \">=3.12\".to_string()\n            )\n        );\n        assert_eq!(\n            PythonRequest::from_str(\">=3.8, <3.12\").unwrap(),\n            PythonRequest::Range(\n                semver::VersionReq::parse(\">=3.8, <3.12\").unwrap(),\n                \">=3.8, <3.12\".to_string()\n            )\n        );\n\n        // Invalid versions\n        assert!(PythonRequest::from_str(\"invalid\").is_err());\n        assert!(PythonRequest::from_str(\"3.12.3.4\").is_err());\n        assert!(PythonRequest::from_str(\"3.12.a\").is_err());\n        assert!(PythonRequest::from_str(\"3.b.1\").is_err());\n        assert!(PythonRequest::from_str(\"3..2\").is_err());\n        assert!(PythonRequest::from_str(\"a3.12\").is_err());\n\n        // TODO: support\n        assert!(PythonRequest::from_str(\"3.12.3a1\").is_err());\n        assert!(PythonRequest::from_str(\"3.12.3rc1\").is_err());\n        assert!(PythonRequest::from_str(\"python3.13.2a1\").is_err());\n        assert!(PythonRequest::from_str(\"python3.13.2rc1\").is_err());\n        assert!(PythonRequest::from_str(\"python3.13.2t1\").is_err());\n        assert!(PythonRequest::from_str(\"python3.13.2-64\").is_err());\n        assert!(PythonRequest::from_str(\"python3.13.2-64\").is_err());\n    }\n\n    #[test]\n    fn test_satisfied_by() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Python, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(3, 12, 1))\n            .with_toolchain(PathBuf::from(\"/usr/bin/python3.12\"));\n\n        assert!(PythonRequest::Any.satisfied_by(&install_info));\n        assert!(PythonRequest::Major(3).satisfied_by(&install_info));\n        assert!(PythonRequest::MajorMinor(3, 12).satisfied_by(&install_info));\n        assert!(PythonRequest::MajorMinorPatch(3, 12, 1).satisfied_by(&install_info));\n        assert!(!PythonRequest::MajorMinorPatch(3, 12, 2).satisfied_by(&install_info));\n        assert!(\n            PythonRequest::Path(PathBuf::from(\"/usr/bin/python3.12\")).satisfied_by(&install_info)\n        );\n        assert!(\n            !PythonRequest::Path(PathBuf::from(\"/usr/bin/python3.11\")).satisfied_by(&install_info)\n        );\n\n        let range_req = semver::VersionReq::parse(\">=3.12\").unwrap();\n        assert!(\n            PythonRequest::Range(range_req.clone(), \">=3.12\".to_string())\n                .satisfied_by(&install_info)\n        );\n\n        let range_req = semver::VersionReq::parse(\">=4.0\").unwrap();\n        assert!(!PythonRequest::Range(range_req, \">=4.0\".to_string()).satisfied_by(&install_info));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/ruby/gem.rs",
    "content": "use std::ffi::OsStr;\nuse std::path::{Path, PathBuf};\nuse std::time::Duration;\n\nuse anyhow::{Context, Result};\nuse futures::{StreamExt, TryStreamExt};\nuse prek_consts::env_vars::EnvVars;\nuse rand::RngExt;\nuse rustc_hash::{FxHashMap, FxHashSet};\nuse tracing::debug;\n\nuse crate::languages::ruby::installer::RubyResult;\nuse crate::process::Cmd;\nuse crate::run::CONCURRENCY;\n\n/// Find all .gemspec files in a directory\nfn find_gemspecs(dir: &Path) -> Result<Vec<PathBuf>> {\n    let mut gemspecs = Vec::new();\n\n    for entry in fs_err::read_dir(dir)? {\n        let entry = entry?;\n        let path = entry.path();\n\n        if path.extension() == Some(OsStr::new(\"gemspec\")) {\n            gemspecs.push(path);\n        }\n    }\n\n    if gemspecs.is_empty() {\n        anyhow::bail!(\"No .gemspec files found in {}\", dir.display());\n    }\n\n    Ok(gemspecs)\n}\n\n/// Build a gemspec into a .gem file\nasync fn build_gemspec(ruby: &RubyResult, gemspec_path: &Path) -> Result<PathBuf> {\n    let repo_dir = gemspec_path\n        .parent()\n        .context(\"Gemspec has no parent directory\")?;\n\n    debug!(\"Building gemspec: {}\", gemspec_path.display());\n\n    // Use `ruby -S gem` instead of calling gem directly to work around Windows\n    // issue where gem.cmd/.bat can't be executed directly (os error 193)\n    let output = Cmd::new(ruby.ruby_bin(), \"gem build\")\n        .arg(\"-S\")\n        .arg(\"gem\")\n        .arg(\"build\")\n        .arg(gemspec_path.file_name().unwrap())\n        .current_dir(repo_dir)\n        .check(true)\n        .output()\n        .await?;\n\n    // Parse output to find generated .gem file\n    let output_str = String::from_utf8_lossy(&output.stdout);\n    let gem_file = output_str\n        .lines()\n        .find(|line| line.contains(\"File:\"))\n        .and_then(|line| line.split_whitespace().last())\n        .context(\"Could not find generated .gem file in output\")?;\n\n    let gem_path = repo_dir.join(gem_file);\n\n    if !gem_path.exists() {\n        anyhow::bail!(\"Generated gem file not found: {}\", gem_path.display());\n    }\n\n    Ok(gem_path)\n}\n\n/// Build all gemspecs in a repository, returning the list of gems built\npub(crate) async fn build_gemspecs(ruby: &RubyResult, repo_dir: &Path) -> Result<Vec<PathBuf>> {\n    let gemspecs = find_gemspecs(repo_dir)?;\n\n    let mut gem_files = Vec::new();\n    for gemspec in gemspecs {\n        let gem_file = build_gemspec(ruby, &gemspec).await?;\n        gem_files.push(gem_file);\n    }\n\n    Ok(gem_files)\n}\n\n/// Set common gem environment variables for isolation.\nfn gem_env<'a>(cmd: &'a mut Cmd, gem_home: &Path) -> &'a mut Cmd {\n    cmd.env(EnvVars::GEM_HOME, gem_home)\n        .env(EnvVars::BUNDLE_IGNORE_CONFIG, \"1\")\n        .env_remove(EnvVars::GEM_PATH)\n        .env_remove(EnvVars::BUNDLE_GEMFILE);\n\n    // Parallelize native extension compilation (e.g. prism's C code).\n    // Respect existing MAKEFLAGS if set (user may need to limit parallelism\n    // in memory-constrained environments like Docker).\n    if EnvVars::var_os(\"MAKEFLAGS\").is_none() {\n        cmd.env(\"MAKEFLAGS\", format!(\"-j{}\", *CONCURRENCY));\n    }\n\n    cmd\n}\n\n/// A gem resolved by `gem install --explain`.\n#[derive(Debug, PartialEq)]\nstruct ResolvedGem {\n    name: String,\n    version: String,\n    /// Platform suffix for pre-built binary gems (e.g. `x86_64-linux`, `java`).\n    platform: Option<String>,\n}\n\nimpl ResolvedGem {\n    /// The `name-version[-platform]` key, matching `.gem` file stems.\n    fn key(&self) -> String {\n        match &self.platform {\n            Some(p) => format!(\"{}-{}-{}\", self.name, self.version, p),\n            None => format!(\"{}-{}\", self.name, self.version),\n        }\n    }\n}\n\n/// Parse `gem install --explain` output into resolved gems.\n///\n/// Splits at the rightmost `-` where the suffix starts with a digit to find\n/// the version boundary, handling gem names with hyphens (e.g.\n/// `ruby-progressbar-1.13.0`) and platform-specific gems (e.g.\n/// `prism-1.9.0-x86_64-linux`).\nfn parse_explain_output(output: &str) -> Vec<ResolvedGem> {\n    output\n        .lines()\n        .filter_map(|line| {\n            let trimmed = line.trim();\n            // Find rightmost '-' where the suffix starts with a digit (version boundary)\n            let version_start = trimmed.rmatch_indices('-').find_map(|(i, _)| {\n                trimmed\n                    .as_bytes()\n                    .get(i + 1)\n                    .filter(|b| b.is_ascii_digit())\n                    .map(|_| i)\n            })?;\n            let name = &trimmed[..version_start];\n            if name.is_empty() {\n                return None;\n            }\n            let rest = &trimmed[version_start + 1..];\n\n            // Split version from platform: gem versions use dots (not hyphens),\n            // so the first hyphen-delimited segment starting with a non-digit\n            // begins the platform suffix (e.g. \"1.9.0-x86_64-linux\").\n            let (version, platform) = match rest.find('-') {\n                Some(i)\n                    if rest\n                        .as_bytes()\n                        .get(i + 1)\n                        .is_some_and(|b| !b.is_ascii_digit()) =>\n                {\n                    (&rest[..i], Some(&rest[i + 1..]))\n                }\n                _ => (rest, None),\n            };\n\n            Some(ResolvedGem {\n                name: name.to_string(),\n                version: version.to_string(),\n                platform: platform.map(String::from),\n            })\n        })\n        .collect()\n}\n\n/// Resolve the full dependency list via `gem install --explain`.\nasync fn resolve_gems(\n    ruby: &RubyResult,\n    gem_home: &Path,\n    gem_files: &[PathBuf],\n    additional_dependencies: &FxHashSet<String>,\n) -> Result<Vec<ResolvedGem>> {\n    let mut cmd = Cmd::new(ruby.ruby_bin(), \"gem install --explain\");\n    cmd.arg(\"-S\")\n        .arg(\"gem\")\n        .arg(\"install\")\n        .arg(\"--explain\")\n        .arg(\"--no-document\")\n        .arg(\"--no-format-executable\")\n        .arg(\"--no-user-install\")\n        .arg(\"--install-dir\")\n        .arg(gem_home)\n        .arg(\"--bindir\")\n        .arg(gem_home.join(\"bin\"))\n        .args(gem_files)\n        .args(additional_dependencies);\n    gem_env(&mut cmd, gem_home);\n\n    let output = cmd.check(true).output().await?;\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    Ok(parse_explain_output(&stdout))\n}\n\n/// Install a single gem with `--ignore-dependencies`.\nasync fn install_single_gem(\n    ruby: &RubyResult,\n    gem_home: &Path,\n    gem: &ResolvedGem,\n    local_path: Option<&Path>,\n) -> Result<()> {\n    let mut cmd = Cmd::new(ruby.ruby_bin(), format!(\"gem install {}\", gem.name));\n    cmd.arg(\"-S\")\n        .arg(\"gem\")\n        .arg(\"install\")\n        .arg(\"--ignore-dependencies\")\n        .arg(\"--no-document\")\n        .arg(\"--no-format-executable\")\n        .arg(\"--no-user-install\")\n        .arg(\"--install-dir\")\n        .arg(gem_home)\n        .arg(\"--bindir\")\n        .arg(gem_home.join(\"bin\"));\n\n    if let Some(path) = local_path {\n        cmd.arg(path);\n    } else {\n        cmd.arg(&gem.name).arg(\"-v\").arg(&gem.version);\n        // Request the specific platform variant when a pre-built binary gem was resolved\n        if let Some(platform) = &gem.platform {\n            cmd.arg(\"--platform\").arg(platform);\n        }\n    }\n\n    gem_env(&mut cmd, gem_home);\n    cmd.check(true).output().await?;\n    Ok(())\n}\n\n/// Fallback: install all gems in a single sequential `gem install` command.\nasync fn install_gems_sequential(\n    ruby: &RubyResult,\n    gem_home: &Path,\n    gem_files: &[PathBuf],\n    additional_dependencies: &FxHashSet<String>,\n) -> Result<()> {\n    let mut cmd = Cmd::new(ruby.ruby_bin(), \"gem install\");\n    cmd.arg(\"-S\")\n        .arg(\"gem\")\n        .arg(\"install\")\n        .arg(\"--no-document\")\n        .arg(\"--no-format-executable\")\n        .arg(\"--no-user-install\")\n        .arg(\"--install-dir\")\n        .arg(gem_home)\n        .arg(\"--bindir\")\n        .arg(gem_home.join(\"bin\"))\n        .args(gem_files)\n        .args(additional_dependencies);\n    gem_env(&mut cmd, gem_home);\n\n    debug!(\"Installing gems sequentially to {}\", gem_home.display());\n    cmd.check(true).output().await?;\n    Ok(())\n}\n\n/// Install gems to an isolated `GEM_HOME`.\n///\n/// Resolves the full dependency graph via `gem install --explain`, then installs\n/// each gem in parallel with `--ignore-dependencies`. Falls back to a single\n/// sequential `gem install` if resolution fails.\npub(crate) async fn install_gems(\n    ruby: &RubyResult,\n    gem_home: &Path,\n    repo_path: Option<&Path>,\n    additional_dependencies: &FxHashSet<String>,\n) -> Result<()> {\n    let mut gem_files = Vec::new();\n\n    // Collect gems from repository. Many of these were probably built from gemspecs earlier,\n    // but install all .gem files found (matches pre-commit behavior)\n    if let Some(repo) = repo_path {\n        for entry in fs_err::read_dir(repo)? {\n            let entry = entry?;\n            let path = entry.path();\n\n            if path.extension() == Some(OsStr::new(\"gem\")) {\n                gem_files.push(path);\n            }\n        }\n    }\n\n    // If there are no gems and no additional dependencies, skip installation\n    if gem_files.is_empty() && additional_dependencies.is_empty() {\n        debug!(\"No gems to install, skipping gem install\");\n        return Ok(());\n    }\n\n    // Map \"name-version\" → local .gem path, so parallel installs can use local files\n    let local_gem_map: FxHashMap<&str, &Path> = gem_files\n        .iter()\n        .filter_map(|path| {\n            let stem = path.file_stem()?.to_str()?;\n            Some((stem, path.as_path()))\n        })\n        .collect();\n\n    match resolve_gems(ruby, gem_home, &gem_files, additional_dependencies).await {\n        Ok(gems) if !gems.is_empty() => {\n            debug!(\"Installing {} gems in parallel\", gems.len());\n\n            let result = futures::stream::iter(gems)\n                .map(|gem| {\n                    let key = gem.key();\n                    let local_path = local_gem_map.get(key.as_str()).copied();\n                    async move {\n                        match install_single_gem(ruby, gem_home, &gem, local_path).await {\n                            Ok(()) => Ok(()),\n                            Err(first_err) => {\n                                // Parallel `gem install` processes can race when reading\n                                // each other's partially-written gemspec files, causing\n                                // transient failures (especially on Windows/NTFS). Retry\n                                // once after a random delay to let the other process finish.\n                                let delay = rand::rng().random_range(50..=500);\n                                debug!(\n                                    \"gem install {} failed, retrying in {delay}ms: {first_err:#}\",\n                                    gem.name\n                                );\n                                tokio::time::sleep(Duration::from_millis(delay)).await;\n                                install_single_gem(ruby, gem_home, &gem, local_path)\n                                    .await\n                                    .with_context(|| {\n                                        format!(\"retry also failed (first error: {first_err:#})\")\n                                    })\n                            }\n                        }\n                    }\n                })\n                .buffer_unordered(*CONCURRENCY)\n                .try_collect::<Vec<()>>()\n                .await;\n\n            match result {\n                Ok(_) => Ok(()),\n                Err(err) => {\n                    // Parallel installs may have partially succeeded (installed\n                    // gems remain in GEM_HOME). Fall back to sequential install\n                    // which will skip already-installed gems and retry the rest.\n                    debug!(\n                        \"Parallel gem install failed after retry ({err:#}), \\\n                         falling back to sequential install\"\n                    );\n                    install_gems_sequential(ruby, gem_home, &gem_files, additional_dependencies)\n                        .await\n                }\n            }\n        }\n        Ok(_) => {\n            debug!(\"gem install --explain returned no gems, falling back to sequential install\");\n            install_gems_sequential(ruby, gem_home, &gem_files, additional_dependencies).await\n        }\n        Err(err) => {\n            debug!(\"gem install --explain failed ({err:#}), falling back to sequential install\");\n            install_gems_sequential(ruby, gem_home, &gem_files, additional_dependencies).await\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn gem(name: &str, version: &str, platform: Option<&str>) -> ResolvedGem {\n        ResolvedGem {\n            name: name.into(),\n            version: version.into(),\n            platform: platform.map(Into::into),\n        }\n    }\n\n    #[test]\n    fn test_parse_explain_output() {\n        let output = \"\\\nGems to install:\n  unicode-emoji-4.1.0\n  ruby-progressbar-1.13.0\n  rubocop-ast-1.44.1\n  rubocop-1.82.0\n\";\n        let gems = parse_explain_output(output);\n        assert_eq!(\n            gems,\n            vec![\n                gem(\"unicode-emoji\", \"4.1.0\", None),\n                gem(\"ruby-progressbar\", \"1.13.0\", None),\n                gem(\"rubocop-ast\", \"1.44.1\", None),\n                gem(\"rubocop\", \"1.82.0\", None),\n            ]\n        );\n    }\n\n    #[test]\n    fn test_parse_explain_output_empty() {\n        assert!(parse_explain_output(\"\").is_empty());\n        assert!(parse_explain_output(\"Gems to install:\\n\").is_empty());\n    }\n\n    #[test]\n    fn test_parse_explain_output_platform_gems() {\n        let output = \"  prism-1.9.0-x86_64-linux\\n  json-2.18.1-java\\n\";\n        let gems = parse_explain_output(output);\n        assert_eq!(\n            gems,\n            vec![\n                gem(\"prism\", \"1.9.0\", Some(\"x86_64-linux\")),\n                gem(\"json\", \"2.18.1\", Some(\"java\")),\n            ]\n        );\n    }\n\n    #[test]\n    fn test_parse_explain_output_edge_cases() {\n        // No version separator\n        assert!(parse_explain_output(\"  rubocop\").is_empty());\n        // Empty name (leading dash)\n        assert!(parse_explain_output(\"  -1.0.0\").is_empty());\n        // Pre-release version with dot separator (RubyGems convention)\n        let gems = parse_explain_output(\"  foo-bar-0.1.0.beta\");\n        assert_eq!(gems, vec![gem(\"foo-bar\", \"0.1.0.beta\", None)]);\n    }\n\n    #[test]\n    fn test_resolved_gem_key() {\n        assert_eq!(gem(\"rubocop\", \"1.82.0\", None).key(), \"rubocop-1.82.0\");\n        assert_eq!(\n            gem(\"prism\", \"1.9.0\", Some(\"x86_64-linux\")).key(),\n            \"prism-1.9.0-x86_64-linux\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/ruby/installer.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::env_vars::EnvVars;\nuse serde::Deserialize;\nuse target_lexicon::{Architecture, Environment, HOST, OperatingSystem, Triple};\nuse tracing::{debug, trace, warn};\n\nuse crate::fs::LockedFile;\nuse crate::http::{REQWEST_CLIENT, download_and_extract_with};\nuse crate::languages::ruby::RubyRequest;\nuse crate::process::Cmd;\nuse crate::store::Store;\n\nconst RV_RUBY_DEFAULT_URL: &str = \"https://github.com/spinel-coop/rv-ruby\";\n\n/// Resolve the rv-ruby mirror base URL and whether it targets github.com.\nfn rv_ruby_mirror() -> (String, bool) {\n    match EnvVars::var(EnvVars::PREK_RUBY_MIRROR) {\n        Ok(mirror) => {\n            let is_github = is_github_https(&mirror);\n            (mirror, is_github)\n        }\n        Err(_) => (RV_RUBY_DEFAULT_URL.to_string(), true),\n    }\n}\n\n/// Returns a URL compatible with the GitHub Releases API for listing rv-ruby\n/// versions, and whether the target host is github.com (for auth token\n/// decisions).\n///\n/// When the mirror is a `github.com` URL, the path is rewritten to use the\n/// `api.github.com` host (e.g. `https://github.com/org/repo` becomes\n/// `https://api.github.com/repos/org/repo/releases/latest`).\nfn rv_ruby_api_url() -> (String, bool) {\n    let (base, is_github) = rv_ruby_mirror();\n    let url = if is_github {\n        // Rewrite github.com web URL to API URL.\n        let path = base\n            .strip_prefix(\"https://github.com\")\n            .expect(\"is_github_https should ensure this\");\n        format!(\"https://api.github.com/repos{path}/releases/latest\")\n    } else {\n        format!(\"{base}/releases/latest\")\n    };\n    (url, is_github)\n}\n\n/// Check whether a URL is an HTTPS URL pointing to github.com.\n/// Only matches the exact host `github.com` over HTTPS, so won't send\n/// tokens to other hosts, subdomains, path-injection attempts,\n/// userinfo-based redirects, or plaintext HTTP.\nfn is_github_https(url: &str) -> bool {\n    (url.starts_with(\"https://github.com/\") || url.starts_with(\"https://github.com:\"))\n        && !url.contains('@')\n}\n\n/// Returns the base URL for downloading rv-ruby release assets, and whether\n/// the target host is github.com (for auth token decisions).\nfn rv_ruby_download_base() -> (String, bool) {\n    let (base, is_github) = rv_ruby_mirror();\n    (format!(\"{base}/releases/latest/download\"), is_github)\n}\n\n/// Conditionally add a GitHub auth token to a request builder.\n/// Only sends `GITHUB_TOKEN` when `is_github` is true.\nfn maybe_add_github_auth(req: reqwest::RequestBuilder, is_github: bool) -> reqwest::RequestBuilder {\n    if is_github {\n        if let Ok(token) = EnvVars::var(EnvVars::GITHUB_TOKEN) {\n            return req.header(http::header::AUTHORIZATION, format!(\"Bearer {token}\"));\n        }\n    }\n    req\n}\n\n#[derive(Deserialize)]\nstruct GitHubRelease {\n    assets: Vec<GitHubAsset>,\n}\n\n#[derive(Deserialize)]\nstruct GitHubAsset {\n    name: String,\n}\n\n/// Returns the rv-ruby release asset platform suffix for the current target.\n///\n/// These strings must match the asset filenames published by rv-ruby\n/// (e.g. `ruby-3.4.8.arm64_linux_musl.tar.gz`). The canonical source is\n/// `HostPlatform::ruby_arch_str()` in rv's `rv-platform` crate:\n/// <https://github.com/spinel-coop/rv/blob/main/crates/rv-platform/src/lib.rs>\n///\n/// The macOS names (`ventura`, `arm64_sonoma`) are Homebrew bottle tags currently\n/// pinned by rv-ruby's packaging script. rv currently build using macOS 15 on Intel\n/// which would suggest a 'sequoia' tag, but their packaging script currently renames the\n/// output to 'ventura'. If this ever changes, this mapping will need to be updated\n/// accordingly.\nfn rv_platform_string(triple: &Triple) -> Option<&'static str> {\n    match (\n        triple.operating_system,\n        triple.architecture,\n        triple.environment,\n    ) {\n        // macOS\n        (OperatingSystem::Darwin(_), Architecture::X86_64, _) => Some(\"ventura\"),\n        (OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => Some(\"arm64_sonoma\"),\n\n        // Linux glibc\n        (OperatingSystem::Linux, Architecture::X86_64, Environment::Gnu) => Some(\"x86_64_linux\"),\n        (OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Gnu) => Some(\"arm64_linux\"),\n\n        // Linux musl (Alpine)\n        (OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => {\n            Some(\"x86_64_linux_musl\")\n        }\n        (OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Musl) => {\n            Some(\"arm64_linux_musl\")\n        }\n\n        // unsupported OS/CPU/libc combination\n        _ => None,\n    }\n}\n\n/// Result of finding/installing a Ruby interpreter\n#[derive(Debug)]\npub(crate) struct RubyResult {\n    /// Path to ruby executable\n    ruby_bin: PathBuf,\n\n    /// Ruby version\n    version: semver::Version,\n\n    /// Ruby engine (ruby, jruby, truffleruby)\n    engine: String,\n}\n\nimpl RubyResult {\n    pub(crate) fn ruby_bin(&self) -> &Path {\n        &self.ruby_bin\n    }\n\n    pub(crate) fn version(&self) -> &semver::Version {\n        &self.version\n    }\n\n    pub(crate) fn engine(&self) -> &str {\n        &self.engine\n    }\n}\n\n/// Ruby installer that finds or installs Ruby interpreters\npub(crate) struct RubyInstaller {\n    root: PathBuf,\n}\n\nimpl RubyInstaller {\n    pub(crate) fn new(root: PathBuf) -> Self {\n        Self { root }\n    }\n\n    /// Main installation entry point\n    pub(crate) async fn install(\n        &self,\n        store: &Store,\n        request: &RubyRequest,\n        allows_download: bool,\n    ) -> Result<RubyResult> {\n        fs_err::tokio::create_dir_all(&self.root).await?;\n        let _lock = LockedFile::acquire(self.root.join(\".lock\"), \"ruby\").await?;\n\n        // 1. Check previously downloaded rubies\n        if let Some(ruby) = self.find_installed(request) {\n            trace!(\n                \"Using managed Ruby: {} at {}\",\n                ruby.version(),\n                ruby.ruby_bin().display()\n            );\n            return Ok(ruby);\n        }\n\n        // 2. Check system Ruby (PATH + version managers)\n        if let Some(ruby) = self.find_system_ruby(request).await? {\n            trace!(\n                \"Using system Ruby: {} at {}\",\n                ruby.version(),\n                ruby.ruby_bin().display()\n            );\n            return Ok(ruby);\n        }\n\n        // 3. Download if allowed and platform is supported\n        if !allows_download {\n            anyhow::bail!(ruby_not_found_error(\n                request,\n                // allows_download can only be false if the original request was\n                // for any version of ruby, but system-only.\n                \"Automatic installation is disabled (language_version: system).\"\n            ));\n        }\n\n        let Some(platform) = rv_platform_string(&HOST) else {\n            anyhow::bail!(ruby_not_found_error(\n                request,\n                // Windows, unknown CPU, etc. that doesn't have a matching rv-ruby\n                // release asset (that we know about).\n                \"Automatic installation is not supported on this platform.\"\n            ));\n        };\n\n        let versions = match self.list_remote_versions(platform).await {\n            Ok(v) => v,\n            Err(e) => {\n                anyhow::bail!(\n                    \"{}\\n\\nCaused by:\\n  {e}\",\n                    ruby_not_found_error(\n                        request,\n                        \"Failed to fetch available Ruby versions from rv-ruby.\"\n                    )\n                );\n            }\n        };\n\n        let Some(version) = versions.into_iter().find(|v| request.matches(v, None)) else {\n            anyhow::bail!(ruby_not_found_error(\n                request,\n                &format!(\"No rv-ruby release found matching: {request}\")\n            ));\n        };\n        self.download(store, &version, platform).await\n    }\n\n    /// Scan `self.root` for previously downloaded Ruby versions.\n    fn find_installed(&self, request: &RubyRequest) -> Option<RubyResult> {\n        fs_err::read_dir(&self.root)\n            .ok()?\n            .flatten()\n            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))\n            .filter_map(|entry| {\n                let version = semver::Version::parse(&entry.file_name().to_string_lossy()).ok()?;\n                let bin_dir = entry.path().join(\"bin\");\n                let ruby_bin = bin_dir.join(\"ruby\");\n                let gem_bin = bin_dir.join(\"gem\");\n                if ruby_bin.exists() && gem_bin.exists() {\n                    Some((version, ruby_bin))\n                } else {\n                    None\n                }\n            })\n            .sorted_unstable_by(|(a, _), (b, _)| b.cmp(a)) // descending\n            .find_map(|(version, ruby_bin)| {\n                if request.matches(&version, Some(&ruby_bin)) {\n                    Some(RubyResult {\n                        ruby_bin,\n                        version,\n                        engine: \"ruby\".to_string(),\n                    })\n                } else {\n                    None\n                }\n            })\n    }\n\n    /// Fetch available Ruby versions from the rv-ruby GitHub release.\n    async fn list_remote_versions(&self, platform: &str) -> Result<Vec<semver::Version>> {\n        let (api_url, is_github) = rv_ruby_api_url();\n        let suffix = format!(\".{platform}.tar.gz\");\n\n        let req = REQWEST_CLIENT\n            .get(&api_url)\n            .header(\"Accept\", \"application/vnd.github+json\");\n        let req = maybe_add_github_auth(req, is_github);\n\n        let response = req\n            .send()\n            .await\n            .with_context(|| format!(\"Failed to fetch rv-ruby releases from {api_url}\"))?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let hint = if matches!(status.as_u16(), 403 | 429) {\n                \" (this may be a rate limit — try setting GITHUB_TOKEN)\"\n            } else {\n                \"\"\n            };\n            anyhow::bail!(\"Failed to fetch rv-ruby releases from {api_url}: {status}{hint}\");\n        }\n\n        let release: GitHubRelease = response\n            .json()\n            .await\n            .context(\"Failed to parse rv-ruby release JSON\")?;\n\n        let versions = release\n            .assets\n            .iter()\n            .filter_map(|asset| parse_version_from_asset(&asset.name, &suffix))\n            .sorted_unstable()\n            .rev()\n            .collect();\n\n        Ok(versions)\n    }\n\n    /// Download and extract a specific Ruby version from rv-ruby.\n    ///\n    /// Uses `download_and_extract_with` to inject a `GITHUB_TOKEN` auth header\n    /// for GitHub-hosted mirrors (including private partial mirrors of rv-ruby).\n    async fn download(\n        &self,\n        store: &Store,\n        version: &semver::Version,\n        platform: &str,\n    ) -> Result<RubyResult> {\n        let filename = format!(\"ruby-{version}.{platform}.tar.gz\");\n        let (base_url, is_github) = rv_ruby_download_base();\n        let url = format!(\"{base_url}/{filename}\");\n        let version_str = version.to_string();\n        let target = self.root.join(&version_str);\n\n        debug!(url = %url, target = %target.display(), \"Downloading Ruby {version}\");\n\n        download_and_extract_with(\n            &url,\n            &filename,\n            store,\n            |req| maybe_add_github_auth(req, is_github),\n            async |extracted| {\n                // rv-ruby tarballs contain: rv-ruby@{version}/{version}/bin/ruby\n                // After strip_component, `extracted` is the rv-ruby@{version}/ directory.\n                // Move the inner {version}/ directory to our target.\n                let inner = extracted.join(&version_str);\n                if !inner.exists() {\n                    anyhow::bail!(\n                        \"Expected directory '{}' inside rv-ruby archive, found: {:?}\",\n                        version_str,\n                        fs_err::read_dir(extracted)?\n                            .flatten()\n                            .map(|e| e.file_name())\n                            .collect::<Vec<_>>()\n                    );\n                }\n\n                if target.exists() {\n                    debug!(target = %target.display(), \"Removing existing Ruby\");\n                    fs_err::tokio::remove_dir_all(&target).await?;\n                }\n\n                fs_err::tokio::rename(&inner, &target).await?;\n                Ok(())\n            },\n        )\n        .await\n        .with_context(|| format!(\"Failed to download Ruby {version} from {url}\"))?;\n\n        Ok(RubyResult {\n            ruby_bin: target.join(\"bin\").join(\"ruby\"),\n            version: version.clone(),\n            engine: \"ruby\".to_string(),\n        })\n    }\n\n    /// Find Ruby in the system PATH\n    async fn find_system_ruby(&self, request: &RubyRequest) -> Result<Option<RubyResult>> {\n        // Try all rubies in PATH first\n        if let Ok(ruby_paths) = which::which_all(\"ruby\") {\n            for ruby_path in ruby_paths {\n                if let Some(result) = try_ruby_path(&ruby_path, request).await {\n                    return Ok(Some(result));\n                }\n            }\n        }\n\n        // If we didn't find a suitable Ruby in PATH, search version manager directories\n        #[cfg(not(target_os = \"windows\"))]\n        if let Some(result) = search_version_managers(request).await {\n            return Ok(Some(result));\n        }\n\n        Ok(None)\n    }\n}\n\n/// Try to use a Ruby at the given path\nasync fn try_ruby_path(ruby_path: &Path, request: &RubyRequest) -> Option<RubyResult> {\n    // Check for gem in same directory\n    if let Err(e) = find_gem_for_ruby(ruby_path) {\n        warn!(\"Ruby at {} has no gem: {}\", ruby_path.display(), e);\n        return None;\n    }\n\n    // Query version and engine\n    match query_ruby_info(ruby_path).await {\n        Ok((version, engine)) => {\n            let result = RubyResult {\n                ruby_bin: ruby_path.to_path_buf(),\n                version,\n                engine,\n            };\n\n            if request.matches(&result.version, Some(&result.ruby_bin)) {\n                Some(result)\n            } else {\n                None\n            }\n        }\n        Err(e) => {\n            warn!(\"Failed to query Ruby at {}: {}\", ruby_path.display(), e);\n            None\n        }\n    }\n}\n\n/// Search version manager directories for suitable Ruby installations\n#[cfg(not(target_os = \"windows\"))]\nasync fn search_version_managers(request: &RubyRequest) -> Option<RubyResult> {\n    let home = EnvVars::var(EnvVars::HOME).ok()?;\n    let home_path = PathBuf::from(home);\n\n    // Common version manager and Homebrew directories\n    let search_dirs = [\n        // rvm: ~/.rvm/rubies/ruby-3.4.6/bin/ruby\n        home_path.join(\".rvm/rubies\"),\n        // rv: ~/.local/share/rv/rubies/3.4.6/bin/ruby\n        home_path.join(\".local/share/rv/rubies\"),\n        // rv legacy path: ~/.data/rv/rubies/3.4.6/bin/ruby\n        home_path.join(\".data/rv/rubies\"),\n        // mise: ~/.local/share/mise/installs/ruby/3.4.6/bin/ruby\n        home_path.join(\".local/share/mise/installs/ruby\"),\n        // rbenv: ~/.rbenv/versions/3.4.6/bin/ruby\n        home_path.join(\".rbenv/versions\"),\n        // asdf: ~/.asdf/installs/ruby/3.4.6/bin/ruby\n        home_path.join(\".asdf/installs/ruby\"),\n        // chruby: ~/.rubies/ruby-3.4.6/bin/ruby\n        home_path.join(\".rubies\"),\n        // chruby system-wide: /opt/rubies/ruby-3.4.6/bin/ruby\n        PathBuf::from(\"/opt/rubies\"),\n        // Homebrew (Apple Silicon): /opt/homebrew/Cellar/ruby/3.4.6/bin/ruby\n        PathBuf::from(\"/opt/homebrew/Cellar/ruby\"),\n        // Homebrew (Intel): /usr/local/Cellar/ruby/3.4.6/bin/ruby\n        PathBuf::from(\"/usr/local/Cellar/ruby\"),\n        // Linuxbrew: /home/linuxbrew/.linuxbrew/Cellar/ruby/3.4.6/bin/ruby\n        PathBuf::from(\"/home/linuxbrew/.linuxbrew/Cellar/ruby\"),\n        // Linuxbrew (user): ~/.linuxbrew/Cellar/ruby/3.4.6/bin/ruby\n        home_path.join(\".linuxbrew/Cellar/ruby\"),\n    ];\n\n    for search_dir in &search_dirs {\n        if let Some(result) = search_ruby_installations(search_dir, request).await {\n            return Some(result);\n        }\n    }\n\n    None\n}\n\n/// Search a version manager directory for Ruby installations\n#[cfg(not(target_os = \"windows\"))]\nasync fn search_ruby_installations(dir: &Path, request: &RubyRequest) -> Option<RubyResult> {\n    let entries = std::fs::read_dir(dir).ok()?;\n\n    for entry in entries.flatten() {\n        let path = entry.path();\n        if !path.is_dir() {\n            continue;\n        }\n\n        let ruby_path = path.join(\"bin/ruby\");\n        if ruby_path.exists() {\n            if let Some(result) = try_ruby_path(&ruby_path, request).await {\n                trace!(\n                    \"Found suitable Ruby in version manager: {}\",\n                    ruby_path.display()\n                );\n                return Some(result);\n            }\n        }\n    }\n\n    None\n}\n\n/// Extract a Ruby version from an rv-ruby release asset name.\n///\n/// Given suffix `.x86_64_linux.tar.gz` and asset `ruby-3.4.8.x86_64_linux.tar.gz`,\n/// returns `Some(Version(3.4.8))`. Returns `None` for non-matching platforms,\n/// non-semver versions (e.g. `0.49`), and pre-release versions.\nfn parse_version_from_asset(name: &str, platform_suffix: &str) -> Option<semver::Version> {\n    let name = name.strip_prefix(\"ruby-\")?;\n    let version_str = name.strip_suffix(platform_suffix)?;\n    let version = semver::Version::parse(version_str).ok()?;\n    // Skip pre-release versions (e.g. 3.5.0-preview1) unless explicitly requested\n    if !version.pre.is_empty() {\n        return None;\n    }\n    Some(version)\n}\n\n/// Generate a consistent error message for all \"can't get Ruby\" scenarios.\nfn ruby_not_found_error(request: &RubyRequest, reason: &str) -> String {\n    format!(\n        \"No suitable Ruby found for request: {request}\\n{reason}\\nPlease install Ruby manually.\"\n    )\n}\n\n/// Find gem executable alongside Ruby\nfn find_gem_for_ruby(ruby_path: &Path) -> Result<PathBuf> {\n    let ruby_dir = ruby_path\n        .parent()\n        .context(\"Ruby executable has no parent directory\")?;\n\n    // Try various gem executable names (for Windows compatibility)\n    for name in [\"gem\", \"gem.bat\", \"gem.cmd\"] {\n        let gem_path = ruby_dir.join(name).with_extension(EXE_EXTENSION);\n        if gem_path.exists() {\n            return Ok(gem_path);\n        }\n\n        // Also try without explicit extension\n        let gem_path = ruby_dir.join(name);\n        if gem_path.exists() {\n            return Ok(gem_path);\n        }\n    }\n\n    anyhow::bail!(\n        \"No gem executable found alongside Ruby at {}\",\n        ruby_path.display()\n    )\n}\n\n/// Query Ruby version and engine\nasync fn query_ruby_info(ruby_path: &Path) -> Result<(semver::Version, String)> {\n    let script = \"puts RUBY_ENGINE; puts RUBY_VERSION\";\n\n    let output = Cmd::new(ruby_path, \"query ruby version\")\n        .arg(\"-e\")\n        .arg(script)\n        .check(true)\n        .output()\n        .await?;\n\n    let mut lines = str::from_utf8(&output.stdout)?.lines();\n    let engine = lines.next().unwrap_or(\"ruby\").to_string();\n    let version_str = lines.next().context(\"No version in Ruby output\")?.trim();\n\n    let version = semver::Version::parse(version_str)\n        .with_context(|| format!(\"Failed to parse Ruby version: {version_str}\"))?;\n\n    Ok((version, engine))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::fs;\n    use std::str::FromStr;\n    use target_lexicon::Triple;\n    use tempfile::TempDir;\n\n    /// Mutex to serialize tests that mutate `PREK_RUBY_MIRROR`.\n    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());\n\n    /// RAII guard that serializes env var access and restores the original value on drop.\n    /// Holds the `ENV_MUTEX` lock for its lifetime, so tests using this guard run\n    /// sequentially. Ensures cleanup even if a test panics.\n    struct EnvVarGuard {\n        key: &'static str,\n        saved: Option<String>,\n        _lock: std::sync::MutexGuard<'static, ()>,\n    }\n\n    impl EnvVarGuard {\n        fn new(key: &'static str) -> Self {\n            let lock = ENV_MUTEX\n                .lock()\n                .unwrap_or_else(std::sync::PoisonError::into_inner);\n            let saved = EnvVars::var(key).ok();\n            Self {\n                key,\n                saved,\n                _lock: lock,\n            }\n        }\n    }\n\n    impl Drop for EnvVarGuard {\n        fn drop(&mut self) {\n            match &self.saved {\n                Some(v) => unsafe { std::env::set_var(self.key, v) },\n                None => unsafe { std::env::remove_var(self.key) },\n            }\n        }\n    }\n\n    #[test]\n    fn test_ruby_request_display() {\n        assert_eq!(RubyRequest::Any.to_string(), \"any\");\n        assert_eq!(RubyRequest::Exact(3, 4, 6).to_string(), \"3.4.6\");\n        assert_eq!(RubyRequest::MajorMinor(3, 4).to_string(), \"3.4\");\n        assert_eq!(RubyRequest::Major(3).to_string(), \"3\");\n\n        let range = semver::VersionReq::parse(\">=3.2\").unwrap();\n        assert_eq!(\n            RubyRequest::Range(range, \">=3.2\".to_string()).to_string(),\n            \">=3.2\"\n        );\n    }\n\n    #[tokio::test]\n    #[cfg(not(target_os = \"windows\"))]\n    async fn test_search_ruby_installations_empty_dir() {\n        let temp_dir = TempDir::new().unwrap();\n        let request = RubyRequest::Any;\n\n        let result = search_ruby_installations(temp_dir.path(), &request).await;\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    #[cfg(not(target_os = \"windows\"))]\n    async fn test_search_ruby_installations_no_ruby() {\n        let temp_dir = TempDir::new().unwrap();\n\n        // Create a subdirectory without ruby\n        let ruby_dir = temp_dir.path().join(\"ruby-3.4.6\");\n        fs::create_dir_all(ruby_dir.join(\"bin\")).unwrap();\n\n        let request = RubyRequest::Any;\n        let result = search_ruby_installations(temp_dir.path(), &request).await;\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    #[cfg(not(target_os = \"windows\"))]\n    async fn test_search_ruby_installations_with_file() {\n        let temp_dir = TempDir::new().unwrap();\n\n        // Create a subdirectory with a fake ruby file (not executable)\n        let ruby_dir = temp_dir.path().join(\"ruby-3.4.6\");\n        fs::create_dir_all(ruby_dir.join(\"bin\")).unwrap();\n        let ruby_path = ruby_dir.join(\"bin/ruby\");\n        fs::write(&ruby_path, \"#!/bin/sh\\necho fake ruby\").unwrap();\n\n        let request = RubyRequest::Any;\n        let result = search_ruby_installations(temp_dir.path(), &request).await;\n\n        // Result should be None because the fake ruby won't execute properly\n        // This test verifies the function handles execution failures gracefully\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_ruby_not_found_error() {\n        let error = ruby_not_found_error(&RubyRequest::Exact(3, 4, 6), \"Some reason.\");\n        assert!(error.contains(\"3.4.6\"));\n        assert!(error.contains(\"No suitable Ruby found\"));\n        assert!(error.contains(\"Some reason.\"));\n        assert!(error.contains(\"Please install Ruby manually.\"));\n\n        let error = ruby_not_found_error(&RubyRequest::Any, \"Another reason.\");\n        assert!(error.contains(\"any\"));\n        assert!(error.contains(\"Another reason.\"));\n    }\n\n    #[test]\n    fn test_rv_ruby_urls_default() {\n        let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR);\n        unsafe { std::env::remove_var(EnvVars::PREK_RUBY_MIRROR) };\n\n        let (api_url, api_is_github) = rv_ruby_api_url();\n        assert_eq!(\n            api_url,\n            \"https://api.github.com/repos/spinel-coop/rv-ruby/releases/latest\"\n        );\n        assert!(api_is_github);\n\n        let (dl_url, dl_is_github) = rv_ruby_download_base();\n        assert_eq!(\n            dl_url,\n            format!(\"{RV_RUBY_DEFAULT_URL}/releases/latest/download\")\n        );\n        assert!(dl_is_github);\n    }\n\n    #[test]\n    fn test_rv_ruby_urls_github_mirror() {\n        // A github.com mirror: API URL is rewritten, download URL uses web URL.\n        let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR);\n        unsafe {\n            std::env::set_var(\n                EnvVars::PREK_RUBY_MIRROR,\n                \"https://github.com/myorg/vetted-rubies\",\n            );\n        }\n\n        let (api_url, api_is_github) = rv_ruby_api_url();\n        assert_eq!(\n            api_url,\n            \"https://api.github.com/repos/myorg/vetted-rubies/releases/latest\"\n        );\n        assert!(api_is_github);\n\n        let (dl_url, dl_is_github) = rv_ruby_download_base();\n        assert_eq!(\n            dl_url,\n            \"https://github.com/myorg/vetted-rubies/releases/latest/download\"\n        );\n        assert!(dl_is_github);\n    }\n\n    #[test]\n    fn test_rv_ruby_urls_non_github_mirror() {\n        // A non-github mirror: both URLs use the mirror as-is, is_github is false.\n        let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR);\n        unsafe {\n            std::env::set_var(\n                EnvVars::PREK_RUBY_MIRROR,\n                \"https://my-mirror.example.com/rv-ruby\",\n            );\n        }\n\n        let (api_url, api_is_github) = rv_ruby_api_url();\n        assert_eq!(\n            api_url,\n            \"https://my-mirror.example.com/rv-ruby/releases/latest\"\n        );\n        assert!(!api_is_github);\n\n        let (dl_url, dl_is_github) = rv_ruby_download_base();\n        assert_eq!(\n            dl_url,\n            \"https://my-mirror.example.com/rv-ruby/releases/latest/download\"\n        );\n        assert!(!dl_is_github);\n    }\n\n    #[test]\n    fn test_find_gem_for_ruby_missing() {\n        let temp_dir = TempDir::new().unwrap();\n        let ruby_path = temp_dir.path().join(\"bin/ruby\");\n\n        // Create parent dir but no gem\n        fs::create_dir_all(temp_dir.path().join(\"bin\")).unwrap();\n        fs::write(&ruby_path, \"fake\").unwrap();\n\n        let result = find_gem_for_ruby(&ruby_path);\n        assert!(result.is_err());\n        assert!(\n            result\n                .unwrap_err()\n                .to_string()\n                .contains(\"No gem executable found\")\n        );\n    }\n\n    #[test]\n    fn test_find_gem_for_ruby_found() {\n        let temp_dir = TempDir::new().unwrap();\n        let bin_dir = temp_dir.path().join(\"bin\");\n        fs::create_dir_all(&bin_dir).unwrap();\n\n        let ruby_path = bin_dir.join(\"ruby\");\n        let gem_path = bin_dir.join(\"gem\");\n\n        fs::write(&ruby_path, \"fake ruby\").unwrap();\n        fs::write(&gem_path, \"fake gem\").unwrap();\n\n        let result = find_gem_for_ruby(&ruby_path);\n        assert!(result.is_ok());\n        assert_eq!(result.unwrap(), gem_path);\n    }\n\n    #[test]\n    fn test_parse_version_from_asset() {\n        let suffix = \".x86_64_linux.tar.gz\";\n\n        // Standard version\n        assert_eq!(\n            parse_version_from_asset(\"ruby-3.4.8.x86_64_linux.tar.gz\", suffix),\n            Some(semver::Version::new(3, 4, 8))\n        );\n\n        // Different version\n        assert_eq!(\n            parse_version_from_asset(\"ruby-3.3.0.x86_64_linux.tar.gz\", suffix),\n            Some(semver::Version::new(3, 3, 0))\n        );\n\n        // Wrong platform: should not match\n        assert_eq!(\n            parse_version_from_asset(\"ruby-3.4.8.arm64_linux.tar.gz\", suffix),\n            None\n        );\n\n        // Pre-release: filtered out\n        assert_eq!(\n            parse_version_from_asset(\"ruby-3.5.0-preview1.x86_64_linux.tar.gz\", suffix),\n            None\n        );\n\n        // Non-semver (two components): filtered out\n        assert_eq!(\n            parse_version_from_asset(\"ruby-0.49.x86_64_linux.tar.gz\", suffix),\n            None\n        );\n\n        // Not a ruby asset\n        assert_eq!(\n            parse_version_from_asset(\"something-else.tar.gz\", suffix),\n            None\n        );\n    }\n\n    #[test]\n    fn test_rv_platform_string_for_macos() {\n        let intel = Triple::from_str(\"x86_64-apple-darwin\").unwrap();\n        assert_eq!(rv_platform_string(&intel), Some(\"ventura\"));\n\n        let arm = Triple::from_str(\"aarch64-apple-darwin\").unwrap();\n        assert_eq!(rv_platform_string(&arm), Some(\"arm64_sonoma\"));\n    }\n\n    #[test]\n    fn test_rv_platform_string_for_linux() {\n        let gnu = Triple::from_str(\"x86_64-unknown-linux-gnu\").unwrap();\n        assert_eq!(rv_platform_string(&gnu), Some(\"x86_64_linux\"));\n\n        let arm_gnu = Triple::from_str(\"aarch64-unknown-linux-gnu\").unwrap();\n        assert_eq!(rv_platform_string(&arm_gnu), Some(\"arm64_linux\"));\n\n        let musl = Triple::from_str(\"x86_64-unknown-linux-musl\").unwrap();\n        assert_eq!(rv_platform_string(&musl), Some(\"x86_64_linux_musl\"));\n\n        let arm_musl = Triple::from_str(\"aarch64-unknown-linux-musl\").unwrap();\n        assert_eq!(rv_platform_string(&arm_musl,), Some(\"arm64_linux_musl\"));\n    }\n\n    #[test]\n    fn test_rv_platform_string_unsupported() {\n        let windows = Triple::from_str(\"x86_64-pc-windows-msvc\").unwrap();\n        assert_eq!(rv_platform_string(&windows), None);\n\n        let linux_unknown_libc = Triple::from_str(\"x86_64-unknown-linux-gnux32\").unwrap();\n        assert_eq!(rv_platform_string(&linux_unknown_libc), None);\n    }\n\n    #[test]\n    fn test_find_installed_empty_dir() {\n        let temp_dir = TempDir::new().unwrap();\n        let installer = RubyInstaller::new(temp_dir.path().to_path_buf());\n\n        assert!(installer.find_installed(&RubyRequest::Any).is_none());\n    }\n\n    #[test]\n    fn test_find_installed_with_versions() {\n        let temp_dir = TempDir::new().unwrap();\n\n        // Create fake Ruby installations\n        for version in [\"3.3.5\", \"3.4.8\", \"3.2.1\"] {\n            let bin_dir = temp_dir.path().join(version).join(\"bin\");\n            fs::create_dir_all(&bin_dir).unwrap();\n            fs::write(bin_dir.join(\"ruby\"), \"fake\").unwrap();\n            fs::write(bin_dir.join(\"gem\"), \"fake\").unwrap();\n        }\n\n        let installer = RubyInstaller::new(temp_dir.path().to_path_buf());\n\n        // Any: should return highest version\n        let result = installer.find_installed(&RubyRequest::Any).unwrap();\n        assert_eq!(*result.version(), semver::Version::new(3, 4, 8));\n\n        // MajorMinor(3, 3): should return 3.3.5\n        let result = installer\n            .find_installed(&RubyRequest::MajorMinor(3, 3))\n            .unwrap();\n        assert_eq!(*result.version(), semver::Version::new(3, 3, 5));\n\n        // Exact match\n        let result = installer\n            .find_installed(&RubyRequest::Exact(3, 2, 1))\n            .unwrap();\n        assert_eq!(*result.version(), semver::Version::new(3, 2, 1));\n\n        // No match\n        assert!(\n            installer\n                .find_installed(&RubyRequest::MajorMinor(2, 7))\n                .is_none()\n        );\n    }\n\n    #[test]\n    fn test_is_github_https() {\n        // Exact match over HTTPS\n        assert!(is_github_https(\"https://github.com/spinel-coop/rv-ruby\"));\n        assert!(is_github_https(\"https://github.com:443/org/repo\"));\n\n        // Plaintext HTTP — don't leak tokens\n        assert!(!is_github_https(\"http://github.com/org/repo\"));\n        // Not github.com\n        assert!(!is_github_https(\"https://gitlab.com/org/repo\"));\n        assert!(!is_github_https(\"https://my-mirror.example.com/rv-ruby\"));\n        // Path injection — github.com in path, not host\n        assert!(!is_github_https(\"https://evil.com/github.com/rv\"));\n        // Subdomain — not the same host\n        assert!(!is_github_https(\"https://api.github.com/repos/org/repo\"));\n        // Userinfo-based redirect\n        assert!(!is_github_https(\"https://github.com@evil.com/org/repo\"));\n        assert!(!is_github_https(\n            \"https://github.com:password@evil.com/org/repo\"\n        ));\n        assert!(!is_github_https(\"https://evil.com@github.com/org/repo\"));\n        // Other schemes\n        assert!(!is_github_https(\"ftp://github.com/org/repo\"));\n    }\n\n    #[test]\n    fn test_find_installed_skips_incomplete_dirs() {\n        let temp_dir = TempDir::new().unwrap();\n\n        // Version dir with ruby but no gem\n        let bin_dir = temp_dir.path().join(\"3.4.8\").join(\"bin\");\n        fs::create_dir_all(&bin_dir).unwrap();\n        fs::write(bin_dir.join(\"ruby\"), \"fake\").unwrap();\n\n        // Version dir with no bin at all\n        fs::create_dir_all(temp_dir.path().join(\"3.3.0\")).unwrap();\n\n        // Non-version directory\n        fs::create_dir_all(temp_dir.path().join(\"not-a-version\").join(\"bin\")).unwrap();\n\n        let installer = RubyInstaller::new(temp_dir.path().to_path_buf());\n        assert!(installer.find_installed(&RubyRequest::Any).is_none());\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/ruby/mod.rs",
    "content": "#![warn(dead_code)]\n#![warn(clippy::missing_errors_doc)]\n#![warn(clippy::missing_panics_doc)]\n#![warn(clippy::must_use_candidate)]\n#![warn(clippy::module_name_repetitions)]\n#![warn(clippy::too_many_arguments)]\n\nmod gem;\nmod installer;\n#[allow(clippy::module_inception)]\nmod ruby;\nmod version;\n\npub(crate) use ruby::Ruby;\npub(crate) use version::RubyRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/ruby/ruby.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::languages::ruby::RubyRequest;\nuse crate::languages::ruby::gem::{build_gemspecs, install_gems};\nuse crate::languages::ruby::installer::RubyInstaller;\nuse crate::languages::version::LanguageRequest;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{Store, ToolBucket};\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Ruby;\n\nimpl LanguageImpl for Ruby {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        // 1. Install Ruby\n        let ruby_dir = store.tools_path(ToolBucket::Ruby);\n        let installer = RubyInstaller::new(ruby_dir);\n\n        let (request, allows_download) = match &hook.language_request {\n            LanguageRequest::Any { system_only } => (&RubyRequest::Any, !system_only),\n            LanguageRequest::Ruby(req) => (req, true),\n            _ => unreachable!(),\n        };\n\n        let ruby = installer\n            .install(store, request, allows_download)\n            .await\n            .context(\"Failed to install Ruby\")?;\n\n        // 2. Create InstallInfo\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        info.with_toolchain(ruby.ruby_bin().to_path_buf())\n            .with_language_version(ruby.version().clone());\n\n        // Store Ruby engine in metadata\n        info.with_extra(\"ruby_engine\", ruby.engine());\n\n        // 3. Create environment directories\n        let gem_home = gem_home(&info.env_path);\n        fs_err::tokio::create_dir_all(&gem_home).await?;\n        fs_err::tokio::create_dir_all(gem_home.join(\"bin\")).await?;\n\n        // 4. Build gemspecs\n        if let Some(repo_path) = hook.repo_path() {\n            // Try to build gemspecs, but don't fail if there aren't any\n            match build_gemspecs(&ruby, repo_path).await {\n                Ok(gem_files) => {\n                    debug!(\"Built {} gem(s) from gemspecs\", gem_files.len());\n                }\n                Err(e) if e.to_string().contains(\"No .gemspec files\") => {\n                    debug!(\"No gemspecs found in repo, skipping gem build\");\n                }\n                Err(e) => return Err(e).context(\"Failed to build gemspecs\"),\n            }\n        }\n\n        // 5. Install gems (Note that pre-commit installs all *.gem files, not only those built from gemspecs)\n        install_gems(\n            &ruby,\n            &gem_home,\n            hook.repo_path(),\n            &hook.additional_dependencies,\n        )\n        .await\n        .context(\"Failed to install gems\")?;\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        // 1. Verify Ruby executable exists\n        if !info.toolchain.exists() {\n            anyhow::bail!(\"Ruby executable not found at {}\", info.toolchain.display());\n        }\n\n        // 2. Verify it runs and reports correct version\n        let script = \"puts RUBY_VERSION\";\n        let output = Cmd::new(&info.toolchain, \"check ruby version\")\n            .arg(\"-e\")\n            .arg(script)\n            .check(true)\n            .output()\n            .await?;\n\n        let version_str = str::from_utf8(&output.stdout)?.trim();\n        let actual_version = semver::Version::parse(version_str)\n            .with_context(|| format!(\"Failed to parse Ruby version: {version_str}\"))?;\n\n        if actual_version != info.language_version {\n            anyhow::bail!(\n                \"Ruby version mismatch: expected {}, found {}\",\n                info.language_version,\n                actual_version\n            );\n        }\n\n        // 3. Verify gem home exists\n        let gem_home = gem_home(&info.env_path);\n        if !gem_home.exists() {\n            anyhow::bail!(\"Gem home directory not found at {}\", gem_home.display());\n        }\n\n        // 4. Verify gem bin directory exists\n        let gem_bin = gem_home.join(\"bin\");\n        if !gem_bin.exists() {\n            anyhow::bail!(\"Gem bin directory not found at {}\", gem_bin.display());\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Ruby hook must have env path\");\n\n        // Prepare PATH\n        let gem_home = gem_home(env_dir);\n        let gem_bin = gem_home.join(\"bin\");\n        let ruby_bin = hook\n            .toolchain_dir()\n            .expect(\"Ruby toolchain should have parent\");\n\n        let new_path = prepend_paths(&[&gem_bin, ruby_bin]).context(\"Failed to join PATH\")?;\n\n        // Resolve entry point\n        let entry = hook.entry.resolve(Some(&new_path))?;\n\n        // Execute in batches\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"ruby hook\")\n                .current_dir(hook.work_dir())\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::GEM_HOME, &gem_home)\n                .env(EnvVars::BUNDLE_IGNORE_CONFIG, \"1\")\n                .env_remove(EnvVars::GEM_PATH)\n                .env_remove(EnvVars::BUNDLE_GEMFILE)\n                .envs(&hook.env)\n                .args(&entry[1..])\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Combine results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\n/// Get the `GEM_HOME` path for this environment\nfn gem_home(env_path: &Path) -> PathBuf {\n    env_path.join(\"gems\")\n}\n"
  },
  {
    "path": "crates/prek/src/languages/ruby/version.rs",
    "content": "use std::fmt;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n/// Ruby version request parsed from `language_version` field\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum RubyRequest {\n    /// Any available Ruby (prefer system, then latest)\n    Any,\n\n    /// Exact major.minor.patch version\n    Exact(u64, u64, u64),\n\n    /// Major.minor (latest patch)\n    MajorMinor(u64, u64),\n\n    /// Major version (latest minor.patch)\n    Major(u64),\n\n    /// Explicit file path to Ruby interpreter\n    Path(PathBuf),\n\n    /// Semver range (e.g., \">=3.2, <4.0\")\n    Range(semver::VersionReq, String),\n}\n\nimpl fmt::Display for RubyRequest {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::Any => f.write_str(\"any\"),\n            Self::Exact(maj, min, patch) => write!(f, \"{maj}.{min}.{patch}\"),\n            Self::MajorMinor(maj, min) => write!(f, \"{maj}.{min}\"),\n            Self::Major(maj) => write!(f, \"{maj}\"),\n            Self::Path(p) => write!(f, \"{}\", p.display()),\n            Self::Range(_, s) => f.write_str(s),\n        }\n    }\n}\n\nimpl FromStr for RubyRequest {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        // Empty/default\n        if s.is_empty() {\n            return Ok(Self::Any);\n        }\n\n        // Strip \"ruby-\" prefix if present\n        if let Some(version_part) = s.strip_prefix(\"ruby\") {\n            let version_part = version_part.strip_prefix('-').unwrap_or(version_part);\n            if version_part.is_empty() {\n                return Ok(Self::Any);\n            }\n\n            // Only allow version numbers after \"ruby\" prefix\n            return Self::parse_version_numbers(version_part, s);\n        }\n\n        // Try parsing as version numbers (any of one to three parts)\n        if let Ok(req) = Self::parse_version_numbers(s, s) {\n            return Ok(req);\n        }\n\n        // Try parsing as semver range\n        if let Ok(req) = semver::VersionReq::parse(s) {\n            return Ok(Self::Range(req, s.to_string()));\n        }\n\n        // Finally try as a file path\n        let path = PathBuf::from(s);\n        if path.exists() {\n            return Ok(Self::Path(path));\n        }\n\n        Err(Error::InvalidVersion(s.to_string()))\n    }\n}\n\nimpl RubyRequest {\n    /// Check if this request accepts any Ruby version\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, Self::Any)\n    }\n\n    /// Parse version numbers into appropriate `RubyRequest` variants\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<RubyRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n\n        match parts.as_slice() {\n            [major] => Ok(RubyRequest::Major(*major)),\n            [major, minor] => Ok(RubyRequest::MajorMinor(*major, *minor)),\n            [major, minor, patch] => Ok(RubyRequest::Exact(*major, *minor, *patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    /// Check if this request matches a Ruby version during installation search\n    ///\n    /// This is used by the installer when searching for existing Ruby installations.\n    pub(crate) fn matches(&self, version: &semver::Version, toolchain: Option<&Path>) -> bool {\n        match self {\n            Self::Any => true,\n            Self::Exact(maj, min, patch) => {\n                version.major == *maj && version.minor == *min && version.patch == *patch\n            }\n            Self::MajorMinor(maj, min) => version.major == *maj && version.minor == *min,\n            Self::Major(maj) => version.major == *maj,\n            // FIXME: consider resolving symlinks and normalizing paths before comparison\n            Self::Path(path) => toolchain.is_some_and(|t| t == path),\n            Self::Range(req, _) => req.matches(version),\n        }\n    }\n\n    /// Check if this request is satisfied by the given Ruby installation\n    ///\n    /// This is used at runtime to verify an installation meets the requirements.\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        self.matches(\n            &install_info.language_version,\n            Some(&install_info.toolchain),\n        )\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::Language;\n    use rustc_hash::FxHashSet;\n\n    #[test]\n    fn test_parse_ruby_request() {\n        // Empty/default\n        assert_eq!(RubyRequest::from_str(\"\").unwrap(), RubyRequest::Any);\n\n        // Exact versions\n        assert_eq!(\n            RubyRequest::from_str(\"3.3.6\").unwrap(),\n            RubyRequest::Exact(3, 3, 6)\n        );\n        assert_eq!(\n            RubyRequest::from_str(\"ruby-3.3.6\").unwrap(),\n            RubyRequest::Exact(3, 3, 6)\n        );\n\n        // Major.minor\n        assert_eq!(\n            RubyRequest::from_str(\"3.3\").unwrap(),\n            RubyRequest::MajorMinor(3, 3)\n        );\n        assert_eq!(\n            RubyRequest::from_str(\"ruby-3.3\").unwrap(),\n            RubyRequest::MajorMinor(3, 3)\n        );\n\n        // Major only\n        assert_eq!(RubyRequest::from_str(\"3\").unwrap(), RubyRequest::Major(3));\n        assert_eq!(\n            RubyRequest::from_str(\"ruby-3\").unwrap(),\n            RubyRequest::Major(3)\n        );\n\n        // Semver range\n        assert!(matches!(\n            RubyRequest::from_str(\">=3.2, <4.0\").unwrap(),\n            RubyRequest::Range(_, _)\n        ));\n        assert!(RubyRequest::from_str(\"ruby>=3.2, <4.0\").is_err());\n    }\n\n    #[test]\n    fn test_version_matching() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(3, 3, 6))\n            .with_toolchain(PathBuf::from(\"/usr/bin/ruby\"));\n\n        assert!(RubyRequest::Any.satisfied_by(&install_info));\n        assert!(RubyRequest::Exact(3, 3, 6).satisfied_by(&install_info));\n        assert!(RubyRequest::MajorMinor(3, 3).satisfied_by(&install_info));\n        assert!(RubyRequest::Major(3).satisfied_by(&install_info));\n        assert!(!RubyRequest::Exact(3, 3, 7).satisfied_by(&install_info));\n        assert!(!RubyRequest::Exact(3, 2, 6).satisfied_by(&install_info));\n\n        // Test path matching\n        assert!(RubyRequest::Path(PathBuf::from(\"/usr/bin/ruby\")).satisfied_by(&install_info));\n        assert!(!RubyRequest::Path(PathBuf::from(\"/usr/bin/ruby3.2\")).satisfied_by(&install_info));\n\n        // Test range matching\n        let req = semver::VersionReq::parse(\">=3.2, <4.0\")?;\n        assert!(\n            RubyRequest::Range(req.clone(), \">=3.2, <4.0\".to_string()).satisfied_by(&install_info)\n        );\n\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(3, 1, 0))\n            .with_toolchain(PathBuf::from(\"/usr/bin/ruby3.1\"));\n        assert!(!RubyRequest::Range(req, \">=3.2, <4.0\".to_string()).satisfied_by(&install_info));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/rust/installer.rs",
    "content": "use std::fmt::Display;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse itertools::Itertools;\nuse prek_consts::env_vars::EnvVars;\nuse semver::Version;\nuse tracing::{debug, trace};\n\nuse crate::fs::LockedFile;\nuse crate::languages::rust::RustRequest;\nuse crate::languages::rust::rustup::{Rustup, ToolchainInfo};\nuse crate::languages::rust::version::{Channel, RustVersion};\nuse crate::process::Cmd;\n\npub(crate) struct RustResult {\n    toolchain: PathBuf,\n    version: RustVersion,\n}\n\nimpl Display for RustResult {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}@{}\", self.toolchain.display(), *self.version)?;\n        Ok(())\n    }\n}\n\nimpl RustResult {\n    pub(crate) fn from_dir(dir: &Path) -> Self {\n        Self {\n            toolchain: dir.to_path_buf(),\n            version: RustVersion::default(),\n        }\n    }\n\n    pub(crate) fn toolchain(&self) -> &Path {\n        &self.toolchain\n    }\n\n    pub(crate) fn version(&self) -> &RustVersion {\n        &self.version\n    }\n\n    pub(crate) fn with_version(mut self, version: RustVersion) -> Self {\n        self.version = version;\n        self\n    }\n\n    pub(crate) async fn fill_version(mut self) -> Result<Self> {\n        let rustc = self\n            .toolchain\n            .join(\"bin\")\n            .join(\"rustc\")\n            .with_extension(std::env::consts::EXE_EXTENSION);\n\n        let output = Cmd::new(rustc, \"rustc --version\")\n            .arg(\"--version\")\n            .env(EnvVars::RUSTUP_AUTO_INSTALL, \"0\")\n            .check(true)\n            .output()\n            .await?;\n\n        // e.g. \"rustc 1.70.0 (90c541806 2023-05-31)\"\n        let version_str = str::from_utf8(&output.stdout)?;\n        let version_str = version_str\n            .split_ascii_whitespace()\n            .nth(1)\n            .with_context(|| format!(\"Failed to parse Rust version from output: {version_str}\"))?;\n\n        let version = Version::parse(version_str)?;\n        let version = RustVersion::from_path(&version, &self.toolchain);\n\n        self.version = version;\n\n        Ok(self)\n    }\n}\n\npub(crate) struct RustInstaller {\n    rustup: Rustup,\n}\n\nimpl RustInstaller {\n    pub(crate) fn new(rustup: Rustup) -> Self {\n        Self { rustup }\n    }\n\n    pub(crate) async fn install(\n        &self,\n        request: &RustRequest,\n        allows_download: bool,\n    ) -> Result<RustResult> {\n        let rustup_home = self.rustup.rustup_home();\n        fs_err::tokio::create_dir_all(rustup_home).await?;\n        let _lock = LockedFile::acquire(rustup_home.join(\".lock\"), \"rustup\").await?;\n\n        // Check installed\n        if let Ok(rust) = self.find_installed(request).await {\n            trace!(%rust, \"Found installed rust\");\n            return Ok(rust);\n        }\n\n        // Check system rust\n        if let Some(rust) = self.find_system_rust(request).await? {\n            trace!(%rust, \"Using system rust\");\n            return Ok(rust);\n        }\n\n        if !allows_download {\n            anyhow::bail!(\"No suitable system Rust version found and downloads are disabled\");\n        }\n\n        // Install new toolchain\n        let toolchain = self.resolve_version(request).await?;\n        self.download(&toolchain).await\n    }\n\n    async fn find_installed(&self, request: &RustRequest) -> Result<RustResult> {\n        let toolchains: Vec<ToolchainInfo> = self.rustup.list_installed_toolchains().await?;\n\n        let installed = toolchains\n            .into_iter()\n            .sorted_unstable_by(|a, b| b.version.cmp(&a.version));\n\n        installed\n            .into_iter()\n            .find_map(|info| {\n                let matches = request.matches(&info.version, Some(&info.path));\n\n                if matches {\n                    trace!(name = %info.name, \"Found matching installed rust\");\n                    Some(RustResult::from_dir(&info.path).with_version(info.version))\n                } else {\n                    trace!(name = %info.name, \"Installed rust does not match request\");\n                    None\n                }\n            })\n            .context(\"No installed rust version matches the request\")\n    }\n\n    async fn find_system_rust(&self, rust_request: &RustRequest) -> Result<Option<RustResult>> {\n        let toolchains: Vec<ToolchainInfo> = self.rustup.list_system_toolchains().await?;\n\n        let installed = toolchains\n            .into_iter()\n            .sorted_unstable_by(|a, b| b.version.cmp(&a.version));\n\n        for info in installed {\n            let matches = rust_request.matches(&info.version, Some(&info.path));\n\n            if matches {\n                trace!(name = %info.name, \"Found matching system rust\");\n                let rust = RustResult::from_dir(&info.path).with_version(info.version);\n                return Ok(Some(rust));\n            }\n            trace!(name = %info.name, \"System rust does not match request\");\n        }\n\n        debug!(\n            ?rust_request,\n            \"No system rust matches the requested version\"\n        );\n        Ok(None)\n    }\n\n    async fn resolve_version(&self, req: &RustRequest) -> Result<RustVersion> {\n        match req {\n            RustRequest::Any => Ok(RustVersion::from_channel(Channel::Stable)),\n            RustRequest::Channel(ch) => Ok(RustVersion::from_channel(*ch)),\n\n            RustRequest::Major(_)\n            | RustRequest::MajorMinor(_, _)\n            | RustRequest::MajorMinorPatch(_, _, _)\n            | RustRequest::Range(_, _) => {\n                let output = crate::git::git_cmd(\"list rust tags\")?\n                    .arg(\"ls-remote\")\n                    .arg(\"--tags\")\n                    .arg(\"https://github.com/rust-lang/rust\")\n                    .output()\n                    .await?\n                    .stdout;\n                let versions: Vec<RustVersion> = str::from_utf8(&output)?\n                    .lines()\n                    .filter_map(|line| {\n                        let tag = line.split('\\t').nth(1)?;\n                        let tag = tag.strip_prefix(\"refs/tags/\")?;\n                        Version::parse(tag)\n                            .ok()\n                            .map(|v| RustVersion::from_version(&v))\n                    })\n                    .sorted_unstable_by(|a, b| b.cmp(a))\n                    .collect();\n\n                let version = versions\n                    .into_iter()\n                    .find(|version| req.matches(version, None))\n                    .with_context(|| format!(\"Version `{req}` not found on remote\"))?;\n                Ok(version)\n            }\n        }\n    }\n\n    async fn download(&self, toolchain: &RustVersion) -> Result<RustResult> {\n        let toolchain = toolchain.to_toolchain_name();\n        debug!(%toolchain, \"Installing Rust toolchain\");\n\n        let toolchain_dir = self\n            .rustup\n            .install_toolchain(&toolchain)\n            .await\n            .context(\"Failed to install Rust toolchain\")?;\n\n        let rust = RustResult::from_dir(&toolchain_dir).fill_version().await?;\n        Ok(rust)\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/rust/mod.rs",
    "content": "mod installer;\n#[allow(clippy::module_inception)]\nmod rust;\nmod rustup;\nmod version;\n\npub(crate) use rust::Rust;\npub(crate) use version::RustRequest;\n"
  },
  {
    "path": "crates/prek/src/languages/rust/rust.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::ffi::OsStr;\nuse std::ops::Deref;\nuse std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::str::FromStr;\nuse std::sync::Arc;\n\nuse anyhow::{Context, bail};\nuse itertools::{Either, Itertools};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::languages::rust::RustRequest;\nuse crate::languages::rust::installer::RustInstaller;\nuse crate::languages::rust::rustup::Rustup;\nuse crate::languages::rust::version::EXTRA_KEY_CHANNEL;\nuse crate::languages::version::LanguageRequest;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::{CacheBucket, Store, ToolBucket};\n\nfn format_cargo_dependency(dep: &str) -> String {\n    let (name, version) = dep.split_once(':').unwrap_or((dep, \"\"));\n    if version.is_empty() {\n        format!(\"{name}@*\")\n    } else {\n        format!(\"{name}@{version}\")\n    }\n}\n\n#[derive(Debug, Eq, PartialEq)]\nenum CargoCliDependency {\n    Crate {\n        name: String,\n        version: Option<String>,\n    },\n    Git {\n        url: String,\n        tag: Option<String>,\n        package: Option<String>,\n    },\n}\n\nimpl FromStr for CargoCliDependency {\n    type Err = anyhow::Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let is_url = s.starts_with(\"http://\") || s.starts_with(\"https://\");\n        if is_url {\n            let scheme_end = s\n                .find(\"://\")\n                .map(|idx| idx + 3)\n                .with_context(|| format!(\"Invalid git URL `{s}`\"))?;\n            let rest = &s[scheme_end..];\n\n            let parts: Vec<&str> = rest.rsplitn(3, ':').collect();\n            let (url_without_scheme, tag, package) = match parts.as_slice() {\n                [url] => (*url, None, None),\n                [tag, url] => {\n                    if tag.is_empty() {\n                        bail!(\n                            \"Git CLI dependency `{s}` contains an empty tag; use `cli:<url>`, `cli:<url>:<tag>`, or `cli:<url>:<tag>:<package>`\"\n                        );\n                    }\n                    (*url, Some(*tag), None)\n                }\n                [package, tag, url] => {\n                    if package.is_empty() {\n                        bail!(\n                            \"Git CLI dependency `{s}` must specify a non-empty package when using `cli:<url>:<tag>:<package>`\"\n                        );\n                    }\n                    let tag = if tag.is_empty() { None } else { Some(*tag) };\n                    (*url, tag, Some(*package))\n                }\n                _ => unreachable!(),\n            };\n\n            let url = format!(\"{}{}\", &s[..scheme_end], url_without_scheme);\n            Ok(CargoCliDependency::Git {\n                url,\n                tag: tag.map(ToString::to_string),\n                package: package.map(ToString::to_string),\n            })\n        } else {\n            let (name, version) = if let Some((pkg, ver)) = s.rsplit_once(':') {\n                (pkg.to_string(), Some(ver.to_string()))\n            } else {\n                (s.to_string(), None)\n            };\n\n            Ok(CargoCliDependency::Crate { name, version })\n        }\n    }\n}\n\nimpl CargoCliDependency {\n    fn to_cargo_args(&self) -> Vec<&str> {\n        let mut args: Vec<&str> = Vec::with_capacity(2);\n        match self {\n            CargoCliDependency::Crate { name, version } => {\n                args.push(name);\n                if let Some(version) = version {\n                    args.push(\"--version\");\n                    args.push(version);\n                }\n            }\n            CargoCliDependency::Git { url, tag, package } => {\n                args.push(\"--git\");\n                args.push(url);\n                if let Some(tag) = tag {\n                    args.push(\"--tag\");\n                    args.push(tag);\n                }\n                if let Some(package) = package {\n                    args.push(package);\n                }\n            }\n        }\n\n        args\n    }\n}\n\n/// Find the package directory that produces the given binary.\n/// Returns (`package_dir`, `package_name`, `is_workspace`).\nasync fn find_package_dir(\n    repo: &Path,\n    binary_name: &str,\n    cargo: Option<&Path>,\n    cargo_home: Option<&Path>,\n    new_path: Option<&OsStr>,\n) -> anyhow::Result<Option<(PathBuf, String, bool)>> {\n    let cargo = cargo.unwrap_or(Path::new(\"cargo\"));\n\n    let mut cmd = Cmd::new(cargo, \"cargo metadata\");\n    if let Some(new_path) = new_path {\n        cmd.env(EnvVars::PATH, new_path);\n    }\n    if let Some(cargo_home) = cargo_home {\n        cmd.env(EnvVars::CARGO_HOME, cargo_home);\n    }\n    let output = cmd\n        .arg(\"metadata\")\n        .arg(\"--format-version\")\n        .arg(\"1\")\n        .arg(\"--no-deps\")\n        .arg(\"--manifest-path\")\n        .arg(repo.join(\"Cargo.toml\"))\n        .output()\n        .await?;\n    let stdout = str::from_utf8(&output.stdout)?\n        .lines()\n        .find(|line| line.starts_with('{'))\n        .ok_or(cargo_metadata::Error::NoJson)?;\n    let metadata: cargo_metadata::Metadata =\n        serde_json::from_str(stdout).context(\"Failed to parse cargo metadata output\")?;\n\n    // Search all workspace packages for one that produces this binary\n    for package_id in &metadata.workspace_members {\n        let package = metadata\n            .packages\n            .iter()\n            .find(|p| &p.id == package_id)\n            .with_context(|| format!(\"Package not found in metadata for id: {package_id}\"))?;\n\n        if package_produces_binary(package, binary_name) {\n            let package_dir = package\n                .manifest_path\n                .parent()\n                .expect(\"manifest should have parent\")\n                .as_std_path()\n                .to_path_buf();\n\n            // It's a workspace if either:\n            // - there are multiple members, OR\n            // - the package is not at the workspace root\n            let is_workspace = metadata.workspace_members.len() > 1\n                || package_dir != metadata.workspace_root.as_std_path();\n\n            return Ok(Some((package_dir, package.name.to_string(), is_workspace)));\n        }\n    }\n\n    Ok(None)\n}\n\n/// Check if two names match, accounting for hyphen/underscore normalization.\nfn names_match(a: &str, b: &str) -> bool {\n    a == b || a.replace('-', \"_\") == b.replace('-', \"_\")\n}\n\n/// Check if a package produces a binary with the given name.\nfn package_produces_binary(package: &cargo_metadata::Package, binary_name: &str) -> bool {\n    package\n        .targets\n        .iter()\n        .filter(|t| t.is_bin())\n        .any(|t| names_match(&t.name, binary_name))\n}\n\n/// Copy executable binaries from a release directory to a destination bin directory.\nasync fn copy_binaries(release_dir: &Path, dest_bin_dir: &Path) -> anyhow::Result<()> {\n    let mut entries = fs_err::tokio::read_dir(release_dir).await?;\n    while let Some(entry) = entries.next_entry().await? {\n        let path = entry.path();\n        let file_type = entry.file_type().await?;\n        // Copy executable files (not directories, not .d files, etc.)\n        if file_type.is_file() {\n            if let Some(ext) = path.extension() {\n                // Skip non-binary files like .d, .rlib, etc.\n                if ext == \"d\" || ext == \"rlib\" || ext == \"rmeta\" {\n                    continue;\n                }\n            }\n            // On Unix, check if it's executable; on Windows, check for .exe\n            #[cfg(unix)]\n            {\n                use std::os::unix::fs::PermissionsExt;\n                let meta = entry.metadata().await?;\n                if meta.permissions().mode() & 0o111 != 0 {\n                    let dest = dest_bin_dir.join(entry.file_name());\n                    fs_err::tokio::copy(&path, &dest).await?;\n                }\n            }\n            #[cfg(windows)]\n            {\n                if path.extension().is_some_and(|e| e == \"exe\") {\n                    let dest = dest_bin_dir.join(entry.file_name());\n                    fs_err::tokio::copy(&path, &dest).await?;\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\nasync fn install_local_project(\n    hook_binary: &str,\n    repo_path: &Path,\n    info: &InstallInfo,\n    lib_deps: &[&String],\n    cargo: &Path,\n    cargo_home: &Path,\n    new_path: &OsStr,\n) -> anyhow::Result<()> {\n    // Find the specific package directory for this hook's binary\n    let (package_dir, package_name, is_workspace) = match find_package_dir(\n        repo_path,\n        hook_binary,\n        Some(cargo),\n        Some(cargo_home),\n        Some(new_path),\n    )\n    .await\n    {\n        Err(e) => {\n            return Err(e.context(\"Failed to find package directory using cargo metadata\"));\n        }\n        Ok(Some((package_dir, package_name, is_workspace))) => {\n            debug!(\n                \"Found package `{}` for binary `{}` in repo `{}` at `{}`\",\n                package_name,\n                hook_binary,\n                repo_path.display(),\n                package_dir.display(),\n            );\n            (package_dir, package_name, is_workspace)\n        }\n        Ok(None) => {\n            debug!(\n                \"Binary `{}` not found in cargo metadata for repo `{}`, falling back to repo root\",\n                hook_binary,\n                repo_path.display(),\n            );\n            (repo_path.to_path_buf(), String::new(), false)\n        }\n    };\n\n    if lib_deps.is_empty() {\n        // For packages without lib deps, use `cargo install` directly\n        Cmd::new(cargo, \"install local\")\n            .args([\"install\", \"--bins\", \"--root\"])\n            .arg(&info.env_path)\n            .args([\"--path\", \".\", \"--locked\"])\n            .current_dir(&package_dir)\n            .env(EnvVars::PATH, new_path)\n            .env(EnvVars::CARGO_HOME, cargo_home)\n            .remove_git_envs()\n            .check(true)\n            .output()\n            .await?;\n    } else {\n        // For packages with lib deps, copy manifest, modify, build and copy binaries\n        let manifest_dir = info.env_path.join(\"manifest\");\n        fs_err::tokio::create_dir_all(&manifest_dir).await?;\n\n        // Copy Cargo.toml\n        let src_manifest = package_dir.join(\"Cargo.toml\");\n        let dst_manifest = manifest_dir.join(\"Cargo.toml\");\n        fs_err::tokio::copy(&src_manifest, &dst_manifest).await?;\n\n        // Copy Cargo.lock if it exists (check both package dir and repo root for workspaces)\n        let lock_locations = if is_workspace {\n            vec![repo_path.join(\"Cargo.lock\"), package_dir.join(\"Cargo.lock\")]\n        } else {\n            vec![package_dir.join(\"Cargo.lock\")]\n        };\n        for lock_path in lock_locations {\n            if lock_path.exists() {\n                fs_err::tokio::copy(&lock_path, manifest_dir.join(\"Cargo.lock\")).await?;\n                break;\n            }\n        }\n\n        // Copy src directory (cargo add needs it to exist for path validation)\n        let src_dir = package_dir.join(\"src\");\n        if src_dir.exists() {\n            let dst_src = manifest_dir.join(\"src\");\n            fs_err::tokio::create_dir_all(&dst_src).await?;\n            let mut entries = fs_err::tokio::read_dir(&src_dir).await?;\n            while let Some(entry) = entries.next_entry().await? {\n                if entry.file_type().await?.is_file() {\n                    fs_err::tokio::copy(entry.path(), dst_src.join(entry.file_name())).await?;\n                }\n            }\n        }\n\n        // Run cargo add on the copied manifest\n        let mut cmd = Cmd::new(cargo, \"add dependencies\");\n        cmd.arg(\"add\");\n        for dep in lib_deps {\n            cmd.arg(format_cargo_dependency(dep.as_str()));\n        }\n        cmd.current_dir(&manifest_dir)\n            .env(EnvVars::PATH, new_path)\n            .env(EnvVars::CARGO_HOME, cargo_home)\n            .remove_git_envs()\n            .check(true)\n            .output()\n            .await?;\n\n        // Build using cargo build with --manifest-path pointing to modified manifest\n        // but source files come from original package_dir\n        let target_dir = info.env_path.join(\"target\");\n        let mut cmd = Cmd::new(cargo, \"build local with deps\");\n        cmd.args([\"build\", \"--bins\", \"--release\"])\n            .arg(\"--manifest-path\")\n            .arg(&dst_manifest)\n            .arg(\"--target-dir\")\n            .arg(&target_dir);\n\n        // For workspace members, explicitly specify the package\n        if is_workspace && !package_name.is_empty() {\n            cmd.args([\"--package\", &package_name]);\n        }\n\n        cmd.current_dir(&package_dir)\n            .env(EnvVars::PATH, new_path)\n            .env(EnvVars::CARGO_HOME, cargo_home)\n            .remove_git_envs()\n            .check(true)\n            .output()\n            .await?;\n\n        // Copy compiled binaries to the bin directory\n        copy_binaries(&target_dir.join(\"release\"), &bin_dir(&info.env_path)).await?;\n\n        // Clean up manifest and target directories\n        fs_err::tokio::remove_dir_all(&manifest_dir).await?;\n        fs_err::tokio::remove_dir_all(&target_dir).await?;\n    }\n\n    Ok(())\n}\n\nasync fn install_cli_dependency(\n    cli_dep: &str,\n    info: &InstallInfo,\n    cargo: &Path,\n    cargo_home: &Path,\n    new_path: &OsStr,\n) -> anyhow::Result<()> {\n    let dep = CargoCliDependency::from_str(cli_dep)?;\n\n    let mut cmd = Cmd::new(cargo, \"install cli dep\");\n    cmd.args([\"install\", \"--bins\", \"--root\"])\n        .arg(&info.env_path)\n        .args(dep.to_cargo_args())\n        .arg(\"--locked\");\n\n    cmd.env(EnvVars::PATH, new_path)\n        .env(EnvVars::CARGO_HOME, cargo_home)\n        .remove_git_envs()\n        .check(true)\n        .output()\n        .await?;\n\n    Ok(())\n}\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Rust;\n\nimpl LanguageImpl for Rust {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> anyhow::Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        // 1. Install Rust\n        let cargo_home = store.cache_path(CacheBucket::Cargo);\n        let rustup_dir = store.tools_path(ToolBucket::Rustup);\n        let rustup = Rustup::install(store, &rustup_dir).await?;\n        let installer = RustInstaller::new(rustup);\n\n        let (version, allows_download) = match &hook.language_request {\n            LanguageRequest::Any { system_only } => (&RustRequest::Any, !system_only),\n            LanguageRequest::Rust(version) => (version, true),\n            _ => unreachable!(),\n        };\n\n        let rust = installer\n            .install(version, allows_download)\n            .await\n            .context(\"Failed to install rust\")?;\n        let rustc_bin = bin_dir(rust.toolchain());\n        let cargo = rustc_bin.join(\"cargo\").with_extension(EXE_EXTENSION);\n        // Add toolchain bin to PATH, for cargo to use correct rustc\n        let new_path = prepend_paths(&[&rustc_bin]).context(\"Failed to join PATH\")?;\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n        info.with_toolchain(rust.toolchain().to_path_buf())\n            .with_language_version(rust.version().deref().clone());\n\n        // Store the channel name for cache matching\n        match version {\n            RustRequest::Channel(channel) => {\n                info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string());\n            }\n            RustRequest::Any => {\n                // Any resolves to \"stable\" in resolve_version\n                info.with_extra(EXTRA_KEY_CHANNEL, \"stable\");\n            }\n            _ => {}\n        }\n\n        // 2. Create environment\n        fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?;\n\n        // 3. Install dependencies\n        // Split dependencies by cli: prefix\n        let (cli_deps, lib_deps): (Vec<_>, Vec<_>) =\n            hook.additional_dependencies.iter().partition_map(|dep| {\n                if let Some(stripped) = dep.strip_prefix(\"cli:\") {\n                    Either::Left(stripped)\n                } else {\n                    Either::Right(dep)\n                }\n            });\n\n        // 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.\n        let hook_entry = hook.entry.split()?;\n        let hook_bin = &hook_entry[0];\n\n        // Install library dependencies and local project\n        if let Some(repo) = hook.repo_path() {\n            install_local_project(\n                hook_bin,\n                repo,\n                &info,\n                &lib_deps,\n                &cargo,\n                &cargo_home,\n                &new_path,\n            )\n            .await?;\n        }\n\n        // Install CLI dependencies\n        for cli_dep in cli_deps {\n            install_cli_dependency(cli_dep, &info, &cargo, &cargo_home, &new_path).await?;\n        }\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        store: &Store,\n        reporter: &HookRunReporter,\n    ) -> anyhow::Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let env_dir = hook.env_path().expect(\"Rust hook must have env path\");\n        let info = hook.install_info().expect(\"Rust hook must be installed\");\n\n        let rust_bin = bin_dir(env_dir);\n        let cargo_home = store.cache_path(CacheBucket::Cargo);\n        let rustc_bin = bin_dir(&info.toolchain);\n\n        let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context(\"Failed to join PATH\")?;\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"rust hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .env(EnvVars::CARGO_HOME, &cargo_home)\n                .env(EnvVars::RUSTUP_AUTO_INSTALL, \"0\")\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\npub(crate) fn bin_dir(env_path: &Path) -> PathBuf {\n    env_path.join(\"bin\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::TempDir;\n\n    async fn write_file(path: &Path, content: &str) {\n        if let Some(parent) = path.parent() {\n            fs_err::tokio::create_dir_all(parent).await.unwrap();\n        }\n        fs_err::tokio::write(path, content).await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_single_package() {\n        let temp = TempDir::new().unwrap();\n        let cargo_toml = r#\"\n[package]\nname = \"my-tool\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n        write_file(&temp.path().join(\"src/main.rs\"), \"fn main() {}\").await;\n\n        let (path, pkg_name, is_workspace) =\n            find_package_dir(temp.path(), \"my-tool\", None, None, None)\n                .await\n                .unwrap()\n                .unwrap();\n        assert_eq!(path, temp.path());\n        assert_eq!(pkg_name, \"my-tool\");\n        assert!(!is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_single_package_underscore_normalization() {\n        let temp = TempDir::new().unwrap();\n        let cargo_toml = r#\"\n[package]\nname = \"my-tool\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n        write_file(&temp.path().join(\"src/main.rs\"), \"fn main() {}\").await;\n\n        // Should match with underscores instead of hyphens\n        let (path, _pkg, is_workspace) = find_package_dir(temp.path(), \"my_tool\", None, None, None)\n            .await\n            .unwrap()\n            .unwrap();\n        assert_eq!(path, temp.path());\n        assert!(!is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_workspace_with_root_package() {\n        let temp = TempDir::new().unwrap();\n        let cargo_toml = r#\"\n[package]\nname = \"cargo-deny\"\nversion = \"0.18.5\"\nedition = \"2021\"\n\n[workspace]\nmembers = [\"subcrate\"]\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n        write_file(&temp.path().join(\"src/main.rs\"), \"fn main() {}\").await;\n\n        // Create subcrate with a lib.rs\n        let subcrate_toml = r#\"\n[package]\nname = \"subcrate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"subcrate/Cargo.toml\"), subcrate_toml).await;\n        write_file(&temp.path().join(\"subcrate/src/lib.rs\"), \"\").await;\n\n        let (path, pkg_name, is_workspace) =\n            find_package_dir(temp.path(), \"cargo-deny\", None, None, None)\n                .await\n                .unwrap()\n                .unwrap();\n        assert_eq!(path, temp.path());\n        assert_eq!(pkg_name, \"cargo-deny\");\n        assert!(is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_workspace_member() {\n        let temp = TempDir::new().unwrap();\n        let cargo_toml = r#\"\n[workspace]\nmembers = [\"cli\", \"lib\"]\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n\n        let cli_toml = r#\"\n[package]\nname = \"my-cli\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"cli/Cargo.toml\"), cli_toml).await;\n        write_file(&temp.path().join(\"cli/src/main.rs\"), \"fn main() {}\").await;\n\n        let lib_toml = r#\"\n[package]\nname = \"my-lib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"lib/Cargo.toml\"), lib_toml).await;\n        write_file(&temp.path().join(\"lib/src/lib.rs\"), \"\").await;\n\n        let (path, pkg_name, is_workspace) =\n            find_package_dir(temp.path(), \"my-cli\", None, None, None)\n                .await\n                .unwrap()\n                .unwrap();\n        assert_eq!(path, temp.path().join(\"cli\"));\n        assert_eq!(pkg_name, \"my-cli\");\n        assert!(is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_by_bin_name() {\n        let temp = TempDir::new().unwrap();\n\n        let cargo_toml = r#\"\n[workspace]\nmembers = [\"crates/typos-cli\"]\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n\n        // Package is typos-cli but binary is typos\n        let cli_toml = r#\"\n[package]\nname = \"typos-cli\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"typos\"\npath = \"src/main.rs\"\n\"#;\n        write_file(&temp.path().join(\"crates/typos-cli/Cargo.toml\"), cli_toml).await;\n        write_file(\n            &temp.path().join(\"crates/typos-cli/src/main.rs\"),\n            \"fn main() {}\",\n        )\n        .await;\n\n        // Should find by binary name, return package name\n        let (path, pkg_name, is_workspace) =\n            find_package_dir(temp.path(), \"typos\", None, None, None)\n                .await\n                .unwrap()\n                .unwrap();\n        assert_eq!(path, temp.path().join(\"crates/typos-cli\"));\n        assert_eq!(pkg_name, \"typos-cli\");\n        assert!(is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_by_src_bin_file() {\n        let temp = TempDir::new().unwrap();\n\n        let cargo_toml = r#\"\n[package]\nname = \"my-pkg\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n        write_file(&temp.path().join(\"src/bin/my-tool.rs\"), \"fn main() {}\").await;\n        // Need a lib.rs or main.rs for the package itself\n        write_file(&temp.path().join(\"src/lib.rs\"), \"\").await;\n\n        let (path, _pkg, is_workspace) = find_package_dir(temp.path(), \"my-tool\", None, None, None)\n            .await\n            .unwrap()\n            .unwrap();\n        assert_eq!(path, temp.path());\n        assert!(!is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_virtual_workspace_nested_member() {\n        let temp = TempDir::new().unwrap();\n\n        let cargo_toml = r#\"\n[workspace]\nmembers = [\"crates/cli\"]\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n\n        let cli_toml = r#\"\n[package]\nname = \"virtual-cli\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"crates/cli/Cargo.toml\"), cli_toml).await;\n        write_file(&temp.path().join(\"crates/cli/src/main.rs\"), \"fn main() {}\").await;\n\n        let (path, pkg_name, is_workspace) =\n            find_package_dir(temp.path(), \"virtual-cli\", None, None, None)\n                .await\n                .unwrap()\n                .unwrap();\n        assert_eq!(path, temp.path().join(\"crates/cli\"));\n        assert_eq!(pkg_name, \"virtual-cli\");\n        assert!(is_workspace);\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_virtual_workspace_glob_members() {\n        let temp = TempDir::new().unwrap();\n\n        let cargo_toml = r#\"\n[workspace]\nmembers = [\"crates/*\"]\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n\n        let cli_toml = r#\"\n[package]\nname = \"my-cli\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"crates/cli/Cargo.toml\"), cli_toml).await;\n        write_file(&temp.path().join(\"crates/cli/src/main.rs\"), \"fn main() {}\").await;\n\n        let lib_toml = r#\"\n[package]\nname = \"my-lib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"crates/lib/Cargo.toml\"), lib_toml).await;\n        write_file(&temp.path().join(\"crates/lib/src/lib.rs\"), \"\").await;\n\n        let (path, pkg_name, is_workspace) =\n            find_package_dir(temp.path(), \"my-cli\", None, None, None)\n                .await\n                .unwrap()\n                .unwrap();\n        assert_eq!(path, temp.path().join(\"crates/cli\"));\n        assert_eq!(pkg_name, \"my-cli\");\n        assert!(is_workspace);\n\n        // my-lib is a library (no binary), so searching for it should fail\n        let result = find_package_dir(temp.path(), \"my-lib\", None, None, None)\n            .await\n            .unwrap();\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_no_cargo_toml() {\n        let temp = TempDir::new().unwrap();\n\n        let result = find_package_dir(temp.path(), \"anything\", None, None, None).await;\n        assert!(result.is_err());\n        // cargo metadata gives a different error message\n        assert!(result.unwrap_err().to_string().contains(\"cargo metadata\"));\n    }\n\n    #[tokio::test]\n    async fn test_find_package_dir_workspace_binary_not_found() {\n        let temp = TempDir::new().unwrap();\n        let cargo_toml = r#\"\n[workspace]\nmembers = [\"cli\"]\n\"#;\n        write_file(&temp.path().join(\"Cargo.toml\"), cargo_toml).await;\n\n        let cli_toml = r#\"\n[package]\nname = \"some-other-tool\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\"#;\n        write_file(&temp.path().join(\"cli/Cargo.toml\"), cli_toml).await;\n        write_file(&temp.path().join(\"cli/src/main.rs\"), \"fn main() {}\").await;\n\n        let result = find_package_dir(temp.path(), \"nonexistent-binary\", None, None, None)\n            .await\n            .unwrap();\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_format_cargo_dependency() {\n        assert_eq!(format_cargo_dependency(\"serde\"), \"serde@*\");\n        assert_eq!(format_cargo_dependency(\"serde:1.0\"), \"serde@1.0\");\n        assert_eq!(format_cargo_dependency(\"tokio:1.0.0\"), \"tokio@1.0.0\");\n    }\n\n    #[test]\n    fn test_parse_cargo_cli_dependency_crate_forms() {\n        assert_eq!(\n            CargoCliDependency::from_str(\"typos-cli\").unwrap(),\n            CargoCliDependency::Crate {\n                name: \"typos-cli\".to_string(),\n                version: None,\n            }\n        );\n        assert_eq!(\n            CargoCliDependency::from_str(\"typos-cli:1.0\").unwrap(),\n            CargoCliDependency::Crate {\n                name: \"typos-cli\".to_string(),\n                version: Some(\"1.0\".to_string())\n            }\n        );\n    }\n\n    #[test]\n    fn test_parse_cargo_cli_dependency_git_valid_forms() {\n        let cases = [\n            (\n                \"https://github.com/fish-shell/fish-shell\",\n                CargoCliDependency::Git {\n                    url: \"https://github.com/fish-shell/fish-shell\".to_string(),\n                    tag: None,\n                    package: None,\n                },\n            ),\n            (\n                \"https://github.com/fish-shell/fish-shell:v4.5.0\",\n                CargoCliDependency::Git {\n                    url: \"https://github.com/fish-shell/fish-shell\".to_string(),\n                    tag: Some(\"v4.5.0\".to_string()),\n                    package: None,\n                },\n            ),\n            (\n                \"https://github.com/fish-shell/fish-shell::fish\",\n                CargoCliDependency::Git {\n                    url: \"https://github.com/fish-shell/fish-shell\".to_string(),\n                    tag: None,\n                    package: Some(\"fish\".to_string()),\n                },\n            ),\n            (\n                \"https://github.com/fish-shell/fish-shell:v4.5.0:fish\",\n                CargoCliDependency::Git {\n                    url: \"https://github.com/fish-shell/fish-shell\".to_string(),\n                    tag: Some(\"v4.5.0\".to_string()),\n                    package: Some(\"fish\".to_string()),\n                },\n            ),\n        ];\n\n        for (input, expected) in cases {\n            assert_eq!(CargoCliDependency::from_str(input).unwrap(), expected);\n        }\n    }\n\n    #[test]\n    fn test_parse_cargo_cli_dependency_git_invalid_forms() {\n        let invalid_cases = [\n            \"https://github.com/fish-shell/fish-shell:\",\n            \"https://github.com/fish-shell/fish-shell:v4.5.0:\",\n            \"https://github.com/fish-shell/fish-shell::\",\n        ];\n\n        for input in invalid_cases {\n            assert!(\n                CargoCliDependency::from_str(input).is_err(),\n                \"input: {input}\"\n            );\n        }\n    }\n\n    #[test]\n    fn test_format_cargo_cli_dependency() {\n        let cases = [\n            (\"typos-cli\", vec![\"typos-cli\"]),\n            (\"typos-cli:1.0\", vec![\"typos-cli\", \"--version\", \"1.0\"]),\n            (\n                \"https://github.com/fish-shell/fish-shell\",\n                vec![\"--git\", \"https://github.com/fish-shell/fish-shell\"],\n            ),\n            (\n                \"https://github.com/fish-shell/fish-shell:v4.5.0\",\n                vec![\n                    \"--git\",\n                    \"https://github.com/fish-shell/fish-shell\",\n                    \"--tag\",\n                    \"v4.5.0\",\n                ],\n            ),\n            (\n                \"https://github.com/fish-shell/fish-shell::fish\",\n                vec![\"--git\", \"https://github.com/fish-shell/fish-shell\", \"fish\"],\n            ),\n            (\n                \"https://github.com/fish-shell/fish-shell:v4.5.0:fish\",\n                vec![\n                    \"--git\",\n                    \"https://github.com/fish-shell/fish-shell\",\n                    \"--tag\",\n                    \"v4.5.0\",\n                    \"fish\",\n                ],\n            ),\n        ];\n\n        for (input, expected) in cases {\n            let dep = CargoCliDependency::from_str(input).unwrap();\n            assert_eq!(dep.to_cargo_args(), expected, \"input: {input}\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/rust/rustup.rs",
    "content": "use std::env::consts::EXE_EXTENSION;\nuse std::path::{Path, PathBuf};\nuse std::sync::LazyLock;\n\nuse anyhow::{Context, Result};\nuse futures::StreamExt;\nuse prek_consts::env_vars::EnvVars;\nuse semver::Version;\nuse target_lexicon::HOST;\nuse tracing::{debug, trace, warn};\n\nuse crate::fs::LockedFile;\nuse crate::http::REQWEST_CLIENT;\nuse crate::languages::rust::version::RustVersion;\nuse crate::process::Cmd;\nuse crate::store::Store;\n\n#[derive(Clone)]\npub(crate) struct Rustup {\n    bin: PathBuf,\n    rustup_home: PathBuf,\n}\n\npub(crate) struct ToolchainInfo {\n    pub(crate) name: String,\n    pub(crate) path: PathBuf,\n    pub(crate) version: RustVersion,\n}\n\nstatic RUSTUP_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {\n    EnvVars::var(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME)\n        .unwrap_or_else(|_| \"rustup\".to_string())\n});\n\nimpl Rustup {\n    pub(crate) fn rustup_home(&self) -> &Path {\n        &self.rustup_home\n    }\n\n    /// Install rustup if not already installed.\n    pub(crate) async fn install(store: &Store, rustup_home: &Path) -> Result<Self> {\n        // 1) Check system installed `rustup`\n        if let Ok(rustup_path) = which::which(&*RUSTUP_BINARY_NAME) {\n            trace!(\"Using system installed rustup at {}\", rustup_path.display());\n            return Ok(Self {\n                bin: rustup_path,\n                rustup_home: rustup_home.to_path_buf(),\n            });\n        }\n\n        // 2) Check if already installed in store\n        let rustup_path = rustup_home.join(\"rustup\").with_extension(EXE_EXTENSION);\n\n        if rustup_path.is_file() {\n            trace!(\"Using managed rustup at {}\", rustup_path.display());\n            return Ok(Self {\n                bin: rustup_path,\n                rustup_home: rustup_home.to_path_buf(),\n            });\n        }\n\n        // 3) Install rustup\n        fs_err::tokio::create_dir_all(&rustup_home).await?;\n        let _lock = LockedFile::acquire(rustup_home.join(\".lock\"), \"rustup\").await?;\n\n        if rustup_path.is_file() {\n            trace!(\"Using managed rustup at {}\", rustup_path.display());\n            return Ok(Self {\n                bin: rustup_path,\n                rustup_home: rustup_home.to_path_buf(),\n            });\n        }\n\n        Self::download(store, rustup_home)\n            .await\n            .context(\"Failed to install rustup\")\n    }\n\n    async fn download(store: &Store, rustup_home: &Path) -> Result<Self> {\n        let triple = HOST.to_string();\n        let filename = if cfg!(windows) {\n            \"rustup-init.exe\"\n        } else {\n            \"rustup-init\"\n        };\n        let url = format!(\"https://static.rust-lang.org/rustup/dist/{triple}/{filename}\");\n        // Save \"rustup-init\" as \"rustup\", this is what \"rustup-init\" does when setting up.\n        let target = rustup_home.join(\"rustup\").with_extension(EXE_EXTENSION);\n\n        let temp_dir = tempfile::tempdir_in(store.scratch_path())?;\n        debug!(url = %url, temp_dir = ?temp_dir.path(), \"Downloading\");\n\n        let tmp_target = temp_dir.path().join(filename);\n        let response = REQWEST_CLIENT\n            .get(&url)\n            .send()\n            .await\n            .with_context(|| format!(\"Failed to download file from {url}\"))?;\n        if !response.status().is_success() {\n            anyhow::bail!(\n                \"Failed to download file from {}: {}\",\n                url,\n                response.status()\n            );\n        }\n\n        let bytes = response.bytes().await?;\n        fs_err::tokio::write(&tmp_target, bytes).await?;\n\n        make_executable(&tmp_target)?;\n\n        // Move to final location\n        if target.exists() {\n            debug!(path = %target.display(), \"Removing existing rustup\");\n            fs_err::tokio::remove_file(&target).await?;\n        }\n        debug!(path = %target.display(), \"Installing rustup\");\n        fs_err::tokio::rename(&tmp_target, &target).await?;\n\n        Ok(Self {\n            bin: target,\n            rustup_home: rustup_home.to_path_buf(),\n        })\n    }\n\n    pub(crate) async fn install_toolchain(&self, toolchain: &str) -> Result<PathBuf> {\n        let output = Cmd::new(&self.bin, \"rustup toolchain install\")\n            .env(EnvVars::RUSTUP_HOME, &self.rustup_home)\n            .env(EnvVars::RUSTUP_AUTO_INSTALL, \"0\")\n            .arg(\"toolchain\")\n            .arg(\"install\")\n            .arg(\"--no-self-update\")\n            .arg(\"--profile\")\n            .arg(\"minimal\")\n            .arg(toolchain)\n            .check(true)\n            .output()\n            .await\n            .with_context(|| format!(\"Failed to install rust toolchain {toolchain}\"))?;\n\n        // Parse installed toolchain name from output\n        let stdout = String::from_utf8_lossy(&output.stdout);\n        let installed_name = stdout\n            .lines()\n            .find_map(|line| {\n                let line = line.trim();\n                let (name, _) = line.split_once(\" installed\")?;\n                let name = name.trim();\n                if name.is_empty() {\n                    None\n                } else {\n                    Some(name.to_string())\n                }\n            })\n            .with_context(|| {\n                format!(\n                    \"Unable to detect installed toolchain name from rustup output for `{toolchain}`\"\n                )\n            })?;\n\n        Ok(self.rustup_home.join(\"toolchains\").join(installed_name))\n    }\n\n    /// List installed toolchains managed by prek.\n    pub(crate) async fn list_installed_toolchains(&self) -> Result<Vec<ToolchainInfo>> {\n        let output = Cmd::new(&self.bin, \"rustup list toolchains\")\n            .arg(\"toolchain\")\n            .arg(\"list\")\n            .arg(\"-v\")\n            .env(EnvVars::RUSTUP_HOME, &self.rustup_home)\n            .env(EnvVars::RUSTUP_AUTO_INSTALL, \"0\")\n            .check(true)\n            .output()\n            .await\n            .context(\"Failed to list installed toolchains\")?;\n\n        let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)?\n            .lines()\n            .filter_map(parse_toolchain_line)\n            .collect();\n\n        let infos: Vec<ToolchainInfo> = futures::stream::iter(entries)\n            .map(async move |(name, path)| toolchain_info(name, path).await)\n            .buffer_unordered(8)\n            .filter_map(async move |result| match result {\n                Ok(info) => Some(info),\n                Err(e) => {\n                    warn!(\"Skipping invalid toolchain: {e:#}\");\n                    None\n                }\n            })\n            .collect()\n            .await;\n\n        Ok(infos)\n    }\n\n    /// List system-installed Rust toolchains.\n    pub(crate) async fn list_system_toolchains(&self) -> Result<Vec<ToolchainInfo>> {\n        let output = Cmd::new(&self.bin, \"rustup toolchain list\")\n            .arg(\"toolchain\")\n            .arg(\"list\")\n            .arg(\"-v\")\n            .env(EnvVars::RUSTUP_AUTO_INSTALL, \"0\")\n            .check(true)\n            .output()\n            .await\n            .context(\"Failed to list system toolchains\")?;\n\n        let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)?\n            .lines()\n            .filter_map(parse_toolchain_line)\n            .collect();\n\n        let infos: Vec<ToolchainInfo> = futures::stream::iter(entries)\n            .map(async move |(name, path)| toolchain_info(name, path).await)\n            .buffer_unordered(8)\n            .filter_map(async move |result| match result {\n                Ok(info) => Some(info),\n                Err(e) => {\n                    warn!(\"Skipping invalid toolchain: {e:#}\");\n                    None\n                }\n            })\n            .collect()\n            .await;\n\n        Ok(infos)\n    }\n}\n\nfn parse_toolchain_line(line: &str) -> Option<(String, PathBuf)> {\n    // Typical formats:\n    // \"stable-aarch64-apple-darwin (default) /Users/me/.rustup/toolchains/stable-aarch64-apple-darwin\"\n    // \"nightly-x86_64-unknown-linux-gnu /home/me/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu\"\n    let parts: Vec<_> = line.split_whitespace().collect();\n    let name = (*parts.first()?).to_string();\n    let path = parts.last()?;\n    let path = PathBuf::from(path);\n    if path.exists() {\n        Some((name, path))\n    } else {\n        None\n    }\n}\n\nasync fn toolchain_info(name: String, toolchain_dir: PathBuf) -> Result<ToolchainInfo> {\n    let rustc = toolchain_dir\n        .join(\"bin\")\n        .join(\"rustc\")\n        .with_extension(EXE_EXTENSION);\n\n    let output = Cmd::new(&rustc, \"rustc version\")\n        .arg(\"--version\")\n        .check(true)\n        .output()\n        .await\n        .with_context(|| format!(\"Failed to read version from {}\", rustc.display()))?;\n\n    let version_str = str::from_utf8(&output.stdout)?\n        .split_whitespace()\n        .nth(1)\n        .context(\"Failed to parse rustc --version output\")?;\n    let version = Version::parse(version_str)?;\n    let version = RustVersion::from_path(&version, &toolchain_dir);\n\n    Ok(ToolchainInfo {\n        name,\n        path: toolchain_dir,\n        version,\n    })\n}\n\nfn make_executable(path: &Path) -> std::io::Result<()> {\n    #[allow(clippy::unnecessary_wraps)]\n    #[cfg(windows)]\n    fn inner(_: &Path) -> std::io::Result<()> {\n        Ok(())\n    }\n    #[cfg(not(windows))]\n    fn inner(path: &Path) -> std::io::Result<()> {\n        use std::os::unix::fs::PermissionsExt;\n\n        let metadata = fs_err::metadata(path)?;\n        let mut perms = metadata.permissions();\n        let mode = perms.mode();\n        let new_mode = (mode & !0o777) | 0o755;\n\n        // Check if permissions are ok already\n        if mode == new_mode {\n            return Ok(());\n        }\n\n        perms.set_mode(new_mode);\n        fs_err::set_permissions(path, perms)\n    }\n\n    inner(path)\n}\n"
  },
  {
    "path": "crates/prek/src/languages/rust/version.rs",
    "content": "use std::fmt::Display;\nuse std::ops::Deref;\nuse std::path::Path;\nuse std::str::FromStr;\n\nuse crate::hook::InstallInfo;\nuse crate::languages::version::{Error, try_into_u64_slice};\n\n#[derive(Debug, Copy, Clone, Eq, PartialEq)]\npub(crate) enum Channel {\n    Stable,\n    Beta,\n    Nightly,\n}\n\nimpl FromStr for Channel {\n    type Err = ();\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s {\n            \"stable\" => Ok(Channel::Stable),\n            \"beta\" => Ok(Channel::Beta),\n            \"nightly\" => Ok(Channel::Nightly),\n            _ => Err(()),\n        }\n    }\n}\n\nimpl Display for Channel {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let channel_str = match self {\n            Channel::Stable => \"stable\",\n            Channel::Beta => \"beta\",\n            Channel::Nightly => \"nightly\",\n        };\n        write!(f, \"{channel_str}\")\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct RustVersion {\n    version: semver::Version,\n    channel: Option<Channel>,\n}\n\nimpl Default for RustVersion {\n    fn default() -> Self {\n        Self {\n            version: semver::Version::new(0, 0, 0),\n            channel: None,\n        }\n    }\n}\n\nimpl Deref for RustVersion {\n    type Target = semver::Version;\n\n    fn deref(&self) -> &Self::Target {\n        &self.version\n    }\n}\n\nimpl RustVersion {\n    pub(crate) fn from_version(version: &semver::Version) -> Self {\n        Self {\n            version: version.clone(),\n            channel: None,\n        }\n    }\n\n    pub(crate) fn from_channel(channel: Channel) -> Self {\n        Self {\n            version: semver::Version::new(0, 0, 0),\n            channel: Some(channel),\n        }\n    }\n\n    pub(crate) fn from_path(version: &semver::Version, path: &Path) -> Self {\n        let toolchain_str = path\n            .file_name()\n            .and_then(|os_str| os_str.to_str())\n            .unwrap_or_default();\n        let path = toolchain_str.to_lowercase();\n        let channel = if path.starts_with(\"nightly\") {\n            Some(Channel::Nightly)\n        } else if path.starts_with(\"beta\") {\n            Some(Channel::Beta)\n        } else if path.starts_with(\"stable\") {\n            Some(Channel::Stable)\n        } else {\n            None\n        };\n        Self {\n            version: version.clone(),\n            channel,\n        }\n    }\n\n    pub(crate) fn to_toolchain_name(&self) -> String {\n        if let Some(channel) = &self.channel {\n            channel.to_string()\n        } else {\n            format!(\n                \"{}.{}.{}\",\n                self.version.major, self.version.minor, self.version.patch\n            )\n        }\n    }\n}\n\n/// `language_version` field of rust can be one of the following:\n/// `default`\n/// `system`\n/// `stable`\n/// `nightly`\n/// `beta`\n/// `1.70` or `1.70.0`\n/// `>= 1.70, < 1.72`\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum RustRequest {\n    Any,\n    Channel(Channel),\n    Major(u64),\n    MajorMinor(u64, u64),\n    MajorMinorPatch(u64, u64, u64),\n    Range(semver::VersionReq, String),\n}\n\nimpl FromStr for RustRequest {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        if s.is_empty() {\n            return Ok(RustRequest::Any);\n        }\n\n        // Check for channel names\n        if let Ok(channel) = Channel::from_str(s) {\n            return Ok(RustRequest::Channel(channel));\n        }\n\n        // Try parsing as version numbers\n        Self::parse_version_numbers(s, s).or_else(|_| {\n            semver::VersionReq::parse(s)\n                .map(|version_req| RustRequest::Range(version_req, s.into()))\n                .map_err(|_| Error::InvalidVersion(s.to_string()))\n        })\n    }\n}\n\nimpl Display for RustRequest {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            RustRequest::Any => write!(f, \"any\"),\n            RustRequest::Channel(channel) => write!(f, \"{channel}\"),\n            RustRequest::Major(major) => write!(f, \"{major}\"),\n            RustRequest::MajorMinor(major, minor) => write!(f, \"{major}.{minor}\"),\n            RustRequest::MajorMinorPatch(major, minor, patch) => {\n                write!(f, \"{major}.{minor}.{patch}\")\n            }\n            RustRequest::Range(_, range_str) => write!(f, \"{range_str}\"),\n        }\n    }\n}\n\npub(crate) const EXTRA_KEY_CHANNEL: &str = \"channel\";\n\nimpl RustRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        matches!(self, RustRequest::Any)\n    }\n\n    fn parse_version_numbers(\n        version_str: &str,\n        original_request: &str,\n    ) -> Result<RustRequest, Error> {\n        let parts = try_into_u64_slice(version_str)\n            .map_err(|_| Error::InvalidVersion(original_request.to_string()))?;\n\n        match parts.as_slice() {\n            [major] => Ok(RustRequest::Major(*major)),\n            [major, minor] => Ok(RustRequest::MajorMinor(*major, *minor)),\n            [major, minor, patch] => Ok(RustRequest::MajorMinorPatch(*major, *minor, *patch)),\n            _ => Err(Error::InvalidVersion(original_request.to_string())),\n        }\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        match self {\n            RustRequest::Any => {\n                // Any request accepts any valid installation, or specifically \"stable\"\n                install_info\n                    .get_extra(EXTRA_KEY_CHANNEL)\n                    .is_some_and(|ch| ch == \"stable\")\n                    || install_info.language_version.major > 0\n            }\n            RustRequest::Channel(requested_channel) => {\n                let channel = install_info\n                    .get_extra(EXTRA_KEY_CHANNEL)\n                    .and_then(|ch| Channel::from_str(ch).ok());\n                channel.as_ref().is_some_and(|ch| ch == requested_channel)\n            }\n            _ => {\n                let version = &install_info.language_version;\n                self.matches(\n                    &RustVersion::from_version(version),\n                    Some(install_info.toolchain.as_ref()),\n                )\n            }\n        }\n    }\n\n    pub(crate) fn matches(&self, version: &RustVersion, _toolchain: Option<&Path>) -> bool {\n        match self {\n            RustRequest::Any => true,\n            RustRequest::Channel(requested_channel) => version\n                .channel\n                .as_ref()\n                .is_some_and(|ch| ch == requested_channel),\n            RustRequest::Major(major) => version.version.major == *major,\n            RustRequest::MajorMinor(major, minor) => {\n                version.version.major == *major && version.version.minor == *minor\n            }\n            RustRequest::MajorMinorPatch(major, minor, patch) => {\n                version.version.major == *major\n                    && version.version.minor == *minor\n                    && version.version.patch == *patch\n            }\n            RustRequest::Range(req, _) => req.matches(&version.version),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::Language;\n    use crate::hook::InstallInfo;\n    use rustc_hash::FxHashSet;\n    use std::path::PathBuf;\n    use std::str::FromStr;\n\n    #[test]\n    fn test_request_from_str() -> anyhow::Result<()> {\n        assert_eq!(RustRequest::from_str(\"\")?, RustRequest::Any);\n        assert_eq!(\n            RustRequest::from_str(\"stable\")?,\n            RustRequest::Channel(Channel::Stable)\n        );\n        assert_eq!(\n            RustRequest::from_str(\"beta\")?,\n            RustRequest::Channel(Channel::Beta)\n        );\n        assert_eq!(\n            RustRequest::from_str(\"nightly\")?,\n            RustRequest::Channel(Channel::Nightly)\n        );\n        assert_eq!(RustRequest::from_str(\"1\")?, RustRequest::Major(1));\n        assert_eq!(\n            RustRequest::from_str(\"1.70\")?,\n            RustRequest::MajorMinor(1, 70)\n        );\n        assert_eq!(\n            RustRequest::from_str(\"1.70.1\")?,\n            RustRequest::MajorMinorPatch(1, 70, 1)\n        );\n\n        let range_str = \">=1.70, <1.72\";\n        assert_eq!(\n            RustRequest::from_str(range_str)?,\n            RustRequest::Range(semver::VersionReq::parse(range_str)?, range_str.into())\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_invalid_requests() {\n        assert!(RustRequest::from_str(\"unknown-channel\").is_err());\n        assert!(RustRequest::from_str(\"1.2.3.4\").is_err());\n        assert!(RustRequest::from_str(\"1.2.a\").is_err());\n        assert!(RustRequest::from_str(\"/non/existent/path/to/rust\").is_err());\n    }\n\n    #[test]\n    fn test_request_matches() -> anyhow::Result<()> {\n        let version = RustVersion::from_path(\n            &semver::Version::new(1, 71, 0),\n            Path::new(\"/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\"),\n        );\n        let other_version = RustVersion::from_version(&semver::Version::new(1, 72, 1));\n\n        assert!(RustRequest::Any.matches(&version, None));\n        assert!(RustRequest::Channel(Channel::Stable).matches(&version, None));\n        assert!(!RustRequest::Channel(Channel::Stable).matches(&other_version, None));\n        assert!(RustRequest::Major(1).matches(&version, None));\n        assert!(!RustRequest::Major(2).matches(&version, None));\n        assert!(RustRequest::MajorMinor(1, 71).matches(&version, None));\n        assert!(!RustRequest::MajorMinor(1, 72).matches(&version, None));\n        assert!(RustRequest::MajorMinorPatch(1, 71, 0).matches(&version, None));\n        assert!(!RustRequest::MajorMinorPatch(1, 71, 1).matches(&version, None));\n\n        let req = semver::VersionReq::parse(\">=1.70, <1.72\")?;\n        assert!(RustRequest::Range(req.clone(), \">=1.70, <1.72\".into()).matches(&version, None));\n        assert!(!RustRequest::Range(req, \">=1.70, <1.72\".into()).matches(&other_version, None));\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_request_satisfied_by_install_info() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let toolchain_path = temp_dir.path().join(\"rust-toolchain\");\n        std::fs::write(&toolchain_path, b\"\")?;\n\n        let mut install_info =\n            InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(1, 71, 0))\n            .with_toolchain(toolchain_path.clone());\n\n        assert!(RustRequest::Any.satisfied_by(&install_info));\n        assert!(RustRequest::Major(1).satisfied_by(&install_info));\n        assert!(RustRequest::MajorMinor(1, 71).satisfied_by(&install_info));\n        assert!(RustRequest::MajorMinorPatch(1, 71, 0).satisfied_by(&install_info));\n        assert!(!RustRequest::MajorMinorPatch(1, 71, 1).satisfied_by(&install_info));\n\n        let req = RustRequest::Range(\n            semver::VersionReq::parse(\">=1.70, <1.72\")?,\n            \">=1.70, <1.72\".into(),\n        );\n        assert!(req.satisfied_by(&install_info));\n\n        let req = RustRequest::Range(semver::VersionReq::parse(\">=1.72\")?, \">=1.72\".into());\n        assert!(!req.satisfied_by(&install_info));\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_satisfied_by_channel() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(1, 75, 0))\n            .with_toolchain(PathBuf::from(\"/some/path\"))\n            .with_extra(EXTRA_KEY_CHANNEL, \"stable\");\n\n        // Channel request should match when extra is set\n        assert!(RustRequest::Channel(Channel::Stable).satisfied_by(&install_info));\n        assert!(!RustRequest::Channel(Channel::Nightly).satisfied_by(&install_info));\n        assert!(!RustRequest::Channel(Channel::Beta).satisfied_by(&install_info));\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_satisfied_by_any_with_stable_channel() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(1, 75, 0))\n            .with_toolchain(PathBuf::from(\"/some/path\"))\n            .with_extra(\"rust_channel\", \"stable\");\n\n        // Any request should match stable channel\n        assert!(RustRequest::Any.satisfied_by(&install_info));\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_satisfied_by_any_without_channel() -> anyhow::Result<()> {\n        let temp_dir = tempfile::tempdir()?;\n        let mut install_info =\n            InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?;\n        install_info\n            .with_language_version(semver::Version::new(1, 75, 0))\n            .with_toolchain(PathBuf::from(\"/some/path\"));\n        // No channel set - should still match Any if version > 0\n\n        assert!(RustRequest::Any.satisfied_by(&install_info));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/script.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::Result;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::InstalledHook;\nuse crate::hook::{Hook, InstallInfo};\nuse crate::languages::{LanguageImpl, resolve_command};\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Script;\n\nimpl LanguageImpl for Script {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        _store: &Store,\n        _reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        Ok(InstalledHook::NoNeedInstall(hook))\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        // For `language: script`, the `entry[0]` is a script path.\n        // For remote hooks, the path is relative to the repo root.\n        // For local hooks, the path is relative to the current working directory.\n\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let repo_path = hook.repo_path().unwrap_or(hook.work_dir());\n        let mut split = hook.entry.split()?;\n\n        let cmd = repo_path.join(&split[0]);\n        split[0] = cmd.to_string_lossy().to_string();\n        let entry = resolve_command(split, None);\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"run script command\")\n                .current_dir(hook.work_dir())\n                .envs(&hook.env)\n                .args(&entry[1..])\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/swift.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\nuse semver::Version;\nuse tracing::debug;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct Swift;\n\npub(crate) struct SwiftInfo {\n    pub(crate) version: Version,\n    pub(crate) executable: PathBuf,\n}\n\npub(crate) async fn query_swift_info() -> Result<SwiftInfo> {\n    // Find swift executable\n    let executable = which::which(\"swift\").context(\"Swift not found on PATH\")?;\n\n    // macOS: \"swift-driver version: X.Y.Z Apple Swift version X.Y.Z ...\"\n    // Linux/Windows: \"Swift version X.Y.Z ...\"\n    let stdout = Cmd::new(\"swift\", \"get swift version\")\n        .arg(\"--version\")\n        .check(true)\n        .output()\n        .await?\n        .stdout;\n\n    let output = String::from_utf8_lossy(&stdout);\n    let version = parse_swift_version(&output).context(\"Failed to parse Swift version\")?;\n\n    Ok(SwiftInfo {\n        version,\n        executable,\n    })\n}\n\n/// Normalize version string to semver format (e.g., \"5.10\" -> \"5.10.0\").\n/// Some Swift toolchains report versions without a patch component.\nfn normalize_version(version_str: &str) -> String {\n    // Strip any pre-release suffix (e.g., \"6.0-dev\" -> \"6.0\")\n    let version_str = version_str.split('-').next().unwrap_or(version_str);\n    if version_str.matches('.').count() == 1 {\n        format!(\"{version_str}.0\")\n    } else {\n        version_str.to_string()\n    }\n}\n\nfn parse_swift_version(output: &str) -> Option<Version> {\n    for line in output.lines() {\n        // Try Apple Swift format (macOS) - may appear mid-line\n        if let Some(idx) = line.find(\"Apple Swift version \") {\n            let rest = &line[idx + \"Apple Swift version \".len()..];\n            if let Some(version_str) = rest.split_whitespace().next() {\n                if let Ok(version) = normalize_version(version_str).parse() {\n                    return Some(version);\n                }\n            }\n        }\n        // Try plain Swift format (Linux) - at start of line\n        if let Some(rest) = line.strip_prefix(\"Swift version \") {\n            let version_str = rest.split_whitespace().next()?;\n            return normalize_version(version_str).parse().ok();\n        }\n    }\n    None\n}\n\nfn build_dir(env_path: &Path) -> PathBuf {\n    env_path.join(\".build\")\n}\n\nconst BIN_PATH_KEY: &str = \"swift_bin_path\";\n\nimpl LanguageImpl for Swift {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        store: &Store,\n        reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        let progress = reporter.on_install_start(&hook);\n\n        let mut info = InstallInfo::new(\n            hook.language,\n            hook.env_key_dependencies().clone(),\n            &store.hooks_dir(),\n        )?;\n\n        debug!(%hook, target = %info.env_path.display(), \"Installing Swift environment\");\n\n        // Query swift info\n        let swift_info = query_swift_info()\n            .await\n            .context(\"Failed to query Swift info\")?;\n\n        // Build if repo has Package.swift\n        if let Some(repo_path) = hook.repo_path() {\n            if repo_path.join(\"Package.swift\").exists() {\n                debug!(%hook, \"Building Swift package\");\n                let build_path = build_dir(&info.env_path);\n                Cmd::new(\"swift\", \"swift build\")\n                    .arg(\"build\")\n                    .arg(\"-c\")\n                    .arg(\"release\")\n                    .arg(\"--package-path\")\n                    .arg(repo_path)\n                    .arg(\"--build-path\")\n                    .arg(&build_path)\n                    .check(true)\n                    .output()\n                    .await\n                    .context(\"Failed to build Swift package\")?;\n\n                // Get the actual bin path (includes target triple, e.g., .build/arm64-apple-macosx/release)\n                let bin_path_output = Cmd::new(\"swift\", \"get bin path\")\n                    .arg(\"build\")\n                    .arg(\"-c\")\n                    .arg(\"release\")\n                    .arg(\"--package-path\")\n                    .arg(repo_path)\n                    .arg(\"--build-path\")\n                    .arg(&build_path)\n                    .arg(\"--show-bin-path\")\n                    .check(true)\n                    .output()\n                    .await\n                    .context(\"Failed to get Swift bin path\")?;\n                let bin_path = String::from_utf8_lossy(&bin_path_output.stdout)\n                    .trim()\n                    .to_string();\n                debug!(%hook, %bin_path, \"Swift bin path\");\n                info.with_extra(BIN_PATH_KEY, &bin_path);\n            } else {\n                debug!(%hook, \"No Package.swift found, skipping build\");\n            }\n        }\n\n        info.with_toolchain(swift_info.executable)\n            .with_language_version(swift_info.version);\n\n        info.persist_env_path();\n\n        reporter.on_install_complete(progress);\n\n        Ok(InstalledHook::Installed {\n            hook,\n            info: Arc::new(info),\n        })\n    }\n\n    async fn check_health(&self, info: &InstallInfo) -> Result<()> {\n        // Verify swift still exists at the stored path\n        if !info.toolchain.exists() {\n            anyhow::bail!(\n                \"Swift executable no longer exists at: {}\",\n                info.toolchain.display()\n            );\n        }\n\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        // Get bin path from install info if a package was built\n        let new_path =\n            if let Some(bin_path) = hook.install_info().and_then(|i| i.get_extra(BIN_PATH_KEY)) {\n                prepend_paths(&[Path::new(bin_path)]).context(\"Failed to join PATH\")?\n            } else {\n                EnvVars::var_os(EnvVars::PATH).unwrap_or_default()\n            };\n\n        let entry = hook.entry.resolve(Some(&new_path))?;\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"swift hook\")\n                .current_dir(hook.work_dir())\n                .args(&entry[1..])\n                .env(EnvVars::PATH, &new_path)\n                .envs(&hook.env)\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::parse_swift_version;\n\n    #[test]\n    fn test_parse_macos_format() {\n        // macOS: \"swift-driver version: ... Apple Swift version X.Y.Z ...\"\n        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)\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 1);\n        assert_eq!(version.patch, 2);\n    }\n\n    #[test]\n    fn test_parse_linux_format() {\n        // Linux/Windows: \"Swift version X.Y.Z ...\"\n        let output = \"Swift version 6.1.2 (swift-6.1.2-RELEASE)\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 1);\n        assert_eq!(version.patch, 2);\n    }\n\n    #[test]\n    fn test_parse_multiline_output() {\n        // macOS output includes target on second line\n        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)\nTarget: arm64-apple-macosx15.0\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 1);\n        assert_eq!(version.patch, 2);\n    }\n\n    #[test]\n    fn test_parse_linux_multiline() {\n        // Linux output includes target on second line\n        let output = r\"Swift version 6.1.2 (swift-6.1.2-RELEASE)\nTarget: x86_64-unknown-linux-gnu\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 1);\n        assert_eq!(version.patch, 2);\n    }\n\n    #[test]\n    fn test_parse_invalid_output() {\n        assert!(parse_swift_version(\"\").is_none());\n        assert!(parse_swift_version(\"not a version string\").is_none());\n        assert!(parse_swift_version(\"version 6.1.2\").is_none()); // Missing \"Swift\"\n    }\n\n    #[test]\n    fn test_parse_version_without_patch() {\n        // Some toolchains report versions without a patch number\n        let output = \"swift-driver version: 1.115.0 Apple Swift version 6.1 (swiftlang-6.1.0.0.1 clang-1700.0.13.1)\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 1);\n        assert_eq!(version.patch, 0); // Normalized to .0\n\n        // Linux format without patch\n        let output = \"Swift version 6.1 (swift-6.1-RELEASE)\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 1);\n        assert_eq!(version.patch, 0);\n    }\n\n    #[test]\n    fn test_parse_dev_version() {\n        // Development/nightly versions have -dev suffix\n        let output = \"Swift version 6.2-dev (LLVM abcdef, Swift 123456)\";\n        let version = parse_swift_version(output).unwrap();\n        assert_eq!(version.major, 6);\n        assert_eq!(version.minor, 2);\n        assert_eq!(version.patch, 0);\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/system.rs",
    "content": "use std::path::Path;\nuse std::process::Stdio;\nuse std::sync::Arc;\n\nuse anyhow::Result;\n\nuse crate::cli::reporter::{HookInstallReporter, HookRunReporter};\nuse crate::hook::{Hook, InstallInfo, InstalledHook};\nuse crate::languages::LanguageImpl;\nuse crate::process::Cmd;\nuse crate::run::run_by_batch;\nuse crate::store::Store;\n\n#[derive(Debug, Copy, Clone)]\npub(crate) struct System;\n\nimpl LanguageImpl for System {\n    async fn install(\n        &self,\n        hook: Arc<Hook>,\n        _store: &Store,\n        _reporter: &HookInstallReporter,\n    ) -> Result<InstalledHook> {\n        Ok(InstalledHook::NoNeedInstall(hook))\n    }\n\n    async fn check_health(&self, _info: &InstallInfo) -> Result<()> {\n        Ok(())\n    }\n\n    async fn run(\n        &self,\n        hook: &InstalledHook,\n        filenames: &[&Path],\n        _store: &Store,\n        reporter: &HookRunReporter,\n    ) -> Result<(i32, Vec<u8>)> {\n        let progress = reporter.on_run_start(hook, filenames.len());\n\n        let entry = hook.entry.resolve(None)?;\n\n        let run = async |batch: &[&Path]| {\n            let mut output = Cmd::new(&entry[0], \"run system command\")\n                .current_dir(hook.work_dir())\n                .envs(&hook.env)\n                .args(&entry[1..])\n                .args(&hook.args)\n                .args(batch)\n                .check(false)\n                .stdin(Stdio::null())\n                .pty_output()\n                .await?;\n\n            reporter.on_run_progress(progress, batch.len() as u64);\n\n            output.stdout.extend(output.stderr);\n            let code = output.status.code().unwrap_or(1);\n            anyhow::Ok((code, output.stdout))\n        };\n\n        let results = run_by_batch(hook, filenames, &entry, run).await?;\n\n        reporter.on_run_complete(progress);\n\n        // Collect results\n        let mut combined_status = 0;\n        let mut combined_output = Vec::new();\n\n        for (code, output) in results {\n            combined_status |= code;\n            combined_output.extend(output);\n        }\n\n        Ok((combined_status, combined_output))\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/languages/version.rs",
    "content": "use std::str::FromStr;\n\nuse crate::config::Language;\nuse crate::hook::InstallInfo;\nuse crate::languages::bun::BunRequest;\nuse crate::languages::deno::DenoRequest;\nuse crate::languages::golang::GoRequest;\nuse crate::languages::node::NodeRequest;\nuse crate::languages::python::PythonRequest;\nuse crate::languages::ruby::RubyRequest;\nuse crate::languages::rust::RustRequest;\n\n#[derive(thiserror::Error, Debug)]\npub(crate) enum Error {\n    #[error(\"Invalid `language_version` value: `{0}`\")]\n    InvalidVersion(String),\n}\n\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) enum LanguageRequest {\n    Any { system_only: bool },\n    Bun(BunRequest),\n    Deno(DenoRequest),\n    Golang(GoRequest),\n    Ruby(RubyRequest),\n    Node(NodeRequest),\n    Python(PythonRequest),\n    Rust(RustRequest),\n    // TODO: all other languages default to semver for now.\n    Semver(SemverRequest),\n}\n\nimpl LanguageRequest {\n    pub(crate) fn is_any(&self) -> bool {\n        match self {\n            LanguageRequest::Any { .. } => true,\n            LanguageRequest::Bun(req) => req.is_any(),\n            LanguageRequest::Deno(req) => req.is_any(),\n            LanguageRequest::Golang(req) => req.is_any(),\n            LanguageRequest::Node(req) => req.is_any(),\n            LanguageRequest::Python(req) => req.is_any(),\n            LanguageRequest::Ruby(req) => req.is_any(),\n            LanguageRequest::Rust(req) => req.is_any(),\n            LanguageRequest::Semver(_) => false,\n        }\n    }\n\n    /// Returns true if this request allows downloading a version.\n    ///\n    /// Currently, only `system` disallows downloading. In the future,\n    /// we may add more specific version requests that also disallow downloading.\n    /// For example `language_version: 3.12; system_only`.\n    pub(crate) fn allows_download(&self) -> bool {\n        match self {\n            LanguageRequest::Any { system_only } => !system_only,\n            _ => true,\n        }\n    }\n\n    pub(crate) fn parse(lang: Language, request: &str) -> Result<Self, Error> {\n        // `pre-commit` support these values in `language_version`:\n        // - `default`: substituted by language `get_default_version` function\n        //   In `get_default_version`, if a system version is available, it will return `system`.\n        //   For Python, it will find from sys.executable, `pythonX.Y`, or versions `py` can find.\n        //   Otherwise, it will still return `default`.\n        // - `system`: use current system installed version\n        // - Python version passed down to `virtualenv`, e.g. `python`, `python3`, `python3.8`\n        // - Node.js version passed down to `nodeenv`\n        // - Rust version passed down to `rustup`\n\n        if request == \"default\" || request.is_empty() {\n            return Ok(LanguageRequest::Any { system_only: false });\n        }\n        if request == \"system\" {\n            return Ok(LanguageRequest::Any { system_only: true });\n        }\n\n        Ok(match lang {\n            Language::Bun => Self::Bun(request.parse()?),\n            Language::Deno => Self::Deno(request.parse()?),\n            Language::Golang => Self::Golang(request.parse()?),\n            Language::Node => Self::Node(request.parse()?),\n            Language::Python => Self::Python(request.parse()?),\n            Language::Ruby => Self::Ruby(request.parse()?),\n            Language::Rust => Self::Rust(request.parse()?),\n            _ => Self::Semver(request.parse()?),\n        })\n    }\n\n    pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        match self {\n            LanguageRequest::Any { .. } => true,\n            LanguageRequest::Bun(req) => req.satisfied_by(install_info),\n            LanguageRequest::Deno(req) => req.satisfied_by(install_info),\n            LanguageRequest::Golang(req) => req.satisfied_by(install_info),\n            LanguageRequest::Node(req) => req.satisfied_by(install_info),\n            LanguageRequest::Python(req) => req.satisfied_by(install_info),\n            LanguageRequest::Ruby(req) => req.satisfied_by(install_info),\n            LanguageRequest::Rust(req) => req.satisfied_by(install_info),\n            LanguageRequest::Semver(req) => req.satisfied_by(install_info),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Eq, PartialEq)]\npub(crate) struct SemverRequest(semver::VersionReq);\n\nimpl FromStr for SemverRequest {\n    type Err = Error;\n\n    fn from_str(request: &str) -> Result<Self, Self::Err> {\n        semver::VersionReq::parse(request)\n            .map(SemverRequest)\n            .map_err(|_| Error::InvalidVersion(request.to_string()))\n    }\n}\n\nimpl SemverRequest {\n    fn satisfied_by(&self, install_info: &InstallInfo) -> bool {\n        self.0.matches(&install_info.language_version)\n    }\n}\n\npub(crate) fn try_into_u64_slice(version: &str) -> Result<Vec<u64>, std::num::ParseIntError> {\n    version\n        .split('.')\n        .map(str::parse::<u64>)\n        .collect::<Result<Vec<_>, _>>()\n}\n"
  },
  {
    "path": "crates/prek/src/main.rs",
    "content": "use std::fmt::Write;\nuse std::path::PathBuf;\nuse std::process::ExitCode;\nuse std::str::FromStr;\nuse std::sync::Mutex;\n\nuse anstream::{ColorChoice, StripStream, eprintln};\nuse anyhow::{Context, Result};\nuse clap::{CommandFactory, Parser};\nuse clap_complete::CompleteEnv;\nuse owo_colors::OwoColorize;\nuse prek_consts::env_vars::EnvVars;\nuse tracing::debug;\nuse tracing::level_filters::LevelFilter;\nuse tracing_subscriber::filter::Directive;\nuse tracing_subscriber::fmt::format::FmtSpan;\nuse tracing_subscriber::layer::SubscriberExt;\nuse tracing_subscriber::util::SubscriberInitExt;\nuse tracing_subscriber::{EnvFilter, Layer};\n\nuse crate::cleanup::cleanup;\nuse crate::cli::{\n    CacheCommand, CacheNamespace, Cli, Command, ExitStatus, UtilCommand, UtilNamespace,\n};\n#[cfg(feature = \"self-update\")]\nuse crate::cli::{SelfCommand, SelfNamespace, SelfUpdateArgs};\nuse crate::printer::Printer;\nuse crate::run::USE_COLOR;\nuse crate::store::Store;\n\nmod archive;\nmod cleanup;\nmod cli;\nmod config;\nmod fs;\nmod git;\nmod hook;\nmod hooks;\nmod http;\nmod install_source;\nmod languages;\nmod printer;\nmod process;\n#[cfg(all(unix, feature = \"profiler\"))]\nmod profiler;\n#[cfg(unix)]\nmod resource_limit;\nmod run;\n#[cfg(feature = \"schemars\")]\nmod schema;\nmod store;\nmod version;\nmod warnings;\nmod workspace;\nmod yaml;\n\n#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]\npub(crate) enum Level {\n    /// Suppress all tracing output by default (overridable by `RUST_LOG`).\n    #[default]\n    Default,\n    /// Show verbose messages.\n    Verbose,\n    /// Show debug messages by default (overridable by `RUST_LOG`).\n    Debug,\n    /// Show trace messages by default (overridable by `RUST_LOG`).\n    Trace,\n    /// Show trace messages for all crates by default (overridable by `RUST_LOG`).\n    TraceAll,\n}\n\nenum LogFile {\n    Default,\n    Path(PathBuf),\n    Disabled,\n}\n\nimpl LogFile {\n    fn from_args(log_file: Option<PathBuf>, no_log_file: bool) -> Self {\n        if no_log_file {\n            Self::Disabled\n        } else if let Some(path) = log_file {\n            Self::Path(path)\n        } else {\n            Self::Default\n        }\n    }\n\n    fn is_disabled(&self) -> bool {\n        matches!(self, Self::Disabled)\n    }\n}\n\nfn setup_logging(level: Level, log_file: LogFile, store: &Store) -> Result<()> {\n    let directive = match level {\n        Level::Default | Level::Verbose => LevelFilter::OFF.into(),\n        Level::Debug => Directive::from_str(\"prek=debug\")?,\n        Level::Trace => Directive::from_str(\"prek=trace\")?,\n        Level::TraceAll => Directive::from_str(\"trace\")?,\n    };\n\n    let stderr_filter = EnvFilter::builder()\n        .with_default_directive(directive)\n        .from_env()\n        .context(\"Invalid RUST_LOG directive\")?;\n    let stderr_format = tracing_subscriber::fmt::format()\n        .with_target(false)\n        .with_ansi(*USE_COLOR);\n    let stderr_layer = tracing_subscriber::fmt::layer()\n        .with_span_events(FmtSpan::CLOSE)\n        .event_format(stderr_format)\n        .with_writer(anstream::stderr)\n        .with_filter(stderr_filter);\n\n    let registry = tracing_subscriber::registry().with(stderr_layer);\n\n    if log_file.is_disabled() {\n        registry.init();\n    } else {\n        let log_file_path = match log_file {\n            LogFile::Default => store.log_file(),\n            LogFile::Path(path) => path,\n            LogFile::Disabled => unreachable!(),\n        };\n        let log_file = fs_err::OpenOptions::new()\n            .create(true)\n            .write(true)\n            .truncate(true)\n            .open(log_file_path)\n            .context(\"Failed to open log file\")?;\n        let log_file = Mutex::new(StripStream::new(log_file.into_file()));\n\n        let file_format = tracing_subscriber::fmt::format()\n            .with_target(false)\n            .with_ansi(false);\n        let file_layer = tracing_subscriber::fmt::layer()\n            .with_span_events(FmtSpan::CLOSE)\n            .event_format(file_format)\n            .with_writer(log_file)\n            .with_filter(EnvFilter::new(\"prek=trace\"));\n\n        registry.with(file_layer).init();\n    }\n\n    Ok(())\n}\n\nasync fn run(cli: Cli) -> Result<ExitStatus> {\n    // Enabled ANSI colors on Windows.\n    let _ = anstyle_query::windows::enable_ansi_colors();\n\n    ColorChoice::write_global(cli.globals.color.into());\n\n    let store = Store::from_settings()?;\n    let log_file = LogFile::from_args(cli.globals.log_file.clone(), cli.globals.no_log_file);\n    setup_logging(\n        match cli.globals.verbose {\n            0 => Level::Default,\n            1 => Level::Verbose,\n            2 => Level::Debug,\n            3 => Level::Trace,\n            _ => Level::TraceAll,\n        },\n        log_file,\n        &store,\n    )?;\n\n    let printer = if cli.globals.quiet == 1 {\n        Printer::Quiet\n    } else if cli.globals.quiet > 1 {\n        Printer::Silent\n    } else if cli.globals.verbose > 1 {\n        Printer::Verbose\n    } else if cli.globals.no_progress {\n        Printer::NoProgress\n    } else {\n        Printer::Default\n    };\n\n    if cli.globals.quiet > 0 {\n        warnings::disable();\n    } else {\n        warnings::enable();\n    }\n\n    debug!(\"prek: {}\", version::version());\n\n    #[cfg(unix)]\n    match resource_limit::adjust_open_file_limit() {\n        Ok(_) | Err(resource_limit::OpenFileLimitError::AlreadySufficient { .. }) => {}\n        Err(err) => {\n            tracing::warn!(\"Failed to adjust open file limit: {err}\");\n        }\n    }\n\n    // If `GIT_DIR` is set, prek may be running from a git hook.\n    // Git exports `GIT_DIR` but *not* `GIT_WORK_TREE`. Without `GIT_WORK_TREE`, git\n    // treats the current working directory as the working tree. If prek changes the current\n    // working directory (with `--cd`), git commands run by prek may behave unexpectedly.\n    //\n    // To make git behavior stable, we set `GIT_WORK_TREE` ourselves to where prek is run from.\n    // If `GIT_WORK_TREE` is already set, we leave it alone.\n    // If `GIT_DIR` is not set, we let git discover `.git` after an optional `cd`.\n    // See: https://www.spinics.net/lists/git/msg374197.html\n    //      https://github.com/pre-commit/pre-commit/issues/2295\n    if EnvVars::is_set(EnvVars::GIT_DIR) && !EnvVars::is_set(EnvVars::GIT_WORK_TREE) {\n        let cwd = std::env::current_dir().context(\"Failed to get current directory\")?;\n        debug!(\"Setting {} to `{}`\", EnvVars::GIT_WORK_TREE, cwd.display());\n        unsafe { std::env::set_var(EnvVars::GIT_WORK_TREE, cwd) }\n    }\n\n    if let Some(dir) = cli.globals.cd.as_ref() {\n        debug!(\"Changing current directory to: `{}`\", dir.display());\n        std::env::set_current_dir(dir)?;\n    }\n\n    debug!(\"Args: {:?}\", std::env::args().collect::<Vec<_>>());\n\n    macro_rules! show_settings {\n        ($arg:expr) => {\n            if cli.globals.show_settings {\n                writeln!(printer.stdout(), \"{:#?}\", $arg)?;\n                return Ok(ExitStatus::Success);\n            }\n        };\n        ($arg:expr, false) => {\n            if cli.globals.show_settings {\n                writeln!(printer.stdout(), \"{:#?}\", $arg)?;\n            }\n        };\n    }\n    show_settings!(cli.globals, false);\n\n    let command = cli\n        .command\n        .unwrap_or_else(|| Command::Run(Box::new(cli.run_args)));\n    match command {\n        Command::Install(args) => {\n            show_settings!(args);\n\n            cli::install(\n                &store,\n                cli.globals.config,\n                args.includes,\n                args.skips,\n                args.hook_types,\n                args.prepare_hooks,\n                args.overwrite,\n                args.allow_missing_config,\n                cli.globals.refresh,\n                cli.globals.quiet,\n                cli.globals.verbose,\n                cli.globals.no_progress,\n                printer,\n                args.git_dir.as_deref(),\n            )\n            .await\n        }\n        Command::PrepareHooks(args) => {\n            cli::prepare_hooks(\n                &store,\n                cli.globals.config,\n                args.includes,\n                args.skips,\n                cli.globals.refresh,\n                printer,\n            )\n            .await\n        }\n        Command::Uninstall(args) => {\n            show_settings!(args);\n\n            cli::uninstall(cli.globals.config, args.hook_types, args.all, printer).await\n        }\n        Command::Run(args) => {\n            show_settings!(args);\n\n            cli::run(\n                &store,\n                cli.globals.config,\n                args.includes,\n                args.skips,\n                args.stage,\n                args.from_ref,\n                args.to_ref,\n                args.all_files,\n                args.files,\n                args.directory,\n                args.last_commit,\n                args.show_diff_on_failure,\n                args.fail_fast,\n                args.dry_run,\n                cli.globals.refresh,\n                args.extra,\n                cli.globals.verbose > 0,\n                printer,\n            )\n            .await\n        }\n        Command::List(args) => {\n            show_settings!(args);\n\n            cli::list(\n                &store,\n                cli.globals.config,\n                args.includes,\n                args.skips,\n                args.hook_stage,\n                args.language,\n                args.output_format,\n                cli.globals.refresh,\n                cli.globals.verbose > 0,\n                printer,\n            )\n            .await\n        }\n        Command::HookImpl(args) => {\n            show_settings!(args);\n\n            cli::hook_impl(\n                &store,\n                cli.globals.config,\n                args.includes,\n                args.skips,\n                args.hook_type,\n                args.hook_dir,\n                args.skip_on_missing_config,\n                args.script_version,\n                args.args,\n                printer,\n            )\n            .await\n        }\n        Command::Cache(CacheNamespace {\n            command: cache_command,\n        }) => match cache_command {\n            CacheCommand::Clean => cli::cache_clean(&store, printer),\n            CacheCommand::Dir => {\n                writeln!(\n                    printer.stdout_important(),\n                    \"{}\",\n                    store.path().display().cyan()\n                )?;\n                Ok(ExitStatus::Success)\n            }\n            CacheCommand::GC(args) => {\n                cli::cache_gc(&store, args.dry_run, cli.globals.verbose > 0, printer).await\n            }\n            CacheCommand::Size(cli::SizeArgs { human }) => cli::cache_size(&store, human, printer),\n        },\n        Command::Clean => cli::cache_clean(&store, printer),\n        Command::GC(args) => {\n            cli::cache_gc(&store, args.dry_run, cli.globals.verbose > 0, printer).await\n        }\n        Command::ValidateConfig(args) => {\n            show_settings!(args);\n\n            cli::validate_configs(args.configs, printer)\n        }\n        Command::ValidateManifest(args) => {\n            show_settings!(args);\n\n            cli::validate_manifest(args.manifests, printer)\n        }\n        Command::SampleConfig(args) => cli::sample_config(args.file.into(), args.format, printer),\n        Command::AutoUpdate(args) => {\n            cli::auto_update(\n                &store,\n                cli.globals.config,\n                args.repo,\n                args.bleeding_edge,\n                args.freeze,\n                args.jobs,\n                args.dry_run,\n                args.cooldown_days,\n                printer,\n            )\n            .await\n        }\n        Command::TryRepo(args) => {\n            show_settings!(args);\n\n            cli::try_repo(\n                cli.globals.config,\n                args.repo,\n                args.rev,\n                args.run_args,\n                cli.globals.refresh,\n                cli.globals.verbose > 0,\n                printer,\n            )\n            .await\n        }\n        Command::Util(UtilNamespace { command }) => match command {\n            UtilCommand::Identify(args) => {\n                show_settings!(args);\n\n                cli::identify(&args.paths, args.output_format, printer)\n            }\n            UtilCommand::ListBuiltins(args) => {\n                show_settings!(args);\n\n                cli::list_builtins(args.output_format, cli.globals.verbose > 0, printer)\n            }\n            UtilCommand::InitTemplateDir(args) => {\n                show_settings!(args);\n\n                cli::init_template_dir(\n                    &store,\n                    args.directory,\n                    cli.globals.config,\n                    args.hook_types,\n                    args.no_allow_missing_config,\n                    cli.globals.refresh,\n                    cli.globals.quiet,\n                    cli.globals.verbose,\n                    cli.globals.no_progress,\n                    printer,\n                )\n                .await\n            }\n            UtilCommand::YamlToToml(args) => {\n                show_settings!(args);\n\n                cli::yaml_to_toml(args.input, args.output, args.force, printer)\n            }\n            UtilCommand::GenerateShellCompletion(args) => {\n                show_settings!(args);\n\n                let mut command = Cli::command();\n                let bin_name = command\n                    .get_bin_name()\n                    .unwrap_or_else(|| command.get_name())\n                    .to_owned();\n                clap_complete::generate(args.shell, &mut command, bin_name, &mut std::io::stdout());\n                Ok(ExitStatus::Success)\n            }\n        },\n        #[cfg(feature = \"self-update\")]\n        Command::Self_(SelfNamespace {\n            command:\n                SelfCommand::Update(SelfUpdateArgs {\n                    target_version,\n                    token,\n                }),\n        }) => cli::self_update(target_version, token, printer).await,\n        #[cfg(not(feature = \"self-update\"))]\n        Command::Self_(_) => {\n            use crate::install_source::InstallSource;\n\n            let msg = InstallSource::detect()\n                .map(|s| {\n                    format!(\n                        \"prek was installed via {} and cannot self-update. To update, run `{}`\",\n                        s.description(),\n                        s.update_instructions()\n                    )\n                })\n                .unwrap_or_else(|| {\n                    \"prek was installed via an external package manager and cannot self-update. \\\n                     Please use your package manager to update prek.\"\n                        .into()\n                });\n\n            anyhow::bail!(\"{msg}\");\n        }\n        Command::InitTemplateDir(args) => {\n            show_settings!(args);\n\n            cli::init_template_dir(\n                &store,\n                args.directory,\n                cli.globals.config,\n                args.hook_types,\n                args.no_allow_missing_config,\n                cli.globals.refresh,\n                cli.globals.quiet,\n                cli.globals.verbose,\n                cli.globals.no_progress,\n                printer,\n            )\n            .await\n        }\n    }\n}\n\nfn main() -> ExitCode {\n    CompleteEnv::with_factory(Cli::command).complete();\n\n    ctrlc::set_handler(move || {\n        cleanup();\n\n        #[allow(clippy::exit, clippy::cast_possible_wrap)]\n        std::process::exit(if cfg!(windows) {\n            0xC000_013A_u32 as i32\n        } else {\n            130\n        });\n    })\n    .expect(\"Error setting Ctrl-C handler\");\n\n    let cli = match Cli::try_parse() {\n        Ok(cli) => cli,\n        Err(err) => err.exit(),\n    };\n\n    #[cfg(all(unix, feature = \"profiler\"))]\n    let _profiler_guard = profiler::start_profiling();\n\n    let runtime = tokio::runtime::Builder::new_current_thread()\n        .enable_all()\n        .build()\n        .expect(\"Failed to create tokio runtime\");\n    let result = runtime.block_on(Box::pin(run(cli)));\n    runtime.shutdown_background();\n\n    // Report the profiler if the feature is enabled\n    #[cfg(all(unix, feature = \"profiler\"))]\n    profiler::finish_profiling(_profiler_guard);\n\n    match result {\n        Ok(code) => code.into(),\n        Err(err) => {\n            let mut causes = err.chain();\n            eprintln!(\"{}: {}\", \"error\".red().bold(), causes.next().unwrap());\n            for err in causes {\n                eprintln!(\"  {}: {}\", \"caused by\".red().bold(), err);\n            }\n            ExitStatus::Error.into()\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/printer.rs",
    "content": "// MIT License\n//\n// Copyright (c) 2023 Astral Software Inc.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nuse anstream::{eprint, print};\nuse indicatif::ProgressDrawTarget;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Printer {\n    /// A printer that suppresses all output.\n    Silent,\n    /// A printer that suppresses most output, but preserves \"important\" stdout.\n    Quiet,\n    /// A printer that prints to standard streams (e.g., stdout).\n    Default,\n    /// A printer that prints all output, including debug messages.\n    Verbose,\n    /// A printer that prints to standard streams, excluding all progress outputs\n    NoProgress,\n}\n\nimpl Printer {\n    /// Return the [`ProgressDrawTarget`] for this printer.\n    pub fn target(self) -> ProgressDrawTarget {\n        match self {\n            Self::Silent => ProgressDrawTarget::hidden(),\n            Self::Quiet => ProgressDrawTarget::hidden(),\n            Self::Default => ProgressDrawTarget::stderr(),\n            // Confusingly, hide the progress bar when in verbose mode.\n            // Otherwise, it gets interleaved with debug messages.\n            Self::Verbose => ProgressDrawTarget::hidden(),\n            Self::NoProgress => ProgressDrawTarget::hidden(),\n        }\n    }\n\n    /// Return the [`Stdout`] for this printer.\n    pub(crate) fn stdout_important(self) -> Stdout {\n        match self {\n            Self::Silent => Stdout::Disabled,\n            Self::Quiet => Stdout::Enabled,\n            Self::Default => Stdout::Enabled,\n            Self::Verbose => Stdout::Enabled,\n            Self::NoProgress => Stdout::Enabled,\n        }\n    }\n\n    /// Return the [`Stdout`] for this printer.\n    pub(crate) fn stdout(self) -> Stdout {\n        match self {\n            Self::Silent => Stdout::Disabled,\n            Self::Quiet => Stdout::Disabled,\n            Self::Default => Stdout::Enabled,\n            Self::Verbose => Stdout::Enabled,\n            Self::NoProgress => Stdout::Enabled,\n        }\n    }\n\n    /// Return the [`Stderr`] for this printer.\n    pub(crate) fn stderr(self) -> Stderr {\n        match self {\n            Self::Silent => Stderr::Disabled,\n            Self::Quiet => Stderr::Disabled,\n            Self::Default => Stderr::Enabled,\n            Self::Verbose => Stderr::Enabled,\n            Self::NoProgress => Stderr::Enabled,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Stdout {\n    Enabled,\n    Disabled,\n}\n\nimpl std::fmt::Write for Stdout {\n    fn write_str(&mut self, s: &str) -> std::fmt::Result {\n        match self {\n            Self::Enabled => {\n                #[allow(clippy::print_stdout, clippy::ignored_unit_patterns)]\n                {\n                    print!(\"{s}\");\n                }\n            }\n            Self::Disabled => {}\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Stderr {\n    Enabled,\n    Disabled,\n}\n\nimpl std::fmt::Write for Stderr {\n    fn write_str(&mut self, s: &str) -> std::fmt::Result {\n        match self {\n            Self::Enabled => {\n                #[allow(clippy::print_stderr, clippy::ignored_unit_patterns)]\n                {\n                    eprint!(\"{s}\");\n                }\n            }\n            Self::Disabled => {}\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/process.rs",
    "content": "// Copyright (c) 2023 Axo Developer Co.\n//\n// Permission is hereby granted, free of charge, to any\n// person obtaining a copy of this software and associated\n// documentation files (the \"Software\"), to deal in the\n// Software without restriction, including without\n// limitation the rights to use, copy, modify, merge,\n// publish, distribute, sublicense, and/or sell copies of\n// the Software, and to permit persons to whom the Software\n// is furnished to do so, subject to the following\n// conditions:\n//\n// The above copyright notice and this permission notice\n// shall be included in all copies or substantial portions\n// of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\n// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\n// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\n// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\n// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\n// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\n// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\n// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\n// DEALINGS IN THE SOFTWARE.\n\n/// Adapt [axoprocess] to use [`tokio::process::Process`] instead of [`std::process::Command`].\nuse std::ffi::OsStr;\nuse std::fmt::Display;\nuse std::path::Path;\nuse std::process::Output;\nuse std::process::{CommandArgs, CommandEnvs, ExitStatus, Stdio};\nuse std::sync::LazyLock;\n\nuse owo_colors::OwoColorize;\nuse prek_consts::env_vars::EnvVars;\nuse thiserror::Error;\nuse tracing::trace;\n\nuse crate::git::GIT;\n\nstatic LOG_TRUNCATE_LIMIT: LazyLock<usize> = LazyLock::new(|| {\n    EnvVars::var(EnvVars::PREK_LOG_TRUNCATE_LIMIT)\n        .ok()\n        .and_then(|limit| limit.parse::<usize>().ok())\n        .filter(|limit| *limit > 0)\n        .unwrap_or(120)\n});\n\n/// An error from executing a Command\n#[derive(Debug, Error)]\npub enum Error {\n    /// The command fundamentally failed to execute (usually means it didn't exist)\n    #[error(\"Run command `{summary}` failed\")]\n    Exec {\n        /// Summary of what the Command was trying to do\n        summary: String,\n        /// What failed\n        #[source]\n        cause: std::io::Error,\n    },\n    #[error(\"Command `{summary}` exited with an error:\\n{error}\")]\n    Status { summary: String, error: StatusError },\n    #[cfg(not(windows))]\n    #[error(\"Failed to open pty\")]\n    Pty(#[from] prek_pty::Error),\n    #[error(\"Failed to setup subprocess for pty\")]\n    PtySetup(#[from] std::io::Error),\n}\n\n/// The command ran but signaled some kind of error condition\n/// (assuming the exit code is used for that)\n#[derive(Debug)]\npub struct StatusError {\n    pub status: ExitStatus,\n    pub output: Option<Output>,\n}\n\nimpl Display for StatusError {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        writeln!(f, \"\\n{}\\n{}\", \"[status]\".red(), self.status)?;\n\n        if let Some(output) = &self.output {\n            let stdout = String::from_utf8_lossy(&output.stdout);\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            let stdout = stdout\n                .split('\\n')\n                .filter_map(|line| {\n                    let line = line.trim();\n                    if line.is_empty() { None } else { Some(line) }\n                })\n                .collect::<Vec<_>>();\n            let stderr = stderr\n                .split('\\n')\n                .filter_map(|line| {\n                    let line = line.trim();\n                    if line.is_empty() { None } else { Some(line) }\n                })\n                .collect::<Vec<_>>();\n\n            if !stdout.is_empty() {\n                writeln!(f, \"\\n{}\\n{}\", \"[stdout]\".red(), stdout.join(\"\\n\"))?;\n            }\n            if !stderr.is_empty() {\n                writeln!(f, \"\\n{}\\n{}\", \"[stderr]\".red(), stderr.join(\"\\n\"))?;\n            }\n        }\n\n        Ok(())\n    }\n}\n\n/// A fancier Command, see the crate's top-level docs!\npub struct Cmd {\n    /// The inner Command, in case you need to access it\n    pub inner: tokio::process::Command,\n    summary: String,\n    check_status: bool,\n}\n\n/// Constructors\nimpl Cmd {\n    /// Create a new Command with an additional \"summary\" of what this is trying to do\n    pub fn new(command: impl AsRef<OsStr>, summary: impl Into<String>) -> Self {\n        let inner = tokio::process::Command::new(command);\n        Self {\n            summary: summary.into(),\n            inner,\n            check_status: true,\n        }\n    }\n}\n\n/// Builder APIs\nimpl Cmd {\n    /// Pipe stdout into stderr\n    ///\n    /// This is useful for cases where you want your program to livestream\n    /// the output of a command to give your user realtime feedback, but the command\n    /// randomly writes some things to stdout, and you don't want your own stdout tainted.\n    pub fn stdout_to_stderr(&mut self) -> &mut Self {\n        self.inner.stdout(std::io::stderr());\n\n        self\n    }\n\n    /// Set whether `Status::success` should be checked after executions\n    /// (except `spawn`, which doesn't yet have a Status to check).\n    ///\n    /// Defaults to `true`.\n    ///\n    /// If true, an Err will be produced by those execution commands.\n    ///\n    /// Executions which produce status will pass them to [`Cmd::maybe_check_status`][],\n    /// which uses this setting.\n    pub fn check(&mut self, checked: bool) -> &mut Self {\n        self.check_status = checked;\n        self\n    }\n}\n\n/// Execution APIs\nimpl Cmd {\n    /// Equivalent to [`Cmd::status`][],\n    /// but doesn't bother returning the actual status code (because it's captured in the Result)\n    pub async fn run(&mut self) -> Result<(), Error> {\n        self.status().await?;\n        Ok(())\n    }\n\n    /// Equivalent to [`std::process::Command::spawn`][],\n    /// but logged and with the error wrapped.\n    pub fn spawn(&mut self) -> Result<tokio::process::Child, Error> {\n        self.log_command();\n        self.inner.spawn().map_err(|cause| Error::Exec {\n            summary: self.summary.clone(),\n            cause,\n        })\n    }\n\n    /// Equivalent to [`std::process::Command::output`][],\n    /// but logged, with the error wrapped, and status checked (by default)\n    pub async fn output(&mut self) -> Result<Output, Error> {\n        self.log_command();\n        let output = self.inner.output().await.map_err(|cause| Error::Exec {\n            summary: self.summary.clone(),\n            cause,\n        })?;\n        self.maybe_check_output(&output)?;\n        Ok(output)\n    }\n\n    #[cfg(windows)]\n    pub async fn pty_output(&mut self) -> Result<Output, Error> {\n        self.output().await\n    }\n\n    #[cfg(not(windows))]\n    pub async fn pty_output(&mut self) -> Result<Output, Error> {\n        // If color is not used, fallback to piped output.\n        if !*crate::run::USE_COLOR {\n            return self.output().await;\n        }\n\n        self.pty_output_inner().await\n    }\n\n    #[cfg(not(windows))]\n    async fn pty_output_inner(&mut self) -> Result<Output, Error> {\n        use tokio::io::AsyncReadExt;\n\n        let (mut pty, pts) = prek_pty::open()?;\n        let (_, stdout, stderr) = pts.setup_subprocess()?;\n\n        self.inner.stdin(Stdio::null());\n        self.inner.stdout(stdout);\n        self.inner.stderr(stderr);\n\n        // We run some commands under a PTY so they behave like they do in an interactive terminal\n        // (colors, progress bars, etc.). However, this is still a *pseudo*-terminal and it doesn't\n        // necessarily provide a full/accurate terminal environment.\n        //\n        // Some libraries (for example Go's termenv) send OSC/CSI queries and wait for a response\n        // from the terminal. Our PTY doesn't emulate those responses, so they can block on a\n        // timeout if the program insists on probing capabilities.\n        //\n        // Previously, we tried to work around this by setting `TERM=dumb` in the environment,\n        // but that caused other issues (for example, some programs (e.g cargo), disable color entirely when they see `TERM=dumb`,\n        // even if the output is actually a terminal that supports color).\n        //\n        // We intentionally do not make the child a session leader/foreground process group here.\n        // When we did, termenv detected it as foreground and ran OSC probes, which then hung.\n\n        let mut child = self.spawn()?;\n\n        let mut stdout = Vec::new();\n        let mut buffer = [0u8; 4096];\n\n        let status = loop {\n            tokio::select! {\n                read_result = pty.read(&mut buffer) => {\n                    match read_result {\n                        Ok(0) => {\n                            // EOF from PTY, child should be done\n                            break child.wait().await?;\n                        }\n                        Ok(n) => {\n                            stdout.extend_from_slice(&buffer[..n]);\n                        }\n                        Err(e) => {\n                            // PTY error, try to get child status\n                            if let Ok(Some(status)) = child.try_wait() {\n                                break status;\n                            }\n                            return Err(Error::PtySetup(e));\n                        }\n                    }\n                }\n                status = child.wait() => {\n                    let status = status?;\n                    drain_ready_pty(&mut pty, &mut stdout, &mut buffer).await?;\n                    break status;\n                }\n            }\n        };\n\n        child.stdin.take();\n        child.stdout.take();\n        child.stderr.take();\n\n        let output = Output {\n            status,\n            stdout,\n            stderr: Vec::new(),\n        };\n\n        self.maybe_check_output(&output)?;\n        Ok(output)\n    }\n\n    /// Equivalent to [`std::process::Command::status`][]\n    /// but logged, with the error wrapped, and status checked (by default)\n    pub async fn status(&mut self) -> Result<ExitStatus, Error> {\n        self.log_command();\n        let status = self.inner.status().await.map_err(|cause| Error::Exec {\n            summary: self.summary.clone(),\n            cause,\n        })?;\n        self.maybe_check_status(status)?;\n        Ok(status)\n    }\n}\n\n#[cfg(not(windows))]\nasync fn drain_ready_pty(\n    pty: &mut prek_pty::Pty,\n    stdout: &mut Vec<u8>,\n    buffer: &mut [u8; 4096],\n) -> Result<(), Error> {\n    use tokio::io::AsyncReadExt;\n    use tokio::time::{Duration, timeout};\n\n    loop {\n        match timeout(Duration::from_millis(20), pty.read(buffer)).await {\n            Ok(Ok(0)) => return Ok(()),\n            Ok(Ok(n)) => stdout.extend_from_slice(&buffer[..n]),\n            Err(_) => return Ok(()),\n            Ok(Err(err)) if err.kind() == std::io::ErrorKind::WouldBlock => return Ok(()),\n            Ok(Err(err)) => return Err(Error::PtySetup(err)),\n        }\n    }\n}\n\n/// Transparently forwarded [`std::process::Command`][] APIs\nimpl Cmd {\n    /// Forwards to [`std::process::Command::arg`][]\n    pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {\n        self.inner.arg(arg);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::args`][]\n    pub fn args<I, S>(&mut self, args: I) -> &mut Self\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        self.inner.args(args);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::env`][]\n    pub fn env<K, V>(&mut self, key: K, val: V) -> &mut Self\n    where\n        K: AsRef<OsStr>,\n        V: AsRef<OsStr>,\n    {\n        self.inner.env(key, val);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::envs`][]\n    pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self\n    where\n        I: IntoIterator<Item = (K, V)>,\n        K: AsRef<OsStr>,\n        V: AsRef<OsStr>,\n    {\n        self.inner.envs(vars);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::env_remove`][]\n    pub fn env_remove<K: AsRef<OsStr>>(&mut self, key: K) -> &mut Self {\n        self.inner.env_remove(key);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::env_clear`][]\n    pub fn env_clear(&mut self) -> &mut Self {\n        self.inner.env_clear();\n        self\n    }\n\n    /// Forwards to [`std::process::Command::current_dir`][]\n    pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {\n        self.inner.current_dir(dir);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::stdin`][]\n    pub fn stdin<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {\n        self.inner.stdin(cfg);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::stdout`][]\n    pub fn stdout<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {\n        self.inner.stdout(cfg);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::stderr`][]\n    pub fn stderr<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {\n        self.inner.stderr(cfg);\n        self\n    }\n\n    /// Forwards to [`std::process::Command::get_program`][]\n    pub fn get_program(&self) -> &OsStr {\n        self.inner.as_std().get_program()\n    }\n\n    /// Forwards to [`std::process::Command::get_args`][]\n    pub fn get_args(&self) -> CommandArgs<'_> {\n        self.inner.as_std().get_args()\n    }\n\n    /// Forwards to [`std::process::Command::get_envs`][]\n    pub fn get_envs(&self) -> CommandEnvs<'_> {\n        self.inner.as_std().get_envs()\n    }\n\n    /// Forwards to [`std::process::Command::get_current_dir`][]\n    pub fn get_current_dir(&self) -> Option<&Path> {\n        self.inner.as_std().get_current_dir()\n    }\n\n    /// Remove some git-specific environment variables to make git commands isolated.\n    pub fn remove_git_envs(&mut self) -> &mut Self {\n        for (key, _) in crate::git::GIT_ENV_TO_REMOVE.iter() {\n            self.inner.env_remove(key);\n        }\n        self\n    }\n}\n\n/// Diagnostic APIs (used internally, but available for yourself)\nimpl Cmd {\n    /// Check `Status::success`, producing a contextual Error if it's `false`.\n    pub fn check_status(&self, status: ExitStatus) -> Result<(), Error> {\n        if status.success() {\n            Ok(())\n        } else {\n            Err(Error::Status {\n                summary: self.summary.clone(),\n                error: StatusError {\n                    status,\n                    output: None,\n                },\n            })\n        }\n    }\n\n    pub fn check_output(&self, output: &Output) -> Result<(), Error> {\n        if output.status.success() {\n            Ok(())\n        } else {\n            Err(Error::Status {\n                summary: self.summary.clone(),\n                error: StatusError {\n                    status: output.status,\n                    output: Some(output.clone()),\n                },\n            })\n        }\n    }\n\n    /// Invoke [`Cmd::check_status`][] if [`Cmd::check`][] is `true`\n    /// (defaults to `true`).\n    pub fn maybe_check_status(&self, status: ExitStatus) -> Result<(), Error> {\n        if self.check_status {\n            self.check_status(status)?;\n        }\n        Ok(())\n    }\n\n    /// Invoke [`Cmd::check_status`][] if [`Cmd::check`][] is `true`\n    /// (defaults to `true`).\n    pub fn maybe_check_output(&self, output: &Output) -> Result<(), Error> {\n        if self.check_status {\n            self.check_output(output)?;\n        }\n        Ok(())\n    }\n\n    /// Log the current Command using the method specified by [`Cmd::log`][]\n    /// (defaults to [`tracing::info!`][]).\n    pub fn log_command(&self) {\n        trace!(\"Executing `{self}`\");\n    }\n}\n\n/// Returns the number of arguments to skip.\nfn skip_args(cmd: &OsStr, cur: &OsStr, next: Option<&&OsStr>) -> usize {\n    if GIT.as_ref().is_ok_and(|git| cmd == git) {\n        if cur == \"-c\" {\n            if let Some(flag) = next {\n                let flag = flag.as_encoded_bytes();\n                if flag.starts_with(b\"core.useBuiltinFSMonitor\")\n                    || flag.starts_with(b\"protocol.version\")\n                {\n                    return 2;\n                }\n            }\n        } else if cur == \"--no-ext-diff\"\n            || cur == \"--no-textconv\"\n            || cur == \"--ignore-submodules\"\n            || cur == \"--no-color\"\n        {\n            return 1;\n        }\n    }\n    0\n}\n\n/// Simplified Command Debug output, with args truncated if they're too long.\nimpl Display for Cmd {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        if let Some(cwd) = self.get_current_dir() {\n            write!(f, \"cd {} && \", cwd.to_string_lossy())?;\n        }\n        let program = self.get_program();\n        let mut args = self.get_args().peekable();\n\n        write!(f, \"{}\", program.to_string_lossy())?;\n        if args.peek().is_some_and(|arg| *arg == program) {\n            args.next(); // Skip the program if it's repeated\n        }\n\n        let mut len = 0;\n        while let Some(arg) = args.next() {\n            let skip = skip_args(program, arg, args.peek());\n            if skip > 0 {\n                for _ in 1..skip {\n                    args.next();\n                }\n                continue;\n            }\n            write!(f, \" {}\", arg.to_string_lossy())?;\n            len += arg.len() + 1;\n            if len > *LOG_TRUNCATE_LIMIT {\n                write!(f, \" [...]\",)?;\n                break;\n            }\n        }\n        Ok(())\n    }\n}\n\n#[cfg(all(test, not(windows)))]\nmod tests {\n    use super::Cmd;\n\n    #[tokio::test]\n    async fn pty_output_captures_trailing_output_after_fast_exit() {\n        for _ in 0..20 {\n            let output = Cmd::new(\"/bin/sh\", \"pty trailing output test\")\n                .arg(\"-c\")\n                .arg(\"printf 'FINAL\\\\n'\")\n                .check(false)\n                .pty_output_inner()\n                .await\n                .expect(\"pty command should succeed\");\n\n            assert!(output.status.success());\n            let stdout = String::from_utf8_lossy(&output.stdout).replace(\"\\r\\n\", \"\\n\");\n            assert_eq!(stdout, \"FINAL\\n\");\n            assert!(output.stderr.is_empty());\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/profiler.rs",
    "content": "use tracing::error;\n\n/// Creates a profiler guard and returns it.\npub(crate) fn start_profiling() -> Option<pprof::ProfilerGuard<'static>> {\n    match pprof::ProfilerGuardBuilder::default()\n        .frequency(1000)\n        .blocklist(&[\"libc\", \"libgcc\", \"pthread\", \"vdso\"])\n        .build()\n    {\n        Ok(guard) => Some(guard),\n        Err(e) => {\n            error!(\"Failed to build profiler guard: {e}\");\n            None\n        }\n    }\n}\n\n/// Reports the profiling results.\npub(crate) fn finish_profiling(profiler_guard: Option<pprof::ProfilerGuard>) {\n    match profiler_guard\n        .expect(\"Failed to retrieve profiler guard\")\n        .report()\n        .build()\n    {\n        Ok(report) => {\n            let random = rand::random::<u64>();\n            let file = fs_err::File::create(format!(\n                \"{}.{random}.flamegraph.svg\",\n                env!(\"CARGO_PKG_NAME\"),\n            ))\n            .expect(\"Failed to create flamegraph file\");\n            if let Err(e) = report.flamegraph(file) {\n                error!(\"failed to create flamegraph file: {e}\");\n            }\n        }\n        Err(e) => {\n            error!(\"Failed to build profiler report: {e}\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/resource_limit.rs",
    "content": "// MIT License\n// Copyright (c) 2025 Astral Software Inc.\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n//! Helper for adjusting Unix resource limits.\n//!\n//! Linux has a historically low default limit of 1024 open file descriptors per process.\n//! macOS also defaults to a low soft limit (typically 256), though its hard limit is much\n//! higher. On modern multi-core machines, these low defaults can cause \"too many open files\"\n//! errors because uv infers concurrency limits from CPU count and may schedule more concurrent\n//! work than the default file descriptor limit allows.\n//!\n//! This module attempts to raise the soft limit to the hard limit at startup to avoid these\n//! errors without requiring users to manually configure their shell's `ulimit` settings.\n//! The raised limit is inherited by child processes, which is important for commands like\n//! `uv run` that spawn Python interpreters.\n//!\n//! See: <https://github.com/astral-sh/uv/issues/16999>\n\nuse rustix::io::Errno;\nuse rustix::process::{Resource, Rlimit, getrlimit, setrlimit};\nuse thiserror::Error;\n\n/// Errors that can occur when adjusting resource limits.\n#[derive(Debug, Error)]\npub enum OpenFileLimitError {\n    #[error(\"Soft limit ({current:?}) already meets the target ({target})\")]\n    AlreadySufficient { current: Option<u64>, target: u64 },\n\n    #[error(\"Failed to raise open file limit from {current:?} to {target}: {source}\")]\n    SetLimitFailed {\n        current: Option<u64>,\n        target: u64,\n        source: Errno,\n    },\n}\n\n/// Maximum file descriptor limit to request.\n///\n/// We cap at 0x100000 (1,048,576) to match the typical Linux default (`/proc/sys/fs/nr_open`)\n/// and to avoid issues with extremely high limits.\n///\n/// `OpenJDK` uses this same cap because:\n///\n/// 1. Some code breaks if `RLIMIT_NOFILE` exceeds `i32::MAX` (despite the type being `u64`)\n/// 2. Code that iterates over all possible FDs, e.g., to close them, can timeout\n///\n/// See: <https://bugs.openjdk.org/browse/JDK-8324577>\n/// See: <https://github.com/oracle/graal/issues/11136>\n///\nconst MAX_NOFILE_LIMIT: u64 = 0x0010_0000;\n\n/// Attempt to raise the open file descriptor limit to the maximum allowed.\n///\n/// This function tries to set the soft limit to `min(hard_limit, 0x100000)`. If the operation\n/// fails, it returns an error since the default limits may still be sufficient for the\n/// current workload.\n///\n/// Returns [`Ok`] with the new soft limit on successful adjustment, or an appropriate\n/// [`OpenFileLimitError`] if adjustment failed.\n///\n/// Note that `rustix::process::Rlimit` represents unlimited values as `None`.\npub fn adjust_open_file_limit() -> Result<u64, OpenFileLimitError> {\n    let rlimit = getrlimit(Resource::Nofile);\n\n    let soft = rlimit.current;\n    let hard = rlimit.maximum;\n\n    // Cap the target limit to avoid issues with extremely high values.\n    // If hard is unlimited, use MAX_NOFILE_LIMIT.\n    let target = hard.unwrap_or(MAX_NOFILE_LIMIT).min(MAX_NOFILE_LIMIT);\n\n    if soft.is_none() || soft.is_some_and(|soft| soft >= target) {\n        return Err(OpenFileLimitError::AlreadySufficient {\n            current: soft,\n            target,\n        });\n    }\n\n    // Try to raise the soft limit to the target.\n    setrlimit(\n        Resource::Nofile,\n        Rlimit {\n            current: Some(target),\n            maximum: hard,\n        },\n    )\n    .map_err(|err| OpenFileLimitError::SetLimitFailed {\n        current: soft,\n        target,\n        source: err,\n    })?;\n\n    Ok(target)\n}\n"
  },
  {
    "path": "crates/prek/src/run.rs",
    "content": "use std::cmp::max;\nuse std::ffi::OsStr;\nuse std::path::Path;\nuse std::sync::LazyLock;\n\nuse anstream::ColorChoice;\nuse futures::{StreamExt, TryStreamExt};\nuse prek_consts::env_vars::EnvVars;\nuse rustc_hash::FxHashMap;\nuse tracing::trace;\n\nuse crate::config::PassFilenames;\nuse crate::hook::Hook;\nuse crate::warn_user;\n\npub(crate) static USE_COLOR: LazyLock<bool> =\n    LazyLock::new(|| match anstream::Stderr::choice(&std::io::stderr()) {\n        ColorChoice::Always | ColorChoice::AlwaysAnsi => true,\n        ColorChoice::Never => false,\n        // We just asked anstream for a choice, that can't be auto\n        ColorChoice::Auto => unreachable!(),\n    });\n\nfn resolve_concurrency(no_concurrency: bool, max_concurrency: Option<&str>, cpu: usize) -> usize {\n    if no_concurrency {\n        return 1;\n    }\n\n    if let Some(v) = max_concurrency {\n        if let Ok(cap) = v.parse::<usize>() {\n            return cap.max(1);\n        }\n        warn_user!(\n            \"Invalid value for {}: {v:?}, using default ({cpu})\",\n            EnvVars::PREK_MAX_CONCURRENCY,\n        );\n    }\n\n    cpu\n}\n\npub(crate) static CONCURRENCY: LazyLock<usize> = LazyLock::new(|| {\n    let cpu = std::thread::available_parallelism()\n        .map(std::num::NonZero::get)\n        .unwrap_or(1);\n\n    resolve_concurrency(\n        EnvVars::is_set(EnvVars::PREK_NO_CONCURRENCY),\n        EnvVars::var(EnvVars::PREK_MAX_CONCURRENCY).ok().as_deref(),\n        cpu,\n    )\n});\n\nfn target_concurrency(serial: bool) -> usize {\n    if serial { 1 } else { *CONCURRENCY }\n}\n\n/// Iterator that yields partitions of filenames that fit within the maximum command line length.\nstruct Partitions<'a> {\n    filenames: &'a [&'a Path],\n    current_index: usize,\n    max_per_batch: usize,\n    remaining_arg_length: usize,\n}\n\n/// We make a conservative guess for the size of a single pointer (64-bit) here\n/// in order to support scenarios where a 32-bit binary is launching a 64-bit\n/// binary.\nconst POINTER_SIZE_CONSERVATIVE: usize = 8;\n\n/// POSIX requires that we leave 2048 bytes of space so that the child processes\n/// can have room to set their own environment variables.\nconst ARG_HEADROOM: usize = 2048;\n\n// Adapted from https://github.com/sharkdp/argmax\n/// Required size for a single KEY=VAR environment variable string and the\n/// corresponding pointer in envp**.\nfn environment_variable_size<O: AsRef<OsStr>>(key: O, value: O) -> usize {\n    POINTER_SIZE_CONSERVATIVE   // size for the pointer in envp**\n        + key.as_ref().len()    // size for the variable name\n        + 1                     // size for the '=' sign\n        + value.as_ref().len()  // size for the value\n        + 1 // terminating NULL\n}\n\n/// Required size to store a single ARG argument and the corresponding\n/// pointer in argv**.\nfn arg_size<O: AsRef<OsStr>>(arg: O) -> usize {\n    POINTER_SIZE_CONSERVATIVE  // size for the pointer in argv**\n        + arg.as_ref().len()   // size for argument string\n        + 1 // terminating NULL\n}\n\n#[cfg(unix)]\nstatic ARG_MAX: LazyLock<usize> = LazyLock::new(|| {\n    let arg_max = unsafe { libc::sysconf(libc::_SC_ARG_MAX) };\n    if arg_max <= 0 {\n        1 << 12\n    } else {\n        usize::try_from(arg_max).expect(\"SC_ARG_MAX too large\")\n    }\n});\n\n#[cfg(unix)]\nstatic PAGE_SIZE: LazyLock<usize> = LazyLock::new(|| {\n    let page_size = unsafe { libc::sysconf(libc::_SC_PAGE_SIZE) };\n    if page_size < 4096 {\n        4096\n    } else {\n        usize::try_from(page_size).expect(\"SC_PAGE_SIZE too large\")\n    }\n});\n\n// https://www.in-ulm.de/~mascheck/various/argmax/\n// https://cgit.git.savannah.gnu.org/cgit/findutils.git/tree/xargs/xargs.c\n// https://github.com/rust-lang/rust/issues/40384\n// https://github.com/uutils/findutils/blob/af48c151fe9b29cb7d25471b5388013ca15748ba/src/xargs/mod.rs#L177\n// https://github.com/sharkdp/argmax\nfn platform_max_cli_length() -> usize {\n    #[cfg(unix)]\n    {\n        let mut arg_max = *ARG_MAX;\n        // Assume arguments are counted with the granularity of a single page,\n        // so allow a one page cushion to account for rounding up\n        arg_max -= *PAGE_SIZE;\n        // POSIX recommends an additional 2048 bytes of headroom\n        arg_max -= ARG_HEADROOM;\n        arg_max.clamp(1 << 12, 1 << 20)\n    }\n    #[cfg(windows)]\n    {\n        (1 << 15) - ARG_HEADROOM // UNICODE_STRING max - headroom\n    }\n    #[cfg(not(any(unix, windows)))]\n    {\n        1 << 12\n    }\n}\n\nfn env_size(override_envs: &FxHashMap<String, String>) -> usize {\n    std::env::vars_os()\n        .map(|(key, value)| {\n            if key\n                .to_str()\n                .map(|key| override_envs.contains_key(key))\n                .unwrap_or(false)\n            {\n                // key is in override_envs; add it later.\n                0\n            } else {\n                environment_variable_size(&key, &value)\n            }\n        })\n        .sum::<usize>()\n        + override_envs\n            .iter()\n            .map(|(key, value)| environment_variable_size(key, value))\n            .sum::<usize>()\n}\n\nimpl<'a> Partitions<'a> {\n    fn split(\n        hook: &'a Hook,\n        entry: &'a [String],\n        filenames: &'a [&'a Path],\n        concurrency: usize,\n    ) -> anyhow::Result<Self> {\n        let max_per_batch = match hook.pass_filenames {\n            PassFilenames::Limited(n) => n.get(),\n            _ => max(4, filenames.len().div_ceil(concurrency)),\n        };\n        let mut arg_max = platform_max_cli_length();\n\n        let cmd = Path::new(&entry[0]);\n        if cfg!(windows)\n            && cmd.extension().is_some_and(|ext| {\n                ext.eq_ignore_ascii_case(\"cmd\") || ext.eq_ignore_ascii_case(\"bat\")\n            })\n        {\n            // Reduce max length for batch files on Windows due to cmd.exe limitations.\n            // 1024 is additionally subtracted to give headroom for further\n            // expansion inside the batch file.\n            arg_max = 8192 - 1024;\n        } else if cfg!(unix) {\n            // We have to share space with the environment variables\n            arg_max -= env_size(&hook.env);\n            // Account for the terminating NULL entry\n            arg_max -= POINTER_SIZE_CONSERVATIVE;\n        }\n\n        let args_size = entry\n            .iter()\n            .chain(hook.args.iter())\n            .map(arg_size)\n            .sum::<usize>()\n            + POINTER_SIZE_CONSERVATIVE; // terminating NULL\n\n        if args_size >= arg_max {\n            anyhow::bail!(\n                \"Command line length ({args_size} bytes) exceeds platform limit ({arg_max} bytes).\n                \\nhint: Shorten the hook `entry`/`args` or wrap the command in a script to reduce command-line length.\",\n            );\n        }\n        arg_max -= args_size;\n\n        Ok(Self {\n            filenames,\n            current_index: 0,\n            max_per_batch,\n            remaining_arg_length: arg_max,\n        })\n    }\n}\n\nimpl<'a> Iterator for Partitions<'a> {\n    type Item = &'a [&'a Path];\n\n    fn next(&mut self) -> Option<Self::Item> {\n        // Handle empty filenames case\n        if self.filenames.is_empty() && self.current_index == 0 {\n            self.current_index = 1;\n            return Some(&[]);\n        }\n\n        if self.current_index >= self.filenames.len() {\n            return None;\n        }\n\n        let start_index = self.current_index;\n        let mut remaining_length = self.remaining_arg_length;\n\n        while self.current_index < self.filenames.len() {\n            let filename = self.filenames[self.current_index];\n            let length = arg_size(filename);\n\n            if length > remaining_length || self.current_index - start_index >= self.max_per_batch {\n                break;\n            }\n\n            remaining_length -= length;\n            self.current_index += 1;\n        }\n\n        if self.current_index == start_index {\n            // If we couldn't add even a single file to this batch, it means the file\n            // is too long to fit in the command line by itself.\n            let filename = self.filenames[self.current_index];\n            let length = arg_size(filename);\n            panic!(\n                \"Filename `{}` is too long ({length} bytes) to fit in command line (remaining {remaining_length} bytes).\",\n                filename.display(),\n            );\n        } else {\n            Some(&self.filenames[start_index..self.current_index])\n        }\n    }\n}\n\npub(crate) async fn run_by_batch<T, F>(\n    hook: &Hook,\n    filenames: &[&Path],\n    entry: &[String],\n    run: F,\n) -> anyhow::Result<Vec<T>>\nwhere\n    F: for<'a> AsyncFn(&'a [&'a Path]) -> anyhow::Result<T>,\n    T: Send + 'static,\n{\n    let concurrency = target_concurrency(hook.require_serial);\n\n    // Split files into batches\n    let partitions = Partitions::split(hook, entry, filenames, concurrency)?;\n    trace!(\n        total_files = filenames.len(),\n        concurrency = concurrency,\n        \"Running {}\",\n        hook.id,\n    );\n\n    #[allow(clippy::redundant_closure)]\n    let results: Vec<_> = futures::stream::iter(partitions)\n        .map(|batch| run(batch))\n        .buffered(concurrency)\n        .try_collect()\n        .await?;\n\n    Ok(results)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::{Path, PathBuf};\n\n    /// Helper to create a Partitions iterator for testing.\n    /// This bypasses the Hook requirement by directly constructing the struct.\n    fn create_test_partitions<'a>(\n        filenames: &'a [&'a Path],\n        remaining_arg_length: usize,\n        max_per_batch: usize,\n    ) -> Partitions<'a> {\n        Partitions {\n            filenames,\n            current_index: 0,\n            remaining_arg_length,\n            max_per_batch,\n        }\n    }\n\n    #[test]\n    fn test_partitions_normal_filenames() {\n        let file1 = PathBuf::from(\"file1.txt\");\n        let file2 = PathBuf::from(\"file2.txt\");\n        let file3 = PathBuf::from(\"file3.txt\");\n        let filenames: Vec<&Path> = vec![&file1, &file2, &file3];\n\n        let partitions = create_test_partitions(&filenames, 4096, 10);\n\n        let total_files: usize = partitions.map(<[&Path]>::len).sum();\n\n        // All files should have been processed (no panic)\n        assert_eq!(total_files, 3);\n    }\n\n    #[test]\n    fn test_partitions_empty_filenames() {\n        let filenames: Vec<&Path> = vec![];\n\n        let mut partitions = create_test_partitions(&filenames, 4096, 10);\n\n        // Should return empty slice once, then None\n        let batch = partitions.next();\n        assert!(batch.is_some());\n        assert_eq!(batch.unwrap().len(), 0);\n\n        let batch = partitions.next();\n        assert!(batch.is_none());\n    }\n\n    #[test]\n    #[should_panic(expected = \"is too long\")]\n    fn test_partitions_long_filename_in_middle_panics() {\n        let file1 = PathBuf::from(\"file1.txt\");\n        let long_name = \"a\".repeat(5000);\n        let long_file = PathBuf::from(&long_name);\n        let file3 = PathBuf::from(\"file3.txt\");\n        let filenames: Vec<&Path> = vec![&file1, &long_file, &file3];\n\n        let mut partitions = create_test_partitions(&filenames, 1000, 10);\n\n        // First batch should succeed with file1\n        let batch1 = partitions.next();\n        assert!(batch1.is_some());\n\n        // Second batch should panic on the long filename\n        // This ensures we don't silently skip file3\n        partitions.next();\n    }\n\n    #[test]\n    fn test_partitions_respects_max_per_batch() {\n        // Create many small files\n        let files: Vec<PathBuf> = (0..100)\n            .map(|i| PathBuf::from(format!(\"f{i}.txt\")))\n            .collect();\n        let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect();\n\n        let partitions = create_test_partitions(&file_refs, 100_000, 25);\n\n        let all_batches: Vec<_> = partitions.map(<[&Path]>::len).collect();\n\n        // Should have multiple batches due to max_per_batch\n        assert!(all_batches.len() >= 4);\n\n        // All files should have been processed\n        let total_files: usize = all_batches.iter().sum();\n        assert_eq!(total_files, 100);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_defaults_to_cpu() {\n        assert_eq!(resolve_concurrency(false, None, 16), 16);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_max_caps_value() {\n        assert_eq!(resolve_concurrency(false, Some(\"4\"), 16), 4);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_max_above_cpu() {\n        assert_eq!(resolve_concurrency(false, Some(\"32\"), 8), 32);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_max_zero_floors_to_one() {\n        assert_eq!(resolve_concurrency(false, Some(\"0\"), 16), 1);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_max_invalid_falls_back() {\n        assert_eq!(resolve_concurrency(false, Some(\"abc\"), 16), 16);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_max_empty_falls_back() {\n        assert_eq!(resolve_concurrency(false, Some(\"\"), 16), 16);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_no_concurrency() {\n        assert_eq!(resolve_concurrency(true, None, 16), 1);\n    }\n\n    #[test]\n    fn test_resolve_concurrency_no_concurrency_overrides_max() {\n        assert_eq!(resolve_concurrency(true, Some(\"8\"), 16), 1);\n    }\n\n    #[test]\n    fn test_partitions_respects_cli_length_limit() {\n        // Create files that will exceed CLI length limit\n        let files: Vec<PathBuf> = (0..10)\n            .map(|i| PathBuf::from(format!(\"file{i}.txt\")))\n            .collect();\n        let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect();\n\n        // Set a small max_cli_length to force multiple batches\n        let partitions = create_test_partitions(&file_refs, 100, 100);\n\n        let all_batches: Vec<_> = partitions.map(<[&Path]>::len).collect();\n\n        // Should have multiple batches due to CLI length limit\n        assert!(all_batches.len() > 1);\n\n        // All files should have been processed\n        let total_files: usize = all_batches.iter().sum();\n        assert_eq!(total_files, 10);\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/schema.rs",
    "content": "use crate::config::{\n    BuiltinHook, BuiltinRepo, FilePattern, LocalRepo, MetaHook, MetaRepo, PassFilenames,\n    RemoteHook, RemoteRepo, Repo, Stage, Stages,\n};\nuse std::borrow::Cow;\n\n#[derive(Debug, Clone)]\nstruct RemoveNullTypes;\n\nimpl schemars::transform::Transform for RemoveNullTypes {\n    fn transform(&mut self, schema: &mut schemars::Schema) {\n        strip_null_acceptance(schema);\n        schemars::transform::transform_subschemas(self, schema);\n    }\n}\n\nfn strip_null_acceptance(schema: &mut schemars::Schema) {\n    use serde_json::Value;\n\n    let Some(obj) = schema.as_object_mut() else {\n        return;\n    };\n\n    const ANNOTATION_KEYS: &[&str] = &[\"title\", \"description\", \"default\", \"examples\"];\n\n    // After stripping nullability, `default: null` is invalid for most schemas and can\n    // trigger editor warnings. Treat it as \"no default\".\n    if obj.get(\"default\").is_some_and(Value::is_null) {\n        obj.remove(\"default\");\n    }\n\n    // Remove `null` from `type`.\n    if let Some(ty) = obj.get_mut(\"type\") {\n        match ty {\n            Value::String(s) if s == \"null\" => {\n                *schema = schemars::json_schema!(false);\n                return;\n            }\n            Value::Array(arr) => {\n                arr.retain(|v| v != \"null\");\n                match arr.len() {\n                    0 => {\n                        *schema = schemars::json_schema!(false);\n                        return;\n                    }\n                    1 => {\n                        if let Some(Value::String(single)) = arr.pop() {\n                            *ty = Value::String(single);\n                        }\n                    }\n                    _ => {}\n                }\n            }\n            _ => {}\n        }\n    }\n\n    // Remove explicit `null` schemas from combinators.\n    for key in [\"anyOf\", \"oneOf\", \"allOf\"] {\n        let Some(Value::Array(arr)) = obj.get_mut(key) else {\n            continue;\n        };\n\n        arr.retain(|sub| {\n            let Some(sub_obj) = sub.as_object() else {\n                return true;\n            };\n\n            match sub_obj.get(\"type\") {\n                Some(Value::String(s)) if s == \"null\" => false,\n                Some(Value::Array(types)) if types.iter().all(|t| t == \"null\") => false,\n                _ => true,\n            }\n        });\n\n        if arr.is_empty() {\n            *schema = schemars::json_schema!(false);\n            return;\n        }\n\n        // If the combinator has only one subschema left, collapse it.\n        if arr.len() == 1 {\n            let only = arr[0].clone();\n\n            // Preserve common annotations from the original wrapper schema.\n            let mut annotations = Vec::new();\n            for k in ANNOTATION_KEYS {\n                if let Some(v) = obj.get(*k).cloned() {\n                    if *k == \"default\" && v.is_null() {\n                        continue;\n                    }\n                    annotations.push(((*k).to_string(), v));\n                }\n            }\n\n            let Ok(only_schema) = serde_json::from_value::<schemars::Schema>(only) else {\n                return;\n            };\n\n            *schema = only_schema;\n            if let Some(new_obj) = schema.as_object_mut() {\n                for (k, v) in annotations {\n                    new_obj.entry(k).or_insert(v);\n                }\n            }\n\n            return;\n        }\n    }\n\n    // If a schema explicitly matches only `null`, block it.\n    if obj.get(\"const\").is_some_and(Value::is_null) {\n        *schema = schemars::json_schema!(false);\n        return;\n    }\n    if let Some(Value::Array(values)) = obj.get(\"enum\") {\n        if !values.is_empty() && values.iter().all(Value::is_null) {\n            *schema = schemars::json_schema!(false);\n        }\n    }\n}\n\nimpl schemars::JsonSchema for Stages {\n    fn inline_schema() -> bool {\n        true\n    }\n\n    fn schema_name() -> Cow<'static, str> {\n        Cow::Borrowed(\"Stages\")\n    }\n\n    fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {\n        let stage_schema = generator.subschema_for::<Stage>();\n        schemars::json_schema!({\n            \"type\": \"array\",\n            \"items\": stage_schema,\n            \"uniqueItems\": true,\n        })\n    }\n}\n\nimpl schemars::JsonSchema for FilePattern {\n    fn schema_name() -> Cow<'static, str> {\n        Cow::Borrowed(\"FilePattern\")\n    }\n\n    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {\n        schemars::json_schema!({\n            \"description\": \"A file pattern, either a regex or glob pattern(s).\",\n            \"oneOf\": [\n                {\n                    \"type\": \"string\",\n                    \"description\": \"A regular expression pattern.\",\n                },\n                {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"glob\": {\n                            \"oneOf\": [\n                                {\n                                    \"type\": \"string\",\n                                    \"description\": \"A glob pattern.\",\n                                },\n                                {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                        \"type\": \"string\",\n                                    },\n                                    \"description\": \"A list of glob patterns.\",\n                                }\n                            ]\n                        }\n                    },\n                    \"required\": [\"glob\"],\n                }\n            ],\n        })\n    }\n}\n\nimpl schemars::JsonSchema for PassFilenames {\n    fn schema_name() -> std::borrow::Cow<'static, str> {\n        std::borrow::Cow::Borrowed(\"PassFilenames\")\n    }\n\n    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {\n        schemars::json_schema!({\n            \"description\": \"Whether to pass filenames to the hook. \\\n            `true` passes all matching filenames (default), \\\n            `false` passes none, and \\\n            a positive integer limits each invocation to at most that many filenames.\",\n            \"oneOf\": [\n                {\"type\": \"boolean\"},\n                {\"type\": \"integer\", \"exclusiveMinimum\": 0}\n            ]\n        })\n    }\n}\n\nfn predefined_hook_schema(\n    schema_gen: &mut schemars::SchemaGenerator,\n    description: &str,\n    id_schema: schemars::Schema,\n) -> schemars::Schema {\n    let mut schema = <RemoteHook as schemars::JsonSchema>::json_schema(schema_gen);\n\n    let root = schema.ensure_object();\n    root.insert(\"description\".to_string(), serde_json::json!(description));\n    root.insert(\"required\".to_string(), serde_json::json!([\"id\"]));\n\n    let properties = root\n        .get_mut(\"properties\")\n        .and_then(serde_json::Value::as_object_mut);\n\n    if let Some(properties) = properties {\n        properties.insert(\"id\".to_string(), id_schema.into());\n        properties.insert(\n            \"language\".to_string(),\n            serde_json::json!({\n                \"type\": \"string\",\n                \"enum\": [\"system\"],\n                \"description\": \"Language must be `system` for predefined hooks (or omitted).\"\n            }),\n        );\n        // `entry` is not allowed for predefined hooks.\n        properties.insert(\n            \"entry\".to_string(),\n            serde_json::json!({\n                \"const\": false,\n                \"description\": \"Entry is not allowed for predefined hooks.\",\n            }),\n        );\n    }\n\n    schema\n}\n\nimpl schemars::JsonSchema for MetaHook {\n    fn schema_name() -> Cow<'static, str> {\n        Cow::Borrowed(\"MetaHook\")\n    }\n\n    fn json_schema(schema_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {\n        use crate::hooks::MetaHooks;\n\n        let id_schema = schema_gen.subschema_for::<MetaHooks>();\n        predefined_hook_schema(schema_gen, \"A meta hook predefined in prek.\", id_schema)\n    }\n}\n\nimpl schemars::JsonSchema for BuiltinHook {\n    fn schema_name() -> Cow<'static, str> {\n        Cow::Borrowed(\"BuiltinHook\")\n    }\n\n    fn json_schema(r#gen: &mut schemars::SchemaGenerator) -> schemars::Schema {\n        use crate::hooks::BuiltinHooks;\n\n        let id_schema = r#gen.subschema_for::<BuiltinHooks>();\n        predefined_hook_schema(r#gen, \"A builtin hook predefined in prek.\", id_schema)\n    }\n}\n\npub(crate) fn schema_repo_local(\n    _gen: &mut schemars::generate::SchemaGenerator,\n) -> schemars::Schema {\n    schemars::json_schema!({\n        \"type\": \"string\",\n        \"const\": \"local\",\n        \"description\": \"Must be `local`.\",\n    })\n}\n\npub(crate) fn schema_repo_meta(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {\n    schemars::json_schema!({\n        \"type\": \"string\",\n        \"const\": \"meta\",\n        \"description\": \"Must be `meta`.\",\n    })\n}\n\npub(crate) fn schema_repo_builtin(\n    _gen: &mut schemars::generate::SchemaGenerator,\n) -> schemars::Schema {\n    schemars::json_schema!({\n        \"type\": \"string\",\n        \"const\": \"builtin\",\n        \"description\": \"Must be `builtin`.\",\n    })\n}\n\npub(crate) fn schema_repo_remote(\n    _gen: &mut schemars::generate::SchemaGenerator,\n) -> schemars::Schema {\n    schemars::json_schema!({\n        \"type\": \"string\",\n        \"not\": {\n            \"enum\": [\"local\", \"meta\", \"builtin\"],\n        },\n        \"description\": \"Remote repository location. Must not be `local`, `meta`, or `builtin`.\",\n    })\n}\n\nimpl schemars::JsonSchema for Repo {\n    fn schema_name() -> Cow<'static, str> {\n        Cow::Borrowed(\"Repo\")\n    }\n\n    fn json_schema(r#gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {\n        let remote_schema = r#gen.subschema_for::<RemoteRepo>();\n        let local_schema = r#gen.subschema_for::<LocalRepo>();\n        let meta_schema = r#gen.subschema_for::<MetaRepo>();\n        let builtin_schema = r#gen.subschema_for::<BuiltinRepo>();\n\n        schemars::json_schema!({\n            \"type\": \"object\",\n            \"description\": \"A repository of hooks, which can be remote, local, meta, or builtin.\",\n            \"oneOf\": [\n                remote_schema,\n                local_schema,\n                meta_schema,\n                builtin_schema,\n            ],\n            \"additionalProperties\": true,\n        })\n    }\n}\n\n#[cfg(unix)]\n#[cfg(all(test, feature = \"schemars\"))]\nmod _gen {\n    use crate::config::Config;\n    use anyhow::bail;\n    use prek_consts::env_vars::EnvVars;\n    use pretty_assertions::StrComparison;\n    use std::path::PathBuf;\n\n    const ROOT_DIR: &str = concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../\");\n\n    enum Mode {\n        /// Update the content.\n        Write,\n\n        /// Don't write to the file, check if the file is up-to-date and error if not.\n        Check,\n\n        /// Write the generated help to stdout.\n        DryRun,\n    }\n\n    fn generate() -> String {\n        let settings = schemars::generate::SchemaSettings::draft07()\n            .with_transform(schemars::transform::RestrictFormats::default())\n            .with_transform(super::RemoveNullTypes);\n        let generator = schemars::SchemaGenerator::new(settings);\n        let schema = generator.into_root_schema_for::<Config>();\n        serde_json::to_string_pretty(&schema).unwrap() + \"\\n\"\n    }\n\n    #[test]\n    fn generate_json_schema() -> anyhow::Result<()> {\n        let mode = if EnvVars::is_set(EnvVars::PREK_GENERATE) {\n            Mode::Write\n        } else {\n            Mode::Check\n        };\n\n        let schema_string = generate();\n        let filename = \"prek.schema.json\";\n        let schema_path = PathBuf::from(ROOT_DIR).join(filename);\n\n        match mode {\n            Mode::DryRun => {\n                anstream::println!(\"{schema_string}\");\n            }\n            Mode::Check => match fs_err::read_to_string(schema_path) {\n                Ok(current) => {\n                    if current == schema_string {\n                        anstream::println!(\"Up-to-date: {filename}\");\n                    } else {\n                        let comparison = StrComparison::new(&current, &schema_string);\n                        bail!(\n                            \"{filename} changed, please run `mise run generate` to update:\\n{comparison}\"\n                        );\n                    }\n                }\n                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n                    bail!(\"{filename} not found, please run `mise run generate` to generate\");\n                }\n                Err(err) => {\n                    bail!(\"{filename} changed, please run `mise run generate` to update:\\n{err}\");\n                }\n            },\n            Mode::Write => match fs_err::read_to_string(&schema_path) {\n                Ok(current) => {\n                    if current == schema_string {\n                        anstream::println!(\"Up-to-date: {filename}\");\n                    } else {\n                        anstream::println!(\"Updating: {filename}\");\n                        fs_err::write(schema_path, schema_string.as_bytes())?;\n                    }\n                }\n                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n                    anstream::println!(\"Updating: {filename}\");\n                    fs_err::write(schema_path, schema_string.as_bytes())?;\n                }\n                Err(err) => {\n                    bail!(\"{filename} changed, please run `mise run generate` to update:\\n{err}\");\n                }\n            },\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__language_version.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nOk(\n    Config {\n        repos: [\n            Local(\n                LocalRepo {\n                    repo: \"local\",\n                    hooks: [\n                        LocalHook {\n                            id: \"hook-1\",\n                            name: \"hook 1\",\n                            entry: \"echo hello world\",\n                            language: System,\n                            priority: None,\n                            options: HookOptions {\n                                alias: None,\n                                files: None,\n                                exclude: None,\n                                types: None,\n                                types_or: None,\n                                exclude_types: None,\n                                additional_dependencies: None,\n                                args: None,\n                                env: None,\n                                always_run: None,\n                                fail_fast: None,\n                                pass_filenames: None,\n                                description: None,\n                                language_version: Some(\n                                    \"default\",\n                                ),\n                                log_file: None,\n                                require_serial: None,\n                                stages: None,\n                                verbose: None,\n                                minimum_prek_version: None,\n                                _unused_keys: {},\n                            },\n                        },\n                        LocalHook {\n                            id: \"hook-2\",\n                            name: \"hook 2\",\n                            entry: \"echo hello world\",\n                            language: System,\n                            priority: None,\n                            options: HookOptions {\n                                alias: None,\n                                files: None,\n                                exclude: None,\n                                types: None,\n                                types_or: None,\n                                exclude_types: None,\n                                additional_dependencies: None,\n                                args: None,\n                                env: None,\n                                always_run: None,\n                                fail_fast: None,\n                                pass_filenames: None,\n                                description: None,\n                                language_version: Some(\n                                    \"system\",\n                                ),\n                                log_file: None,\n                                require_serial: None,\n                                stages: None,\n                                verbose: None,\n                                minimum_prek_version: None,\n                                _unused_keys: {},\n                            },\n                        },\n                        LocalHook {\n                            id: \"hook-3\",\n                            name: \"hook 3\",\n                            entry: \"echo hello world\",\n                            language: System,\n                            priority: None,\n                            options: HookOptions {\n                                alias: None,\n                                files: None,\n                                exclude: None,\n                                types: None,\n                                types_or: None,\n                                exclude_types: None,\n                                additional_dependencies: None,\n                                args: None,\n                                env: None,\n                                always_run: None,\n                                fail_fast: None,\n                                pass_filenames: None,\n                                description: None,\n                                language_version: Some(\n                                    \"3.8\",\n                                ),\n                                log_file: None,\n                                require_serial: None,\n                                stages: None,\n                                verbose: None,\n                                minimum_prek_version: None,\n                                _unused_keys: {},\n                            },\n                        },\n                    ],\n                    _unused_keys: {},\n                },\n            ),\n        ],\n        default_install_hook_types: None,\n        default_language_version: None,\n        default_stages: None,\n        files: None,\n        exclude: None,\n        fail_fast: None,\n        minimum_prek_version: None,\n        orphan: None,\n        _unused_keys: {},\n    },\n)\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Meta(\n            MetaRepo {\n                repo: \"meta\",\n                hooks: [\n                    MetaHook {\n                        id: \"check-hooks-apply\",\n                        name: \"Check hooks apply\",\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: Some(\n                                Glob(\n                                    GlobPatterns {\n                                        patterns: [\n                                            \"prek.toml\",\n                                            \".pre-commit-config.yaml\",\n                                            \".pre-commit-config.yml\",\n                                        ],\n                                        ..\n                                    },\n                                ),\n                            ),\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                    MetaHook {\n                        id: \"check-useless-excludes\",\n                        name: \"Check useless excludes\",\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: Some(\n                                Glob(\n                                    GlobPatterns {\n                                        patterns: [\n                                            \"prek.toml\",\n                                            \".pre-commit-config.yaml\",\n                                            \".pre-commit-config.yml\",\n                                        ],\n                                        ..\n                                    },\n                                ),\n                            ),\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                    MetaHook {\n                        id: \"identity\",\n                        name: \"identity\",\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: Some(\n                                true,\n                            ),\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: config\n---\nConfig {\n    repos: [\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/pre-commit/mirrors-mypy\",\n                rev: \"1.0\",\n                hooks: [\n                    RemoteHook {\n                        id: \"mypy\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-fmt\",\n                        name: \"cargo fmt\",\n                        entry: \"cargo fmt\",\n                        language: Rust,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__parse_repos-2.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: LocalRepoLocation,\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-fmt\",\n                        name: \"cargo fmt\",\n                        entry: \"cargo fmt\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: Some(\n                                [\n                                    \"rust\",\n                                ],\n                            ),\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {\n                    \"unknown_field\": String(\"some_value\"),\n                },\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-fmt\",\n                        name: \"cargo fmt\",\n                        entry: \"cargo fmt\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: Some(\n                                [\n                                    \"rust\",\n                                ],\n                            ),\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {\n                    \"unknown_field\": String(\"some_value\"),\n                },\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/crate-ci/typos\",\n                rev: \"v1.0.0\",\n                hooks: [\n                    RemoteHook {\n                        id: \"typos\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/crate-ci/typos\",\n                rev: \"v1.0.0\",\n                hooks: [\n                    RemoteHook {\n                        id: \"typos\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__parse_repos.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: result\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-fmt\",\n                        name: \"cargo fmt\",\n                        entry: \"cargo fmt --\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: config\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"mypy-local\",\n                        name: \"Local mypy\",\n                        entry: \"python tools/pre_commit/mypy.py 0 \\\"local\\\"\",\n                        language: Python,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: Some(\n                                [\n                                    \"pyi\",\n                                    \"python\",\n                                ],\n                            ),\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                    LocalHook {\n                        id: \"mypy-3.10\",\n                        name: \"Mypy 3.10\",\n                        entry: \"python tools/pre_commit/mypy.py 1 \\\"3.10\\\"\",\n                        language: Python,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: Some(\n                                [\n                                    \"pyi\",\n                                    \"python\",\n                                ],\n                            ),\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: config\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"test-yaml\",\n                        name: \"Test YAML compatibility\",\n                        entry: \"prek --help\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: Some(\n                                None,\n                            ),\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: Some(\n                                true,\n                            ),\n                            stages: Some(\n                                Some(\n                                    {\n                                        PreCommit,\n                                    },\n                                ),\n                            ),\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: None,\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {\n        \"local\": Object {\n            \"language\": String(\"system\"),\n            \"pass_filenames\": Bool(false),\n            \"require_serial\": Bool(true),\n        },\n        \"local-commit\": Object {\n            \"stages\": Array [\n                String(\"pre-commit\"),\n            ],\n            \"language\": String(\"system\"),\n            \"pass_filenames\": Bool(false),\n            \"require_serial\": Bool(true),\n        },\n    },\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__read_manifest.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: manifest\n---\nManifest {\n    hooks: [\n        ManifestHook {\n            id: \"pip-compile\",\n            name: \"pip-compile\",\n            entry: \"uv pip compile\",\n            language: Python,\n            options: HookOptions {\n                alias: None,\n                files: Some(\n                    Regex(\n                        ^requirements\\.(in|txt)$,\n                    ),\n                ),\n                exclude: None,\n                types: None,\n                types_or: None,\n                exclude_types: None,\n                additional_dependencies: Some(\n                    [],\n                ),\n                args: Some(\n                    [],\n                ),\n                env: None,\n                always_run: None,\n                fail_fast: None,\n                pass_filenames: Some(\n                    None,\n                ),\n                description: Some(\n                    \"Automatically run 'uv pip compile' on your requirements\",\n                ),\n                language_version: None,\n                log_file: None,\n                require_serial: None,\n                stages: None,\n                verbose: None,\n                minimum_prek_version: None,\n                _unused_keys: {\n                    \"minimum_pre_commit_version\": String(\"2.9.2\"),\n                },\n            },\n        },\n        ManifestHook {\n            id: \"uv-lock\",\n            name: \"uv-lock\",\n            entry: \"uv lock\",\n            language: Python,\n            options: HookOptions {\n                alias: None,\n                files: Some(\n                    Regex(\n                        ^(uv\\.lock|pyproject\\.toml|uv\\.toml)$,\n                    ),\n                ),\n                exclude: None,\n                types: None,\n                types_or: None,\n                exclude_types: None,\n                additional_dependencies: Some(\n                    [],\n                ),\n                args: Some(\n                    [],\n                ),\n                env: None,\n                always_run: None,\n                fail_fast: None,\n                pass_filenames: Some(\n                    None,\n                ),\n                description: Some(\n                    \"Automatically run 'uv lock' on your project dependencies\",\n                ),\n                language_version: None,\n                log_file: None,\n                require_serial: None,\n                stages: None,\n                verbose: None,\n                minimum_prek_version: None,\n                _unused_keys: {\n                    \"minimum_pre_commit_version\": String(\"2.9.2\"),\n                },\n            },\n        },\n        ManifestHook {\n            id: \"uv-export\",\n            name: \"uv-export\",\n            entry: \"uv export\",\n            language: Python,\n            options: HookOptions {\n                alias: None,\n                files: Some(\n                    Regex(\n                        ^uv\\.lock$,\n                    ),\n                ),\n                exclude: None,\n                types: None,\n                types_or: None,\n                exclude_types: None,\n                additional_dependencies: Some(\n                    [],\n                ),\n                args: Some(\n                    [\n                        \"--frozen\",\n                        \"--output-file=requirements.txt\",\n                    ],\n                ),\n                env: None,\n                always_run: None,\n                fail_fast: None,\n                pass_filenames: Some(\n                    None,\n                ),\n                description: Some(\n                    \"Automatically run 'uv export' on your project dependencies\",\n                ),\n                language_version: None,\n                log_file: None,\n                require_serial: None,\n                stages: None,\n                verbose: None,\n                minimum_prek_version: None,\n                _unused_keys: {\n                    \"minimum_pre_commit_version\": String(\"2.9.2\"),\n                },\n            },\n        },\n    ],\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: config\n---\nConfig {\n    repos: [\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-fmt\",\n                        name: \"cargo fmt\",\n                        entry: \"cargo fmt --\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/pre-commit/pre-commit-hooks\",\n                rev: \"v6.0.0\",\n                hooks: [\n                    RemoteHook {\n                        id: \"trailing-whitespace\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                    RemoteHook {\n                        id: \"end-of-file-fixer\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: Some(\n                                [\n                                    \"--fix\",\n                                    \"crlf\",\n                                ],\n                            ),\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: None,\n    fail_fast: Some(\n        true,\n    ),\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap",
    "content": "---\nsource: crates/prek/src/config.rs\nexpression: config\n---\nConfig {\n    repos: [\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/abravalheri/validate-pyproject\",\n                rev: \"v0.20.2\",\n                hooks: [\n                    RemoteHook {\n                        id: \"validate-pyproject\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/crate-ci/typos\",\n                rev: \"v1.26.0\",\n                hooks: [\n                    RemoteHook {\n                        id: \"typos\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: Some(\n                            10,\n                        ),\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-fmt\",\n                        name: \"cargo fmt\",\n                        entry: \"cargo fmt --\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: Some(\n                                [\n                                    \"rust\",\n                                ],\n                            ),\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: Some(\n                                None,\n                            ),\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n        Local(\n            LocalRepo {\n                repo: \"local\",\n                hooks: [\n                    LocalHook {\n                        id: \"cargo-dev-generate-all\",\n                        name: \"cargo dev generate-all\",\n                        entry: \"cargo dev generate-all\",\n                        language: System,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: Some(\n                                Regex(\n                                    ^crates/(uv-cli|uv-settings)/,\n                                ),\n                            ),\n                            exclude: None,\n                            types: Some(\n                                [\n                                    \"rust\",\n                                ],\n                            ),\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: Some(\n                                None,\n                            ),\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/pre-commit/mirrors-prettier\",\n                rev: \"v3.1.0\",\n                hooks: [\n                    RemoteHook {\n                        id: \"prettier\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: Some(\n                                [\n                                    \"json5\",\n                                    \"yaml\",\n                                ],\n                            ),\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n        Remote(\n            RemoteRepo {\n                repo: \"https://github.com/astral-sh/ruff-pre-commit\",\n                rev: \"v0.6.9\",\n                hooks: [\n                    RemoteHook {\n                        id: \"ruff-format\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: None,\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                    RemoteHook {\n                        id: \"ruff\",\n                        name: None,\n                        entry: None,\n                        language: None,\n                        priority: None,\n                        options: HookOptions {\n                            alias: None,\n                            files: None,\n                            exclude: None,\n                            types: None,\n                            types_or: None,\n                            exclude_types: None,\n                            additional_dependencies: None,\n                            args: Some(\n                                [\n                                    \"--fix\",\n                                    \"--exit-non-zero-on-fix\",\n                                ],\n                            ),\n                            env: None,\n                            always_run: None,\n                            fail_fast: None,\n                            pass_filenames: None,\n                            description: None,\n                            language_version: None,\n                            log_file: None,\n                            require_serial: None,\n                            stages: None,\n                            verbose: None,\n                            minimum_prek_version: None,\n                            _unused_keys: {},\n                        },\n                    },\n                ],\n                _unused_keys: {},\n            },\n        ),\n    ],\n    default_install_hook_types: None,\n    default_language_version: None,\n    default_stages: None,\n    files: None,\n    exclude: Some(\n        Regex(\n            (?x)^(\n              .*/(snapshots)/.*|\n            )$\n            ,\n        ),\n    ),\n    fail_fast: Some(\n        true,\n    ),\n    minimum_prek_version: None,\n    orphan: None,\n    _unused_keys: {},\n}\n"
  },
  {
    "path": "crates/prek/src/store.rs",
    "content": "use std::hash::{DefaultHasher, Hash, Hasher};\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse anyhow::Result;\nuse etcetera::BaseStrategy;\nuse futures::StreamExt;\nuse rustc_hash::{FxHashMap, FxHashSet};\nuse thiserror::Error;\nuse tracing::{debug, warn};\n\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::config::RemoteRepo;\nuse crate::fs::LockedFile;\nuse crate::git::{self, TerminalPrompt};\nuse crate::hook::InstallInfo;\nuse crate::run::CONCURRENCY;\nuse crate::warn_user;\nuse crate::workspace::{HookInitReporter, WorkspaceCache};\n\nstruct PendingClone<'a> {\n    repo: &'a RemoteRepo,\n}\n\nenum FirstClonePass<'a> {\n    Ready {\n        repo: &'a RemoteRepo,\n        temp: tempfile::TempDir,\n        progress: Option<usize>,\n    },\n    AuthFailed {\n        repo: &'a RemoteRepo,\n        error: git::Error,\n        progress: Option<usize>,\n    },\n}\n\n#[derive(Debug, Error)]\npub enum Error {\n    #[error(\"Home directory not found\")]\n    HomeNotFound,\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n    #[error(\"Failed to clone repo `{repo}`\")]\n    CloneRepo {\n        repo: String,\n        #[source]\n        error: git::Error,\n    },\n    #[error(transparent)]\n    Serde(#[from] serde_json::Error),\n}\n\n/// Expand a path starting with `~` to the user's home directory.\nfn expand_tilde(path: PathBuf) -> PathBuf {\n    if let Ok(stripped) = path.strip_prefix(\"~\") {\n        if let Some(home) = std::env::home_dir() {\n            return home.join(stripped);\n        }\n    }\n    path\n}\n\npub(crate) const REPO_MARKER: &str = \".prek-repo.json\";\n\n/// A store for managing repos.\n#[derive(Debug)]\npub struct Store {\n    path: PathBuf,\n}\n\nimpl Store {\n    pub(crate) fn from_path(path: impl Into<PathBuf>) -> Self {\n        Self { path: path.into() }\n    }\n\n    /// Create a store from environment variables or default paths.\n    pub(crate) fn from_settings() -> Result<Self, Error> {\n        let path = if let Some(path) = EnvVars::var_os(EnvVars::PREK_HOME) {\n            Some(expand_tilde(PathBuf::from(path)))\n        } else {\n            etcetera::choose_base_strategy()\n                .map(|path| path.cache_dir().join(\"prek\"))\n                .ok()\n        };\n\n        let Some(path) = path else {\n            return Err(Error::HomeNotFound);\n        };\n        let store = Store::from_path(path).init()?;\n\n        Ok(store)\n    }\n\n    pub(crate) fn path(&self) -> &Path {\n        self.path.as_ref()\n    }\n\n    /// Initialize the store.\n    pub(crate) fn init(self) -> Result<Self, Error> {\n        fs_err::create_dir_all(&self.path)?;\n        fs_err::create_dir_all(self.repos_dir())?;\n        fs_err::create_dir_all(self.hooks_dir())?;\n        fs_err::create_dir_all(self.scratch_path())?;\n\n        match fs_err::OpenOptions::new()\n            .write(true)\n            .create_new(true)\n            .open(self.path.join(\"README\")) {\n            Ok(mut f) => f.write_all(b\"This directory is maintained by the prek project.\\nLearn more: https://github.com/j178/prek\\n\")?,\n            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => (),\n            Err(err) => return Err(err.into()),\n        }\n        Ok(self)\n    }\n\n    async fn clone_repo_to_temp(\n        &self,\n        repo: &RemoteRepo,\n        terminal_prompt: TerminalPrompt,\n    ) -> Result<tempfile::TempDir, git::Error> {\n        let temp = tempfile::tempdir_in(self.scratch_path())?;\n        debug!(\n            target = %temp.path().display(),\n            %repo,\n            ?terminal_prompt,\n            \"Cloning repo\"\n        );\n        git::clone_repo(&repo.repo, &repo.rev, temp.path(), terminal_prompt).await?;\n        Ok(temp)\n    }\n\n    async fn persist_cloned_repo(\n        &self,\n        repo: &RemoteRepo,\n        temp: tempfile::TempDir,\n    ) -> Result<PathBuf, Error> {\n        let target = self.repo_path(repo);\n\n        // TODO: add windows retry\n        fs_err::tokio::remove_dir_all(&target).await.ok();\n        fs_err::tokio::rename(temp, &target).await?;\n\n        let content = serde_json::to_string_pretty(&repo)?;\n        fs_err::tokio::write(target.join(REPO_MARKER), content).await?;\n\n        Ok(target)\n    }\n\n    /// Clone remote repositories into the store.\n    ///\n    /// The first pass runs in parallel with terminal prompts disabled. Repositories that fail\n    /// with an authentication error are retried afterwards, sequentially, with terminal prompts\n    /// enabled so the user can provide credentials for one repository at a time.\n    pub(crate) async fn clone_repos<'a>(\n        &self,\n        repos: impl IntoIterator<Item = &'a RemoteRepo>,\n        reporter: Option<&dyn HookInitReporter>,\n    ) -> Result<FxHashMap<RemoteRepo, PathBuf>, Error> {\n        #[expect(clippy::mutable_key_type)]\n        let mut cloned = FxHashMap::default();\n        let mut pending = Vec::new();\n\n        for repo in repos {\n            let target = self.repo_path(repo);\n            if target.join(REPO_MARKER).try_exists()? {\n                cloned.insert(repo.clone(), target);\n                continue;\n            }\n\n            pending.push(PendingClone { repo });\n        }\n\n        let mut auth_failed = Vec::new();\n        let mut tasks = futures::stream::iter(pending)\n            .map(async |pending| {\n                let progress =\n                    reporter.map(|reporter| reporter.on_clone_start(&format!(\"{}\", pending.repo)));\n                match self\n                    .clone_repo_to_temp(pending.repo, TerminalPrompt::Disabled)\n                    .await\n                {\n                    Ok(temp) => Ok(FirstClonePass::Ready {\n                        repo: pending.repo,\n                        temp,\n                        progress,\n                    }),\n                    Err(err) if git::is_auth_error(&err) => {\n                        warn!(\n                            repo = %pending.repo.repo,\n                            ?err,\n                            \"Clone failed with authentication error and terminal prompts disabled\"\n                        );\n                        Ok(FirstClonePass::AuthFailed {\n                            repo: pending.repo,\n                            error: err,\n                            progress,\n                        })\n                    }\n                    Err(err) => Err(Error::CloneRepo {\n                        repo: pending.repo.repo.clone(),\n                        error: err,\n                    }),\n                }\n            })\n            .buffer_unordered(*CONCURRENCY);\n\n        while let Some(result) = tasks.next().await {\n            match result? {\n                FirstClonePass::Ready {\n                    repo,\n                    temp,\n                    progress,\n                } => {\n                    let path = self.persist_cloned_repo(repo, temp).await?;\n                    if let (Some(reporter), Some(progress)) = (reporter, progress) {\n                        reporter.on_clone_complete(progress);\n                    }\n                    cloned.insert(repo.clone(), path);\n                }\n                FirstClonePass::AuthFailed {\n                    repo,\n                    error,\n                    progress,\n                } => {\n                    if let (Some(reporter), Some(progress)) = (reporter, progress) {\n                        reporter.on_clone_complete(progress);\n                    }\n                    auth_failed.push((repo, error));\n                }\n            }\n        }\n\n        if EnvVars::is_under_ci() {\n            // CI cannot answer interactive credential prompts, so surface the original auth\n            // failure instead of attempting the prompt-enabled retry path.\n            if let Some((repo, error)) = auth_failed.into_iter().next() {\n                return Err(Error::CloneRepo {\n                    repo: repo.repo.clone(),\n                    error,\n                });\n            }\n\n            return Ok(cloned);\n        }\n\n        if !auth_failed.is_empty() {\n            // Tear down the shared MultiProgress before warning/prompt output so progress redraws\n            // do not overwrite terminal messages or git credential prompts.\n            reporter.map(HookInitReporter::on_complete);\n        }\n\n        for (repo, _error) in auth_failed {\n            warn_user!(\n                \"Authentication may be required to clone repository `{}`. Retrying with terminal prompts enabled.\",\n                repo.repo\n            );\n            let temp = self\n                .clone_repo_to_temp(repo, TerminalPrompt::Enabled)\n                .await\n                .map_err(|error| Error::CloneRepo {\n                    repo: repo.repo.clone(),\n                    error,\n                })?;\n            let path = self.persist_cloned_repo(repo, temp).await?;\n            cloned.insert(repo.clone(), path);\n        }\n\n        Ok(cloned)\n    }\n\n    /// Clone a single remote repository into the store.\n    pub(crate) async fn clone_repo(\n        &self,\n        repo: &RemoteRepo,\n        reporter: Option<&dyn HookInitReporter>,\n    ) -> Result<PathBuf, Error> {\n        #[expect(clippy::mutable_key_type)]\n        let cloned = self.clone_repos(std::iter::once(repo), reporter).await?;\n        cloned.get(repo).cloned().ok_or_else(|| Error::CloneRepo {\n            repo: repo.repo.clone(),\n            error: git::Error::Io(std::io::Error::other(\"repo was not cloned\")),\n        })\n    }\n\n    /// Returns installed hooks in the store.\n    pub(crate) async fn installed_hooks(&self) -> Vec<Arc<InstallInfo>> {\n        let Ok(dirs) = fs_err::read_dir(self.hooks_dir()) else {\n            return vec![];\n        };\n\n        let mut tasks = futures::stream::iter(dirs)\n            .map(async |entry| {\n                let path = match entry {\n                    Ok(entry) => entry.path(),\n                    Err(err) => {\n                        warn!(%err, \"Failed to read hook dir\");\n                        return None;\n                    }\n                };\n                let info = match InstallInfo::from_env_path(&path).await {\n                    Ok(info) => info,\n                    Err(err) => {\n                        warn!(%err, path = %path.display(), \"Skipping invalid installed hook\");\n                        return None;\n                    }\n                };\n                Some(info)\n            })\n            .buffer_unordered(*CONCURRENCY);\n\n        let mut hooks = Vec::new();\n        while let Some(hook) = tasks.next().await {\n            if let Some(hook) = hook {\n                hooks.push(Arc::new(hook));\n            }\n        }\n\n        hooks\n    }\n\n    pub(crate) async fn lock_async(&self) -> Result<LockedFile, std::io::Error> {\n        LockedFile::acquire(self.path.join(\".lock\"), \"store\").await\n    }\n\n    /// Returns the path to where a remote repo would be stored.\n    pub(crate) fn repo_path(&self, repo: &RemoteRepo) -> PathBuf {\n        self.repos_dir().join(Self::repo_key(repo))\n    }\n\n    /// Returns the store key (directory name) for a remote repo.\n    pub(crate) fn repo_key(repo: &RemoteRepo) -> String {\n        let mut hasher = DefaultHasher::new();\n        repo.hash(&mut hasher);\n        to_hex(hasher.finish())\n    }\n\n    pub(crate) fn repos_dir(&self) -> PathBuf {\n        self.path.join(\"repos\")\n    }\n\n    pub(crate) fn hooks_dir(&self) -> PathBuf {\n        self.path.join(\"hooks\")\n    }\n\n    pub(crate) fn patches_dir(&self) -> PathBuf {\n        self.path.join(\"patches\")\n    }\n\n    pub(crate) fn tools_dir(&self) -> PathBuf {\n        self.path.join(\"tools\")\n    }\n\n    pub(crate) fn cache_dir(&self) -> PathBuf {\n        self.path.join(\"cache\")\n    }\n\n    /// The path to the tool directory in the store.\n    pub(crate) fn tools_path(&self, tool: ToolBucket) -> PathBuf {\n        self.tools_dir().join(tool.as_ref())\n    }\n\n    pub(crate) fn cache_path(&self, tool: CacheBucket) -> PathBuf {\n        self.cache_dir().join(tool.as_ref())\n    }\n\n    /// Scratch path for temporary files.\n    pub(crate) fn scratch_path(&self) -> PathBuf {\n        self.path.join(\"scratch\")\n    }\n\n    pub(crate) fn log_file(&self) -> PathBuf {\n        self.path.join(\"prek.log\")\n    }\n\n    pub(crate) fn config_tracking_file(&self) -> PathBuf {\n        self.path.join(\"config-tracking.json\")\n    }\n\n    /// Get all tracked config files.\n    ///\n    /// Seed `config-tracking.json` from the workspace discovery cache if it doesn't exist.\n    /// This is a one-time upgrade helper: it only does work when tracking is empty.\n    pub(crate) fn tracked_configs(&self) -> Result<FxHashSet<PathBuf>, Error> {\n        let tracking_file = self.config_tracking_file();\n        match fs_err::read_to_string(&tracking_file) {\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}\n            Err(e) => return Err(e.into()),\n            Ok(content) => {\n                let tracked = serde_json::from_str(&content).unwrap_or_else(|e| {\n                    warn!(\"Failed to parse config tracking file: {e}, resetting\");\n                    FxHashSet::default()\n                });\n                return Ok(tracked);\n            }\n        }\n\n        let cached = WorkspaceCache::cached_config_paths(self);\n        if cached.is_empty() {\n            return Ok(FxHashSet::default());\n        }\n\n        debug!(\n            count = cached.len(),\n            \"Bootstrapping config tracking from workspace cache\"\n        );\n        self.update_tracked_configs(&cached)?;\n\n        Ok(cached)\n    }\n\n    /// Track new config files for GC.\n    pub(crate) fn track_configs<'a>(\n        &self,\n        config_paths: impl Iterator<Item = &'a Path>,\n    ) -> Result<(), Error> {\n        let mut tracked = self.tracked_configs()?;\n        for config_path in config_paths {\n            tracked.insert(config_path.to_path_buf());\n        }\n\n        let tracking_file = self.config_tracking_file();\n        let content = serde_json::to_string_pretty(&tracked)?;\n        fs_err::write(&tracking_file, content)?;\n\n        Ok(())\n    }\n\n    /// Update the tracked configs file.\n    pub(crate) fn update_tracked_configs(&self, configs: &FxHashSet<PathBuf>) -> Result<(), Error> {\n        let tracking_file = self.config_tracking_file();\n        let content = serde_json::to_string_pretty(configs)?;\n        fs_err::write(&tracking_file, content)?;\n\n        Ok(())\n    }\n}\n\n#[derive(Copy, Clone, Eq, Hash, PartialEq, strum::EnumIter, strum::AsRefStr, strum::Display)]\n#[strum(serialize_all = \"lowercase\")]\npub(crate) enum ToolBucket {\n    Uv,\n    Python,\n    Node,\n    Go,\n    Ruby,\n    Rustup,\n    Bun,\n    Deno,\n}\n\n#[derive(Copy, Clone, Eq, Hash, PartialEq, strum::AsRefStr, strum::Display)]\n#[strum(serialize_all = \"lowercase\")]\npub(crate) enum CacheBucket {\n    Uv,\n    Go,\n    Python,\n    Cargo,\n    Deno,\n    Prek,\n}\n\n/// Convert a u64 to a hex string.\nfn to_hex(num: u64) -> String {\n    hex::encode(num.to_le_bytes())\n}\n"
  },
  {
    "path": "crates/prek/src/version.rs",
    "content": "/* MIT License\r\n\r\nCopyright (c) 2023 Astral Software Inc.\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\n*/\r\n\r\n// See also <https://github.com/astral-sh/ruff/blob/8118d29419055b779719cc96cdf3dacb29ac47c9/crates/ruff/src/version.rs>\r\nuse std::fmt;\r\n\r\nuse serde::Serialize;\r\n\r\n/// Information about the git repository where prek was built from.\r\n#[derive(Serialize)]\r\npub(crate) struct CommitInfo {\r\n    pub(crate) short_commit_hash: String,\r\n    pub(crate) commit_hash: String,\r\n    pub(crate) commit_date: String,\r\n    pub(crate) last_tag: Option<String>,\r\n    pub(crate) commits_since_last_tag: u32,\r\n}\r\n\r\n/// prek's version.\r\n#[derive(Serialize)]\r\npub(crate) struct VersionInfo {\r\n    /// prek's version, such as \"0.0.6\"\r\n    pub(crate) version: String,\r\n    /// Information about the git commit we may have been built from.\r\n    ///\r\n    /// `None` if not built from a git repo or if retrieval failed.\r\n    pub(crate) commit_info: Option<CommitInfo>,\r\n}\r\n\r\nimpl fmt::Display for VersionInfo {\r\n    /// Formatted version information: \"<version>[+<commits>] (<commit> <date>)\"\r\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\r\n        write!(f, \"{}\", self.version)?;\r\n\r\n        if let Some(ref ci) = self.commit_info {\r\n            if ci.commits_since_last_tag > 0 {\r\n                write!(f, \"+{}\", ci.commits_since_last_tag)?;\r\n            }\r\n            write!(f, \" ({} {})\", ci.short_commit_hash, ci.commit_date)?;\r\n        }\r\n\r\n        Ok(())\r\n    }\r\n}\r\n\r\nimpl From<VersionInfo> for clap::builder::Str {\r\n    fn from(val: VersionInfo) -> Self {\r\n        val.to_string().into()\r\n    }\r\n}\r\n\r\n/// Returns information about prek's version.\r\npub fn version() -> VersionInfo {\r\n    // Environment variables are only read at compile-time\r\n    macro_rules! option_env_str {\r\n        ($name:expr) => {\r\n            option_env!($name).map(|s| s.to_string())\r\n        };\r\n    }\r\n\r\n    // This version is pulled from Cargo.toml and set by Cargo\r\n    let version = env!(\"CARGO_PKG_VERSION\").to_string();\r\n\r\n    // Commit info is pulled from git and set by `build.rs`\r\n    let commit_info = option_env_str!(\"PREK_COMMIT_HASH\").map(|commit_hash| CommitInfo {\r\n        short_commit_hash: option_env_str!(\"PREK_COMMIT_SHORT_HASH\").unwrap(),\r\n        commit_hash,\r\n        commit_date: option_env_str!(\"PREK_COMMIT_DATE\").unwrap(),\r\n        last_tag: option_env_str!(\"PREK_LAST_TAG\"),\r\n        commits_since_last_tag: option_env_str!(\"PREK_LAST_TAG_DISTANCE\")\r\n            .as_deref()\r\n            .map_or(0, |value| value.parse::<u32>().unwrap_or(0)),\r\n    });\r\n\r\n    VersionInfo {\r\n        version,\r\n        commit_info,\r\n    }\r\n}\r\n"
  },
  {
    "path": "crates/prek/src/warnings.rs",
    "content": "// MIT License\n//\n// Copyright (c) 2023 Astral Software Inc.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nuse std::collections::HashSet;\nuse std::sync::atomic::AtomicBool;\nuse std::sync::{LazyLock, Mutex};\n\n// macro hygiene: The user might not have direct dependencies on those crates\n#[doc(hidden)]\npub use anstream;\n#[doc(hidden)]\npub use owo_colors;\n\n/// Whether user-facing warnings are enabled.\npub static ENABLED: AtomicBool = AtomicBool::new(false);\n\n/// Enable user-facing warnings.\npub fn enable() {\n    ENABLED.store(true, std::sync::atomic::Ordering::SeqCst);\n}\n\n/// Disable user-facing warnings.\npub fn disable() {\n    ENABLED.store(false, std::sync::atomic::Ordering::SeqCst);\n}\n\n/// Warn a user, if warnings are enabled.\n#[macro_export]\nmacro_rules! warn_user {\n    ($($arg:tt)*) => {\n        use $crate::warnings::anstream::eprintln;\n        use $crate::warnings::owo_colors::OwoColorize;\n\n        if $crate::warnings::ENABLED.load(std::sync::atomic::Ordering::SeqCst) {\n            let message = format!(\"{}\", format_args!($($arg)*));\n            let formatted = message.bold();\n            eprintln!(\"{}{} {formatted}\", \"warning\".yellow().bold(), \":\".bold());\n        }\n    };\n}\n\npub static WARNINGS: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(Mutex::default);\n\n/// Warn a user once, if warnings are enabled, with uniqueness determined by the content of the\n/// message.\n#[macro_export]\nmacro_rules! warn_user_once {\n    ($($arg:tt)*) => {\n        use $crate::warnings::anstream::eprintln;\n        use $crate::warnings::owo_colors::OwoColorize;\n\n        if $crate::warnings::ENABLED.load(std::sync::atomic::Ordering::SeqCst) {\n            if let Ok(mut states) = $crate::warnings::WARNINGS.lock() {\n                let message = format!(\"{}\", format_args!($($arg)*));\n                if states.insert(message.clone()) {\n                    eprintln!(\"{}{} {}\", \"warning\".yellow().bold(), \":\".bold(), message.bold());\n                }\n            }\n        }\n    };\n}\n"
  },
  {
    "path": "crates/prek/src/workspace.rs",
    "content": "use std::borrow::Cow;\nuse std::fmt::Display;\nuse std::hash::{DefaultHasher, Hash, Hasher};\nuse std::path::{Path, PathBuf};\nuse std::sync::{Arc, Mutex};\nuse std::time::SystemTime;\n\nuse anyhow::Result;\nuse ignore::WalkState;\nuse itertools::zip_eq;\nuse owo_colors::OwoColorize;\nuse prek_consts::CONFIG_FILENAMES;\nuse rustc_hash::{FxHashMap, FxHashSet};\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse tracing::{debug, error, instrument, trace};\n\nuse crate::cli::run::Selectors;\nuse crate::config::{self, Config, read_config};\nuse crate::fs::Simplified;\nuse crate::git::GIT_ROOT;\nuse crate::hook::HookSpec;\nuse crate::hook::{self, Hook, HookBuilder, Repo};\nuse crate::store::{CacheBucket, Store};\nuse crate::{git, store, warn_user};\n\n#[derive(Error, Debug)]\npub(crate) enum Error {\n    #[error(transparent)]\n    Config(#[from] config::Error),\n\n    #[error(transparent)]\n    Hook(#[from] hook::Error),\n\n    #[error(transparent)]\n    Git(#[from] anyhow::Error),\n\n    #[error(\n        \"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.\",\n        \"hint:\".yellow().bold(),\n    )]\n    MissingConfigFile,\n\n    #[error(\"Hook `{hook}` not present in repo `{repo}`\")]\n    HookNotFound { hook: String, repo: String },\n\n    #[error(transparent)]\n    Store(#[from] store::Error),\n}\n\npub(crate) trait HookInitReporter {\n    fn on_clone_start(&self, repo: &str) -> usize;\n    fn on_clone_complete(&self, id: usize);\n    fn on_complete(&self);\n}\n\n#[derive(Clone)]\npub(crate) struct Project {\n    /// The absolute path of the project directory.\n    root: PathBuf,\n    /// The absolute path of the configuration file.\n    config_path: PathBuf,\n    /// The relative path of the project directory from the git root.\n    relative_path: PathBuf,\n    // The order index of the project in the workspace.\n    idx: usize,\n    config: Config,\n    repos: Vec<Arc<Repo>>,\n}\n\nimpl std::fmt::Debug for Project {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"Project\")\n            .field(\"relative_path\", &self.relative_path)\n            .field(\"idx\", &self.idx)\n            .field(\"config\", &self.config)\n            .field(\"repos\", &self.repos)\n            .finish_non_exhaustive()\n    }\n}\n\nimpl Display for Project {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        if self.is_root() {\n            write!(f, \".\")\n        } else {\n            write!(f, \"{}\", self.relative_path.display())\n        }\n    }\n}\n\nimpl PartialEq for Project {\n    fn eq(&self, other: &Self) -> bool {\n        self.config_path == other.config_path\n    }\n}\n\nimpl Eq for Project {}\n\nimpl Hash for Project {\n    fn hash<H: Hasher>(&self, state: &mut H) {\n        self.config_path.hash(state);\n    }\n}\n\nimpl Project {\n    /// Initialize a new project from the configuration file with an optional root path.\n    /// If root is not given, it will be the parent directory of the configuration file.\n    pub(crate) fn from_config_file(\n        config_path: Cow<'_, Path>,\n        root: Option<PathBuf>,\n    ) -> Result<Self, Error> {\n        debug!(\n            path = %config_path.user_display(),\n            \"Loading project configuration\"\n        );\n\n        let mut config = read_config(&config_path)?;\n        let size = config.repos.len();\n\n        let config_dir = config_path\n            .parent()\n            .expect(\"config file must have a parent\");\n\n        // Resolve relative repo paths against the config file's directory.\n        // This ensures paths like `../hook-repo` are resolved from where the\n        // config file lives, not from the process's current working directory.\n        for repo in &mut config.repos {\n            if let config::Repo::Remote(remote) = repo {\n                let repo_path = Path::new(&remote.repo);\n                if !remote.repo.starts_with(\"http://\")\n                    && !remote.repo.starts_with(\"https://\")\n                    && repo_path.is_relative()\n                {\n                    let resolved = config_dir.join(repo_path);\n                    if resolved.is_dir() {\n                        remote.repo = resolved.to_string_lossy().into_owned();\n                    }\n                }\n            }\n        }\n\n        let root = root.unwrap_or_else(|| config_dir.to_path_buf());\n\n        Ok(Self {\n            root,\n            config,\n            config_path: config_path.into_owned(),\n            idx: 0,\n            relative_path: PathBuf::new(),\n            repos: Vec::with_capacity(size),\n        })\n    }\n\n    fn find_config(path: &Path) -> Option<PathBuf> {\n        for name in CONFIG_FILENAMES {\n            let file = path.join(name);\n            if file.is_file() {\n                return Some(file);\n            }\n        }\n        None\n    }\n\n    fn find_all_configs(path: &Path) -> Vec<(&'static str, PathBuf)> {\n        let mut configs = Vec::new();\n        for &name in CONFIG_FILENAMES {\n            let file = path.join(name);\n            if file.is_file() {\n                configs.push((name, file));\n            }\n        }\n        configs\n    }\n\n    /// Find the configuration file in the given path.\n    pub(crate) fn from_directory(path: &Path) -> Result<Self, Error> {\n        let present = Self::find_all_configs(path);\n\n        let Some((_, selected)) = present.first() else {\n            return Err(Error::MissingConfigFile);\n        };\n\n        if present.len() > 1 {\n            let found = present\n                .iter()\n                .map(|(name, _)| format!(\"`{name}`\"))\n                .collect::<Vec<_>>()\n                .join(\", \");\n            warn_user!(\n                \"Multiple configuration files found ({found}); using `{selected}`\",\n                found = found,\n                selected = selected.display(),\n            );\n        }\n\n        Self::from_config_file(Cow::Borrowed(selected), None)\n    }\n\n    /// Discover a project from the give path or search from the given path to the git root.\n    pub(crate) fn discover(config_file: Option<&Path>, dir: &Path) -> Result<Project, Error> {\n        let git_root = GIT_ROOT.as_ref().map_err(|e| Error::Git(e.into()))?;\n\n        if let Some(config) = config_file {\n            return Project::from_config_file(config.into(), Some(git_root.clone()));\n        }\n\n        let workspace_root = Workspace::find_root(None, dir)?;\n        debug!(\"Found project root at `{}`\", workspace_root.user_display());\n\n        Project::from_directory(&workspace_root)\n    }\n\n    pub(crate) fn with_relative_path(&mut self, relative_path: PathBuf) {\n        self.relative_path = relative_path;\n    }\n\n    fn with_idx(&mut self, idx: usize) {\n        self.idx = idx;\n    }\n\n    pub(crate) fn config(&self) -> &Config {\n        &self.config\n    }\n\n    /// Get the path to the configuration file.\n    /// Must be an absolute path.\n    pub(crate) fn config_file(&self) -> &Path {\n        &self.config_path\n    }\n\n    /// Get the path to the project directory.\n    pub(crate) fn path(&self) -> &Path {\n        &self.root\n    }\n\n    /// Get the path to the project directory relative to the workspace root.\n    ///\n    /// Hooks will be executed in this directory and accept only files from this directory.\n    /// In non-workspace mode (`--config <path>`), this is empty.\n    pub(crate) fn relative_path(&self) -> &Path {\n        &self.relative_path\n    }\n\n    pub(crate) fn is_root(&self) -> bool {\n        self.relative_path.as_os_str().is_empty()\n    }\n\n    pub(crate) fn depth(&self) -> usize {\n        self.relative_path.components().count()\n    }\n\n    pub(crate) fn idx(&self) -> usize {\n        self.idx\n    }\n\n    /// Initialize the project, cloning the repository and preparing hooks.\n    pub(crate) async fn init_hooks(\n        &mut self,\n        store: &Store,\n        reporter: Option<&dyn HookInitReporter>,\n    ) -> Result<Vec<Hook>, Error> {\n        self.init_repos(store, reporter).await?;\n        // TODO: avoid clone\n        let project = Arc::new(self.clone());\n\n        let hooks = project.internal_init_hooks().await?;\n\n        Ok(hooks)\n    }\n\n    /// Initialize remote repositories for the project.\n    #[allow(clippy::mutable_key_type)]\n    async fn init_repos(\n        &mut self,\n        store: &Store,\n        reporter: Option<&dyn HookInitReporter>,\n    ) -> Result<(), Error> {\n        let mut seen = FxHashSet::default();\n\n        // Prepare remote repos in parallel.\n        let remotes_iter = self.config.repos.iter().filter_map(|repo| match repo {\n            // Deduplicate remote repos.\n            config::Repo::Remote(repo) if seen.insert(repo) => Some(repo),\n            _ => None,\n        });\n        let cloned_repos = store.clone_repos(remotes_iter, reporter).await?;\n\n        let mut remote_repos = FxHashMap::default();\n        for (repo_config, path) in cloned_repos {\n            let repo = Arc::new(Repo::remote(\n                repo_config.repo.clone(),\n                repo_config.rev.clone(),\n                path,\n            )?);\n            remote_repos.insert(repo_config, repo);\n        }\n\n        let mut repos = Vec::with_capacity(self.config.repos.len());\n\n        for repo in &self.config.repos {\n            match repo {\n                config::Repo::Remote(repo) => {\n                    let repo = remote_repos.get(repo).expect(\"repo not found\");\n                    repos.push(repo.clone());\n                }\n                config::Repo::Local(repo) => {\n                    let repo = Repo::local(repo.hooks.clone());\n                    repos.push(Arc::new(repo));\n                }\n                config::Repo::Meta(repo) => {\n                    let repo = Repo::meta(repo.hooks.clone());\n                    repos.push(Arc::new(repo));\n                }\n                config::Repo::Builtin(repo) => {\n                    let repo = Repo::builtin(repo.hooks.clone());\n                    repos.push(Arc::new(repo));\n                }\n            }\n        }\n\n        self.repos = repos;\n\n        Ok(())\n    }\n\n    /// Load and prepare hooks for the project.\n    async fn internal_init_hooks(self: Arc<Self>) -> Result<Vec<Hook>, Error> {\n        let mut hooks = Vec::new();\n\n        for (repo_config, repo) in zip_eq(self.config.repos.iter(), self.repos.iter()) {\n            match repo_config {\n                config::Repo::Remote(repo_config) => {\n                    for hook_config in &repo_config.hooks {\n                        // Check hook id is valid.\n                        let Some(manifest_hook) = repo.get_hook(&hook_config.id) else {\n                            return Err(Error::HookNotFound {\n                                hook: hook_config.id.clone(),\n                                repo: repo.to_string(),\n                            });\n                        };\n\n                        let mut hook_spec = manifest_hook.clone();\n                        hook_spec.apply_remote_hook_overrides(hook_config);\n\n                        let builder = HookBuilder::new(\n                            self.clone(),\n                            Arc::clone(repo),\n                            hook_spec,\n                            hooks.len(),\n                        );\n                        let hook = builder.build().await?;\n\n                        hooks.push(hook);\n                    }\n                }\n                config::Repo::Local(repo_config) => {\n                    for hook_config in &repo_config.hooks {\n                        let hook_spec = HookSpec::from(hook_config.clone());\n\n                        let builder = HookBuilder::new(\n                            self.clone(),\n                            Arc::clone(repo),\n                            hook_spec,\n                            hooks.len(),\n                        );\n                        let hook = builder.build().await?;\n\n                        hooks.push(hook);\n                    }\n                }\n                config::Repo::Meta(repo_config) => {\n                    for hook_config in &repo_config.hooks {\n                        let hook_spec = HookSpec::from(hook_config.clone());\n\n                        let builder = HookBuilder::new(\n                            self.clone(),\n                            Arc::clone(repo),\n                            hook_spec,\n                            hooks.len(),\n                        );\n                        let hook = builder.build().await?;\n\n                        hooks.push(hook);\n                    }\n                }\n                config::Repo::Builtin(repo_config) => {\n                    for hook_config in &repo_config.hooks {\n                        let hook_spec = HookSpec::from(hook_config.clone());\n\n                        let builder = HookBuilder::new(\n                            self.clone(),\n                            Arc::clone(repo),\n                            hook_spec,\n                            hooks.len(),\n                        );\n                        let hook = builder.build().await?;\n\n                        hooks.push(hook);\n                    }\n                }\n            }\n        }\n\n        Ok(hooks)\n    }\n}\n\n/// Cache entry for a project configuration file\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct CachedConfigFile {\n    /// Absolute path to the config file\n    path: PathBuf,\n    /// Last modification time\n    modified: SystemTime,\n    /// File size for quick change detection\n    size: u64,\n}\n\n/// Workspace discovery cache\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub(crate) struct WorkspaceCache {\n    /// Cache version for compatibility\n    version: u32,\n    /// Workspace root path\n    workspace_root: PathBuf,\n    /// Cache creation timestamp\n    created_at: SystemTime,\n    /// Configuration files with their metadata\n    config_files: Vec<CachedConfigFile>,\n}\n\nimpl WorkspaceCache {\n    const CURRENT_VERSION: u32 = 1;\n    /// Maximum cache age before forcing rediscovery (1 hour)\n    const MAX_CACHE_AGE: u64 = 60 * 60;\n\n    /// Create a new cache from workspace discovery results\n    fn new(workspace_root: PathBuf, projects: &[Project]) -> Self {\n        let mut config_files = Vec::new();\n\n        for project in projects {\n            if let Ok(metadata) = std::fs::metadata(&project.config_path) {\n                config_files.push(CachedConfigFile {\n                    path: project.config_path.clone(),\n                    modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),\n                    size: metadata.len(),\n                });\n            }\n        }\n\n        Self {\n            version: Self::CURRENT_VERSION,\n            created_at: SystemTime::now(),\n            workspace_root,\n            config_files,\n        }\n    }\n\n    /// Check if the cache is still valid\n    fn is_valid(&self) -> bool {\n        // Check cache age - invalidate if older than MAX_CACHE_AGE\n        if let Ok(elapsed) = self.created_at.elapsed() {\n            if elapsed.as_secs() > Self::MAX_CACHE_AGE {\n                debug!(\n                    \"Cache is too old ({}s > {}s), invalidating\",\n                    elapsed.as_secs(),\n                    Self::MAX_CACHE_AGE\n                );\n                return false;\n            }\n        }\n\n        // Check if all config files still exist and haven't been modified\n        for cached_file in &self.config_files {\n            if let Ok(metadata) = std::fs::metadata(&cached_file.path) {\n                let current_modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);\n                let current_size = metadata.len();\n\n                if current_modified != cached_file.modified || current_size != cached_file.size {\n                    debug!(\n                        path = %cached_file.path.display(),\n                        \"Config file changed, invalidating cache\"\n                    );\n                    return false;\n                }\n            } else {\n                debug!(\n                    path = %cached_file.path.display(),\n                    \"Config file no longer exists, invalidating cache\"\n                );\n                return false;\n            }\n        }\n\n        // Check if workspace root still exists\n        if !self.workspace_root.exists() {\n            debug!(\"Workspace root no longer exists, invalidating cache\");\n            return false;\n        }\n\n        // Note: We don't check for newly added config files here to avoid\n        // expensive directory traversal. New files will be detected when\n        // the cache fails to load a project during cache restoration,\n        // or when the cache expires due to age (every hour).\n\n        true\n    }\n\n    /// Get cache file path for a workspace\n    fn cache_path(store: &Store, workspace_root: &Path) -> PathBuf {\n        let mut hasher = DefaultHasher::new();\n        workspace_root.hash(&mut hasher);\n        let digest = hex::encode(hasher.finish().to_le_bytes());\n\n        store\n            .cache_path(CacheBucket::Prek)\n            .join(\"workspace\")\n            .join(digest)\n    }\n\n    /// Load cache from file\n    fn load(store: &Store, workspace_root: &Path, refresh: bool) -> Option<Self> {\n        if refresh {\n            return None;\n        }\n        let cache_path = Self::cache_path(store, workspace_root);\n\n        match std::fs::read_to_string(&cache_path) {\n            Ok(content) => match serde_json::from_str::<Self>(&content) {\n                Ok(cache) => {\n                    if cache.version == Self::CURRENT_VERSION && cache.is_valid() {\n                        Some(cache)\n                    } else {\n                        // Invalid cache, remove it\n                        let _ = std::fs::remove_file(&cache_path);\n                        None\n                    }\n                }\n                Err(e) => {\n                    debug!(\"Failed to deserialize cache: {}\", e);\n                    let _ = std::fs::remove_file(&cache_path);\n                    None\n                }\n            },\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,\n            Err(e) => {\n                debug!(\"Failed to read cache file: {}\", e);\n                None\n            }\n        }\n    }\n\n    /// Save cache to file\n    fn save(&self, store: &Store) -> Result<()> {\n        let cache_path = Self::cache_path(store, &self.workspace_root);\n\n        // Create cache directory if it doesn't exist\n        if let Some(parent) = cache_path.parent() {\n            std::fs::create_dir_all(parent)?;\n        }\n\n        let content = serde_json::to_string_pretty(self)?;\n        std::fs::write(&cache_path, content)?;\n        Ok(())\n    }\n\n    /// Best-effort source of config paths for bootstrapping config tracking.\n    ///\n    /// This is used on upgrades from older versions that didn't track configs yet.\n    /// It reads all cached workspace discovery entries under `cache/prek/workspace/*`\n    /// and collects any config file paths they mention.\n    pub(crate) fn cached_config_paths(store: &Store) -> FxHashSet<PathBuf> {\n        let mut paths: FxHashSet<PathBuf> = FxHashSet::default();\n\n        let workspace_cache_root = store.cache_path(CacheBucket::Prek).join(\"workspace\");\n        let entries = match fs_err::read_dir(&workspace_cache_root) {\n            Ok(entries) => entries,\n            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return paths,\n            Err(err) => {\n                debug!(path = %workspace_cache_root.display(), %err, \"Failed to read workspace cache directory for tracking bootstrap\");\n                return paths;\n            }\n        };\n\n        for entry in entries {\n            let entry = match entry {\n                Ok(entry) => entry,\n                Err(err) => {\n                    debug!(%err, \"Failed to read workspace cache entry for tracking bootstrap\");\n                    continue;\n                }\n            };\n\n            let path = entry.path();\n            if !path.is_file() {\n                continue;\n            }\n\n            let content = match fs_err::read_to_string(&path) {\n                Ok(content) => content,\n                Err(err) => {\n                    debug!(path = %path.display(), %err, \"Failed to read workspace cache file for tracking bootstrap\");\n                    continue;\n                }\n            };\n\n            let cache: WorkspaceCache = match serde_json::from_str(&content) {\n                Ok(cache) => cache,\n                Err(err) => {\n                    debug!(path = %path.display(), %err, \"Failed to parse workspace cache file for tracking bootstrap\");\n                    continue;\n                }\n            };\n\n            if cache.version != WorkspaceCache::CURRENT_VERSION {\n                continue;\n            }\n\n            for file in cache.config_files {\n                paths.insert(file.path);\n            }\n        }\n\n        paths\n    }\n}\n\npub(crate) struct Workspace {\n    root: PathBuf,\n    projects: Vec<Arc<Project>>,\n    all_projects: Vec<Project>,\n}\n\nimpl Workspace {\n    /// Find the workspace root.\n    /// `dir` must be an absolute path.\n    pub(crate) fn find_root(config_file: Option<&Path>, dir: &Path) -> Result<PathBuf, Error> {\n        let git_root = GIT_ROOT.as_ref().map_err(|e| Error::Git(e.into()))?;\n\n        if config_file.is_some() {\n            // For `--config <path>`, the workspace root is the git root.\n            return Ok(git_root.clone());\n        }\n\n        // Walk from the given path up to the git root, to find the workspace root.\n        let workspace_root = dir\n            .ancestors()\n            .take_while(|p| git_root.parent().map(|root| *p != root).unwrap_or(true))\n            .find(|p| Project::find_config(p).is_some())\n            .ok_or(Error::MissingConfigFile)?\n            .to_path_buf();\n\n        debug!(\"Found workspace root at `{}`\", workspace_root.display());\n        Ok(workspace_root)\n    }\n\n    /// Discover the workspace from the given workspace root.\n    #[instrument(level = \"trace\", skip(store, selectors))]\n    pub(crate) fn discover(\n        store: &Store,\n        root: PathBuf,\n        config: Option<PathBuf>,\n        selectors: Option<&Selectors>,\n        refresh: bool,\n    ) -> Result<Self, Error> {\n        if let Some(config) = config {\n            let project = Project::from_config_file(config.into(), Some(root.clone()))?;\n            let arc_project = Arc::new(project.clone());\n            return Ok(Self {\n                root,\n                projects: vec![arc_project],\n                all_projects: vec![project],\n            });\n        }\n\n        // Try to load from cache first\n        let projects = if let Some(cache) = WorkspaceCache::load(store, &root, refresh) {\n            debug!(\"Loaded workspace from cache\");\n            let projects: Result<Vec<_>, _> = cache\n                .config_files\n                .into_iter()\n                .map(\n                    |config_file| match Project::from_config_file(config_file.path.into(), None) {\n                        Ok(mut project) => {\n                            let relative_path = project\n                                .config_file()\n                                .parent()\n                                .and_then(|p| p.strip_prefix(&root).ok())\n                                .expect(\"Entry path should be relative to the root\")\n                                .to_path_buf();\n                            project.with_relative_path(relative_path);\n                            Ok(project)\n                        }\n                        Err(e) => {\n                            debug!(\"Failed to load cached project config: {}\", e);\n                            Err(e)\n                        }\n                    },\n                )\n                .collect();\n\n            match projects {\n                Ok(projects) if !projects.is_empty() => Some(projects),\n                _ => {\n                    debug!(\"Cache invalid or empty, performing fresh discovery\");\n                    None\n                }\n            }\n        } else {\n            None\n        };\n\n        let mut all_projects = if let Some(projects) = projects {\n            projects\n        } else {\n            // Cache miss or invalid, perform fresh discovery\n            debug!(\"Performing fresh workspace discovery\");\n            let projects = Self::discover_fresh(&root, selectors)?;\n\n            // Save to cache\n            let cache = WorkspaceCache::new(root.clone(), &projects);\n            if let Err(e) = cache.save(store) {\n                debug!(\"Failed to save workspace cache: {}\", e);\n            }\n            projects\n        };\n\n        Self::sort_and_index_projects(&mut all_projects);\n\n        let projects = if let Some(selectors) = selectors {\n            let selected = all_projects\n                .iter()\n                .filter(|p| selectors.matches_path(p.relative_path()))\n                .cloned()\n                .map(Arc::new)\n                .collect::<Vec<_>>();\n            if selected.is_empty() {\n                return Err(Error::MissingConfigFile);\n            }\n            selected\n        } else {\n            all_projects\n                .iter()\n                .cloned()\n                .map(Arc::new)\n                .collect::<Vec<_>>()\n        };\n\n        if projects.is_empty() {\n            return Err(Error::MissingConfigFile);\n        }\n\n        Ok(Self {\n            root,\n            projects,\n            all_projects,\n        })\n    }\n\n    /// Perform fresh workspace discovery without cache\n    fn discover_fresh(root: &Path, selectors: Option<&Selectors>) -> Result<Vec<Project>, Error> {\n        let projects = Mutex::new(Ok(Vec::new()));\n\n        let git_root = GIT_ROOT.as_ref().map_err(|e| Error::Git(e.into()))?;\n        let submodules = git::list_submodules(git_root).unwrap_or_else(|e| {\n            error!(\"Failed to list git submodules: {e}\");\n            Vec::new()\n        });\n\n        ignore::WalkBuilder::new(root)\n            .follow_links(false)\n            .add_custom_ignore_filename(\".prekignore\")\n            .build_parallel()\n            .run(|| {\n                Box::new(|result| {\n                    let Ok(entry) = result else {\n                        return WalkState::Continue;\n                    };\n                    let Some(file_type) = entry.file_type() else {\n                        return WalkState::Continue;\n                    };\n                    if !file_type.is_dir() {\n                        return WalkState::Continue;\n                    }\n\n                    // Skip cookiecutter template directories\n                    if entry.file_name().to_str().is_some_and(|filename| {\n                        filename.starts_with(\"{{\")\n                            && filename.ends_with(\"}}\")\n                            && filename.contains(\"cookiecutter\")\n                    }) {\n                        trace!(\n                            path = %entry.path().user_display(),\n                            \"Skipping cookiecutter template directory\"\n                        );\n                        return WalkState::Skip;\n                    }\n\n                    // Do not descend into git submodules\n                    if submodules\n                        .iter()\n                        .any(|submodule| entry.path().starts_with(submodule))\n                    {\n                        trace!(\n                            path = %entry.path().user_display(),\n                            \"Skipping git submodule\"\n                        );\n                        return WalkState::Skip;\n                    }\n\n                    match Project::from_directory(entry.path()) {\n                        Ok(mut project) => {\n                            let relative_path = entry\n                                .into_path()\n                                .strip_prefix(root)\n                                .expect(\"Entry path should be relative to the root\")\n                                .to_path_buf();\n                            project.with_relative_path(relative_path);\n\n                            if let Ok(projects) = projects.lock().unwrap().as_mut() {\n                                projects.push(project);\n                            }\n                        }\n                        Err(Error::MissingConfigFile) => {}\n                        Err(e) => {\n                            // Exit early if the path is selected\n                            if let Some(selectors) = selectors {\n                                let relative_path = entry\n                                    .path()\n                                    .strip_prefix(root)\n                                    .expect(\"Entry path should be relative to the root\");\n                                if selectors.matches_path(relative_path) {\n                                    *projects.lock().unwrap() = Err(e);\n                                    return WalkState::Quit;\n                                }\n                            }\n                            // Otherwise, just log the error and continue\n                            error!(\n                                path = %entry.path().user_display(),\n                                \"Skipping project due to error: {e}\"\n                            );\n                            return WalkState::Skip;\n                        }\n                    }\n\n                    WalkState::Continue\n                })\n            });\n\n        let projects = projects.into_inner().unwrap()?;\n        if projects.is_empty() {\n            return Err(Error::MissingConfigFile);\n        }\n\n        Ok(projects)\n    }\n\n    /// Sort projects by depth and assign indices\n    fn sort_and_index_projects(projects: &mut [Project]) {\n        // Sort projects by their depth in the directory tree.\n        // The deeper the project comes first.\n        // This is useful for nested projects where we want to prefer the most specific project.\n        projects.sort_by(|a, b| {\n            b.depth()\n                .cmp(&a.depth())\n                // If depth is the same, sort by relative path to have a deterministic order.\n                .then_with(|| a.relative_path.cmp(&b.relative_path))\n        });\n\n        // Assign index to each project.\n        for (idx, project) in projects.iter_mut().enumerate() {\n            project.with_idx(idx);\n        }\n    }\n\n    pub(crate) fn root(&self) -> &Path {\n        &self.root\n    }\n\n    pub(crate) fn projects(&self) -> &[Arc<Project>] {\n        &self.projects\n    }\n\n    pub(crate) fn all_projects(&self) -> &[Project] {\n        &self.all_projects\n    }\n\n    /// Initialize remote repositories for all projects.\n    async fn init_repos(\n        &mut self,\n        store: &Store,\n        reporter: Option<&dyn HookInitReporter>,\n    ) -> Result<(), Error> {\n        #[allow(clippy::mutable_key_type)]\n        let remote_repos = {\n            let mut seen = FxHashSet::default();\n\n            // Prepare remote repos in parallel.\n            let remotes_iter = self\n                .projects\n                .iter()\n                .flat_map(|proj| proj.config.repos.iter())\n                .filter_map(|repo| match repo {\n                    // Deduplicate remote repos.\n                    config::Repo::Remote(repo) if seen.insert(repo) => Some(repo),\n                    _ => None,\n                });\n\n            let cloned_repos = store.clone_repos(remotes_iter, reporter).await?;\n\n            let mut remote_repos = FxHashMap::default();\n            for (repo_config, path) in cloned_repos {\n                let repo = Arc::new(Repo::remote(\n                    repo_config.repo.clone(),\n                    repo_config.rev.clone(),\n                    path,\n                )?);\n                remote_repos.insert(repo_config, repo);\n            }\n\n            remote_repos\n        };\n\n        for project in &mut self.projects {\n            let mut repos = Vec::with_capacity(project.config.repos.len());\n\n            for repo in &project.config.repos {\n                match repo {\n                    config::Repo::Remote(repo) => {\n                        let repo = remote_repos.get(repo).expect(\"repo not found\");\n                        repos.push(repo.clone());\n                    }\n                    config::Repo::Local(repo) => {\n                        let repo = Repo::local(repo.hooks.clone());\n                        repos.push(Arc::new(repo));\n                    }\n                    config::Repo::Meta(repo) => {\n                        let repo = Repo::meta(repo.hooks.clone());\n                        repos.push(Arc::new(repo));\n                    }\n                    config::Repo::Builtin(repo) => {\n                        let repo = Repo::builtin(repo.hooks.clone());\n                        repos.push(Arc::new(repo));\n                    }\n                }\n            }\n\n            Arc::get_mut(project).unwrap().repos = repos;\n        }\n\n        Ok(())\n    }\n\n    /// Load and prepare hooks for all projects.\n    pub(crate) async fn init_hooks(\n        &mut self,\n        store: &Store,\n        reporter: Option<&dyn HookInitReporter>,\n    ) -> Result<Vec<Hook>, Error> {\n        self.init_repos(store, reporter).await?;\n\n        let mut hooks = Vec::new();\n        for project in &self.projects {\n            let project_hooks = Arc::clone(project).internal_init_hooks().await?;\n            hooks.extend(project_hooks);\n        }\n\n        reporter.map(HookInitReporter::on_complete);\n\n        Ok(hooks)\n    }\n\n    /// Check if all configuration files are staged in git.\n    pub(crate) async fn check_configs_staged(&self) -> Result<()> {\n        let config_files = self\n            .projects\n            .iter()\n            .map(|project| project.config_file())\n            .collect::<Vec<_>>();\n        let non_staged = git::files_not_staged(&config_files).await?;\n\n        let git_root = GIT_ROOT.as_ref()?;\n        if !non_staged.is_empty() {\n            let non_staged = non_staged\n                .into_iter()\n                .map(|p| git_root.join(p))\n                .collect::<Vec<_>>();\n            match non_staged.as_slice() {\n                [filename] => anyhow::bail!(\n                    \"prek configuration file is not staged, run `{}` to stage it\",\n                    format!(\"git add {}\", filename.user_display()).cyan()\n                ),\n                _ => anyhow::bail!(\n                    \"The following configuration files are not staged, `git add` them first:\\n{}\",\n                    non_staged\n                        .iter()\n                        .map(|p| format!(\"  {}\", p.user_display()))\n                        .collect::<Vec<_>>()\n                        .join(\"\\n\")\n                ),\n            }\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek/src/yaml.rs",
    "content": "// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or\n// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license\n// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your\n// option. This file may not be copied, modified, or distributed\n// except according to those terms.\n\nuse std::fmt::Write;\n\n/// Serialize a YAML scalar while preserving the caller's quote style.\npub(crate) fn serialize_yaml_scalar(value: &str, quote: &str) -> anyhow::Result<String> {\n    match quote {\n        \"'\" => Ok(format!(\"'{}'\", escape_single_quoted(value))),\n        \"\\\"\" => Ok(format!(\"\\\"{}\\\"\", escape_double_quoted(value))),\n        _ => {\n            if is_simple_plain(value) {\n                Ok(value.to_owned())\n            } else {\n                // Defer to serde-saphyr to select quoting/escaping for non-trivial scalars.\n                let rendered = serde_saphyr::to_string(&value)?;\n                Ok(rendered.trim_end_matches('\\n').to_owned())\n            }\n        }\n    }\n}\n\n/// Fast-path: allow simple, plain scalars we want to keep unquoted.\nfn is_simple_plain(value: &str) -> bool {\n    if value.is_empty() {\n        return false;\n    }\n    value\n        .chars()\n        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '/' | '+' | '@'))\n}\n\n/// YAML single-quoted strings escape a single quote by doubling it.\nfn escape_single_quoted(value: &str) -> String {\n    value.replace('\\'', \"''\")\n}\n\n/// YAML double-quoted strings use backslash escapes for control characters.\nfn escape_double_quoted(value: &str) -> String {\n    let mut escaped = String::with_capacity(value.len());\n    for ch in value.chars() {\n        match ch {\n            '\\\\' => escaped.push_str(\"\\\\\\\\\"),\n            '\"' => escaped.push_str(\"\\\\\\\"\"),\n            '\\t' => escaped.push_str(\"\\\\t\"),\n            '\\n' => escaped.push_str(\"\\\\n\"),\n            '\\r' => escaped.push_str(\"\\\\r\"),\n            c if c.is_control() => {\n                let _ = write!(escaped, \"\\\\u{:04X}\", c as u32);\n            }\n            c => escaped.push(c),\n        }\n    }\n    escaped\n}\n\n#[cfg(test)]\nmod tests {\n    use super::serialize_yaml_scalar;\n\n    #[test]\n    fn serialize_yaml_scalar_plain() {\n        let rendered = serialize_yaml_scalar(\"v1.2.3\", \"\").unwrap();\n        assert_eq!(rendered, \"v1.2.3\");\n        let rendered = serialize_yaml_scalar(\"v1.2.3\", \"'\").unwrap();\n        assert_eq!(rendered, \"'v1.2.3'\");\n        let rendered = serialize_yaml_scalar(\"v1.2.3\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"v1.2.3\\\"\");\n        let rendered = serialize_yaml_scalar(\"123\", \"\").unwrap();\n        assert_eq!(rendered, \"123\");\n        let rendered = serialize_yaml_scalar(\"123\", \"'\").unwrap();\n        assert_eq!(rendered, \"'123'\");\n        let rendered = serialize_yaml_scalar(\"123\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"123\\\"\");\n        let rendered = serialize_yaml_scalar(\"a:b\", \"\").unwrap();\n        assert_eq!(rendered, \"a:b\");\n        let rendered = serialize_yaml_scalar(\"a:b\", \"'\").unwrap();\n        assert_eq!(rendered, \"'a:b'\");\n        let rendered = serialize_yaml_scalar(\"a\\\"b\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"a\\\\\\\"b\\\"\");\n        let rendered = serialize_yaml_scalar(\"a'b\", \"'\").unwrap();\n        assert_eq!(rendered, \"'a''b'\");\n\n        let rendered = serialize_yaml_scalar(\"abc def\", \"\").unwrap();\n        assert_eq!(rendered, \"abc def\");\n        let rendered = serialize_yaml_scalar(\"abc def\", \"'\").unwrap();\n        assert_eq!(rendered, \"'abc def'\");\n        let rendered = serialize_yaml_scalar(\"abc def\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"abc def\\\"\");\n    }\n\n    #[test]\n    fn serialize_yaml_scalar_quotes_and_escapes() {\n        let rendered = serialize_yaml_scalar(\"a\\\\b\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"a\\\\\\\\b\\\"\");\n        let rendered = serialize_yaml_scalar(\"a\\nb\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"a\\\\nb\\\"\");\n        let rendered = serialize_yaml_scalar(\"a\\tb\", \"\\\"\").unwrap();\n        assert_eq!(rendered, \"\\\"a\\\\tb\\\"\");\n        let rendered = serialize_yaml_scalar(\"a\\\\b\", \"'\").unwrap();\n        assert_eq!(rendered, \"'a\\\\b'\");\n    }\n}\n"
  },
  {
    "path": "crates/prek/tests/auto_update.rs",
    "content": "use anyhow::Result;\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::fixture::ChildPath;\nuse assert_fs::prelude::*;\nuse insta::assert_snapshot;\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PREK_TOML};\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\nmod common;\n\nconst BASE_TIMESTAMP: u64 = 1_000_000_000;\nconst INCREMENTING_STEP_SECS: u64 = 100;\nconst FIXED_STEP_SECS: u64 = 0;\n\n/// Helper function to create a local git repository with hooks and incrementing timestamps.\nfn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str]) -> Result<String> {\n    create_local_git_repo_with_timestamps(context, repo_name, tags, INCREMENTING_STEP_SECS)\n}\n\n/// Like `create_local_git_repo`, but all commits and tags share a single fixed timestamp.\n/// Simulates mirror repos where all tags are imported simultaneously.\nfn create_local_git_repo_fixed_ts(\n    context: &TestContext,\n    repo_name: &str,\n    tags: &[&str],\n) -> Result<String> {\n    create_local_git_repo_with_timestamps(context, repo_name, tags, FIXED_STEP_SECS)\n}\n\nfn create_local_git_repo_with_timestamps(\n    context: &TestContext,\n    repo_name: &str,\n    tags: &[&str],\n    timestamp_step_secs: u64,\n) -> Result<String> {\n    let repo_dir = context.home_dir().child(format!(\"test-repos/{repo_name}\"));\n    repo_dir.create_dir_all()?;\n\n    git_cmd(&repo_dir)\n        .arg(\"-c\")\n        .arg(\"init.defaultBranch=master\")\n        .arg(\"init\")\n        .assert()\n        .success();\n\n    // Create .pre-commit-hooks.yaml\n    repo_dir\n        .child(\".pre-commit-hooks.yaml\")\n        .write_str(indoc::indoc! {r#\"\n        - id: test-hook\n          name: Test Hook\n          entry: echo\n          language: system\n        - id: another-hook\n          name: Another Hook\n          entry: python3 -c 'print(\"hello\")'\n          language: python\n    \"#})?;\n\n    git_cmd(&repo_dir).arg(\"add\").arg(\".\").assert().success();\n\n    let mut timestamp = BASE_TIMESTAMP;\n\n    git_cmd(&repo_dir)\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Initial commit\")\n        .env(\"GIT_AUTHOR_DATE\", format!(\"{timestamp} +0000\"))\n        .env(\"GIT_COMMITTER_DATE\", format!(\"{timestamp} +0000\"))\n        .assert()\n        .success();\n\n    // Create tags\n    for tag in tags {\n        timestamp += timestamp_step_secs;\n        git_cmd(&repo_dir)\n            .arg(\"commit\")\n            .arg(\"-m\")\n            .arg(format!(\"Release {tag}\"))\n            .arg(\"--allow-empty\")\n            .env(\"GIT_AUTHOR_DATE\", format!(\"{timestamp} +0000\"))\n            .env(\"GIT_COMMITTER_DATE\", format!(\"{timestamp} +0000\"))\n            .assert()\n            .success();\n        git_cmd(&repo_dir)\n            .arg(\"tag\")\n            .arg(tag)\n            .arg(\"-m\")\n            .arg(tag)\n            .env(\"GIT_AUTHOR_DATE\", format!(\"{timestamp} +0000\"))\n            .env(\"GIT_COMMITTER_DATE\", format!(\"{timestamp} +0000\"))\n            .assert()\n            .success();\n    }\n\n    timestamp += timestamp_step_secs;\n    // Add an extra commit to the tip\n    git_cmd(&repo_dir)\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"tip\")\n        .arg(\"--allow-empty\")\n        .env(\"GIT_AUTHOR_DATE\", format!(\"{timestamp} +0000\"))\n        .env(\"GIT_COMMITTER_DATE\", format!(\"{timestamp} +0000\"))\n        .assert()\n        .success();\n\n    Ok(repo_dir.to_string_lossy().to_string())\n}\n\n#[test]\nfn auto_update_basic() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"test-repo\", &[\"v1.0.0\", \"v1.1.0\", \"v2.0.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/test-repo] updating v1.0.0 -> v2.0.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/test-repo\n                rev: v2.0.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_already_up_to_date() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"up-to-date-repo\", &[\"v1.0.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/up-to-date-repo] already up to date\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/up-to-date-repo\n                rev: v1.0.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\n#[cfg(unix)]\nfn auto_update_does_not_rewrite_config_when_up_to_date() -> Result<()> {\n    use std::time::UNIX_EPOCH;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"up-to-date-repo-mtime\", &[\"v1.0.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n    context.git_add(\".\");\n\n    let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);\n\n    let before_secs = std::fs::metadata(config_path.path())?\n        .modified()?\n        .duration_since(UNIX_EPOCH)?\n        .as_secs();\n\n    let assert = context\n        .auto_update()\n        .arg(\"--cooldown-days\")\n        .arg(\"0\")\n        .assert()\n        .success();\n    let stdout = String::from_utf8_lossy(&assert.get_output().stdout);\n    assert!(stdout.contains(\"already up to date\"));\n\n    let after_secs = std::fs::metadata(config_path.path())?\n        .modified()?\n        .duration_since(UNIX_EPOCH)?\n        .as_secs();\n    assert_eq!(after_secs, before_secs);\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_multiple_repos_mixed() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo1_path = create_local_git_repo(&context, \"repo1\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    let repo2_path = create_local_git_repo(&context, \"repo2\", &[\"v2.0.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: same-hook\n          - repo: {}\n            rev: v2.0.0\n            hooks:\n              - id: another-hook\n    \", repo1_path, repo1_path, repo2_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0\n    [[HOME]/test-repos/repo2] already up to date\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r\"\n            repos:\n              - repo: [HOME]/test-repos/repo1\n                rev: v1.1.0\n                hooks:\n                  - id: test-hook\n              - repo: [HOME]/test-repos/repo1\n                rev: v1.1.0\n                hooks:\n                  - id: same-hook\n              - repo: [HOME]/test-repos/repo2\n                rev: v2.0.0\n                hooks:\n                  - id: another-hook\n            \");\n        }\n    );\n\n    Ok(())\n}\n\n/// Test that `auto-update` ignores the `GIT_DIR` environment variable.\n#[test]\nfn test_resolve_revision_ignores_git_dir_env_var() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"target-repo\", &[\"v0.1.0\", \"v0.2.0\"])?;\n    let external_repo_path = create_local_git_repo(&context, \"external-repo\", &[\"v9.9.9\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v0.1.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    let mut cmd = context.auto_update();\n    cmd.arg(\"--cooldown-days\")\n        .arg(\"0\")\n        .env(\"GIT_DIR\", ChildPath::new(&external_repo_path).join(\".git\"));\n\n    cmd_snapshot!(filters.clone(), cmd, @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/target-repo] updating v0.1.0 -> v0.2.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/target-repo\n                rev: v0.2.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_specific_repos() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo1_path = create_local_git_repo(&context, \"repo1\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    let repo2_path = create_local_git_repo(&context, \"repo2\", &[\"v2.0.0\", \"v2.1.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n          - repo: {}\n            rev: v2.0.0\n            hooks:\n              - id: another-hook\n    \", repo1_path, repo2_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    // Update only repo1\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--repo\").arg(&repo1_path).arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/repo1\n                rev: v1.1.0\n                hooks:\n                  - id: test-hook\n              - repo: [HOME]/test-repos/repo2\n                rev: v2.0.0\n                hooks:\n                  - id: another-hook\n            \"#);\n        }\n    );\n\n    // Update both repo1 and repo2\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--repo\").arg(&repo1_path).arg(\"--repo\").arg(&repo2_path).arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/repo1] already up to date\n    [[HOME]/test-repos/repo2] updating v2.0.0 -> v2.1.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/repo1\n                rev: v1.1.0\n                hooks:\n                  - id: test-hook\n              - repo: [HOME]/test-repos/repo2\n                rev: v2.1.0\n                hooks:\n                  - id: another-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_bleeding_edge() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"bleeding-repo\", &[\"v1.0.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{40}\", \"[COMMIT_SHA]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--bleeding-edge\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/bleeding-repo] updating v1.0.0 -> [COMMIT_SHA]\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/bleeding-repo\n                rev: [COMMIT_SHA]\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_freeze() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"freeze-repo\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    // Make sure the \"# frozen: v1.1.0\" comment works correctly by adding a tag without dot\n    git_cmd(&repo_path)\n        .arg(\"tag\")\n        .arg(\"v1\")\n        .arg(\"-m\")\n        .arg(\"v1\")\n        .arg(\"v1.1.0^{}\")\n        .assert()\n        .success();\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\" [a-f0-9]{40}\", r\" [COMMIT_SHA]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--freeze\").arg(\"--cooldown-days\").arg(\"0\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA]\n\n    ----- stderr -----\n    \");\n\n    // Should contain frozen comment\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r##\"\n            repos:\n              - repo: [HOME]/test-repos/freeze-repo\n                rev: [COMMIT_SHA]  # frozen: v1.1.0\n                hooks:\n                  - id: test-hook\n            \"##);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_freeze_uses_dereferenced_commit_for_annotated_tags() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path =\n        create_local_git_repo(&context, \"freeze-annotated-repo\", &[\"v1.0.0\", \"v1.1.0\"])?;\n\n    let tag_object_sha = git_cmd(&repo_path)\n        .args([\"rev-parse\", \"v1.1.0\"])\n        .output()?\n        .stdout;\n    let tag_object_sha = str::from_utf8(&tag_object_sha)?.trim();\n\n    let commit_sha = git_cmd(&repo_path)\n        .args([\"rev-parse\", \"v1.1.0^{}\"])\n        .output()?\n        .stdout;\n    let commit_sha = str::from_utf8(&commit_sha)?.trim();\n\n    assert_ne!(\n        tag_object_sha, commit_sha,\n        \"sanity check failed: annotated tag object SHA should differ from commit SHA\"\n    );\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n    context.git_add(\".\");\n\n    context\n        .auto_update()\n        .arg(\"--freeze\")\n        .arg(\"--cooldown-days\")\n        .arg(\"0\")\n        .assert()\n        .success();\n\n    let config = context.read(PRE_COMMIT_CONFIG_YAML);\n    assert!(\n        config.contains(&format!(\"rev: {commit_sha}\")),\n        \"expected config to contain the dereferenced commit SHA\"\n    );\n    assert!(\n        config.contains(\"# frozen: v1.1.0\"),\n        \"expected config to preserve the original tag in the frozen comment\"\n    );\n    assert!(\n        !config.contains(tag_object_sha),\n        \"expected config to not contain the annotated tag object SHA\"\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_preserve_quote_style() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo1_path = create_local_git_repo(&context, \"repo1\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    let repo2_path = create_local_git_repo(&context, \"repo2\", &[\"v1.0.0\", \"v1.1.0\"])?;\n\n    // Use specific formatting with comments\n    context.write_pre_commit_config(&indoc::formatdoc! {r#\"\n        # Pre-commit configuration\n        repos:\n          - repo: {}  # Test repository\n            rev: v1.0.0  # No quotes\n            hooks:\n              - id: test-hook\n                # Hook configuration\n                name: Test Hook\n          - repo: {}  # Test repository\n            rev: 'v1.0.0'  # Single quotes\n            hooks:\n              - id: test-hook\n                # Hook configuration\n                name: Test Hook\n          - repo: {}\n            rev: \"v1.0.0\"  # Double quotes\n            hooks:\n              - id: test-hook\n                # Hook configuration\n                name: Test Hook\n    \"#, repo1_path, repo1_path, repo2_path });\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0\n    [[HOME]/test-repos/repo2] updating v1.0.0 -> v1.1.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            # Pre-commit configuration\n            repos:\n              - repo: [HOME]/test-repos/repo1  # Test repository\n                rev: v1.1.0  # No quotes\n                hooks:\n                  - id: test-hook\n                    # Hook configuration\n                    name: Test Hook\n              - repo: [HOME]/test-repos/repo1  # Test repository\n                rev: 'v1.1.0'  # Single quotes\n                hooks:\n                  - id: test-hook\n                    # Hook configuration\n                    name: Test Hook\n              - repo: [HOME]/test-repos/repo2\n                rev: \"v1.1.0\"  # Double quotes\n                hooks:\n                  - id: test-hook\n                    # Hook configuration\n                    name: Test Hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_with_existing_frozen_comment() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path =\n        create_local_git_repo(&context, \"frozen-repo\", &[\"v1.0.0\", \"v1.1.0\", \"v1.2.0\"])?;\n\n    let commit_sha = \"1234567890abcdef1234567890abcdef12345678\";\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: {}  # frozen: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path, commit_sha});\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(commit_sha, \"[COMMIT_SHA]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/frozen-repo] updating [COMMIT_SHA] -> v1.2.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/frozen-repo\n                rev: v1.2.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_local_repo_ignored() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"remote-repo\", &[\"v1.0.0\", \"v1.1.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-hook\n                name: Local Hook\n                language: system\n                entry: echo\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/remote-repo] updating v1.0.0 -> v1.1.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: local-hook\n                    name: Local Hook\n                    language: system\n                    entry: echo\n              - repo: [HOME]/test-repos/remote-repo\n                rev: v1.1.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn missing_hook_ids() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"missing-hook-repo\", &[\"v1.0.0\"])?;\n\n    // Remove the 'test-hook' from the hooks file\n    ChildPath::new(&repo_path)\n        .child(\".pre-commit-hooks.yaml\")\n        .write_str(indoc::indoc! {r#\"\n        - id: another-hook\n          name: Another Hook\n          entry: python3 -c 'print(\"hello\")'\n          language: python\n    \"#})?;\n\n    git_cmd(&repo_path).arg(\"add\").arg(\".\").assert().success();\n    git_cmd(&repo_path)\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Remove test-hook\")\n        .assert()\n        .success();\n    git_cmd(&repo_path)\n        .arg(\"tag\")\n        .arg(\"v2.0.0\")\n        .arg(\"-m\")\n        .arg(\"v2.0.0\")\n        .assert()\n        .success();\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    [[HOME]/test-repos/missing-hook-repo] update failed: Cannot update to rev `v2.0.0`, hook is missing: test-hook\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_workspace() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo1_path =\n        create_local_git_repo(&context, \"workspace-repo1\", &[\"v1.0.0\", \"v1.1.0\", \"v2.0.0\"])?;\n    let repo2_path = create_local_git_repo(&context, \"workspace-repo2\", &[\"v1.0.0\", \"v1.5.0\"])?;\n    let repo3_path = create_local_git_repo(&context, \"workspace-repo3\", &[\"v2.0.0\"])?;\n\n    context.setup_workspace(\n        &[\"project-a\", \"project-b\"],\n        \"repos: []\", // Minimal valid config for root\n    )?;\n\n    context\n        .work_dir()\n        .child(\"project-a/.pre-commit-config.yaml\")\n        .write_str(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: another-hook\n    \", repo1_path, repo2_path})?;\n\n    context\n        .work_dir()\n        .child(\"project-b/.pre-commit-config.yaml\")\n        .write_str(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: another-hook\n          - repo: {}\n            rev: v2.0.0\n            hooks:\n              - id: test-hook\n    \", repo2_path, repo3_path})?;\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/workspace-repo1] updating v1.0.0 -> v2.0.0\n    [[HOME]/test-repos/workspace-repo2] updating v1.0.0 -> v1.5.0\n    [[HOME]/test-repos/workspace-repo3] already up to date\n\n    ----- stderr -----\n    \");\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(\"project-a/.pre-commit-config.yaml\"), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/workspace-repo1\n                rev: v2.0.0\n                hooks:\n                  - id: test-hook\n              - repo: [HOME]/test-repos/workspace-repo2\n                rev: v1.5.0\n                hooks:\n                  - id: another-hook\n            \"#);\n        }\n    );\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(\"project-b/.pre-commit-config.yaml\"), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/workspace-repo2\n                rev: v1.5.0\n                hooks:\n                  - id: another-hook\n              - repo: [HOME]/test-repos/workspace-repo3\n                rev: v2.0.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n// When multiple tags point to the same object, prek prefers a tag that:\n// - contains a dot (e.g., a SemVer-like tag), and\n// - is most similar to the current revision, as measured by Levenshtein distance.\n#[test]\nfn prefer_similar_tags() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"remote-repo\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    // Add a second tag (`foo-v1.1.0`) pointing at the same commit as `v1.1.0`.\n    // From the current `rev` (`v1.0.0`):\n    // - `levenshtein(v1.0.0, v1.1.0) == 1`\n    // - `levenshtein(v1.0.0, foo-v1.1.0) == 5`\n    // Therefore, `v1.1.0` should be selected as the update target.\n    // 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.\n    git_cmd(&repo_path)\n        .arg(\"tag\")\n        .arg(\"foo-v1.1.0\")\n        .arg(\"-m\")\n        .arg(\"foo-v1.1.0\")\n        .arg(\"v1.1.0^{}\")\n        .assert()\n        .success();\n    // Add tag v1 pointing to the same commit as v1.1.0\n    git_cmd(&repo_path)\n        .arg(\"tag\")\n        .arg(\"v1\")\n        .arg(\"-m\")\n        .arg(\"v1\")\n        .arg(\"v1.1.0^{}\")\n        .assert()\n        .success();\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-hook\n                name: Local Hook\n                language: system\n                entry: echo\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/remote-repo] updating v1.0.0 -> v1.1.0\n\n    ----- stderr -----\n    \");\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r\"\n            repos:\n              - repo: local\n                hooks:\n                  - id: local-hook\n                    name: Local Hook\n                    language: system\n                    entry: echo\n              - repo: [HOME]/test-repos/remote-repo\n                rev: v1.1.0\n                hooks:\n                  - id: test-hook\n            \");\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_dry_run() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"test-repo\", &[\"v1.0.0\", \"v1.1.0\", \"v2.0.0\"])?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--dry-run\").arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/test-repo] updating v1.0.0 -> v2.0.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r\"\n            repos:\n              - repo: [HOME]/test-repos/test-repo\n                rev: v1.0.0\n                hooks:\n                  - id: test-hook\n            \");\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn quoting_float_like_version_number() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"test-repo\", &[\"0.49\", \"0.50\"])?;\n\n    // Our serialize by default quotes this floats with single quotes, e.g., '0.49'. Use\n    // a different quotaing style here to validate that this does not create conflicts.\n    context.write_pre_commit_config(&indoc::formatdoc! {r#\"\n        repos:\n          - repo: {}\n            rev: \"0.49\"\n            hooks:\n              - id: test-hook\n    \"#, repo_path});\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/test-repo] updating 0.49 -> 0.50\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/test-repo\n                rev: \"0.50\"\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_with_invalid_config_file() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Write an invalid config file\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(\"invalid_yaml: [unclosed_list\")?;\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: error: line 1 column 15: unclosed bracket '['\n     --> <input>:1:15\n      |\n    1 | invalid_yaml: [unclosed_list\n      |               ^ unclosed bracket '['\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_toml() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path =\n        create_local_git_repo(&context, \"test-repo-toml\", &[\"v1.0.0\", \"v1.1.0\", \"v2.0.0\"])?;\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(&indoc::formatdoc! {r#\"\n        [[repos]]\n        repo = \"{}\"\n        rev = \"v1.0.0\"\n        hooks = [\n          {{ id = \"test-hook\" }},\n        ]\n      \"#, repo_path.replace('\\\\', \"/\")})?;\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/test-repo-toml] updating v1.0.0 -> v2.0.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n      { filters => filters.clone() },\n      {\n        assert_snapshot!(context.read(PREK_TOML), @r#\"\n        [[repos]]\n        repo = \"[HOME]/test-repos/test-repo-toml\"\n        rev = \"v2.0.0\"\n        hooks = [\n          { id = \"test-hook\" },\n        ]\n        \"#);\n      }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_toml_with_comment() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path =\n        create_local_git_repo(&context, \"test-repo-toml\", &[\"v1.0.0\", \"v1.1.0\", \"v2.0.0\"])?;\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(&indoc::formatdoc! {r#\"\n        [[repos]]\n        repo = \"{}\"\n        rev = \"v1.0.0\" # This is a comment\n        hooks = [\n          {{ id = \"test-hook\" }},\n        ]\n      \"#, repo_path.replace('\\\\', \"/\")})?;\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/test-repo-toml] updating v1.0.0 -> v2.0.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n      { filters => filters.clone() },\n      {\n        assert_snapshot!(context.read(PREK_TOML), @r#\"\n        [[repos]]\n        repo = \"[HOME]/test-repos/test-repo-toml\"\n        rev = \"v2.0.0\" # This is a comment\n        hooks = [\n          { id = \"test-hook\" },\n        ]\n        \"#);\n      }\n    );\n\n    // \"frozen: xx\" comment should be removed\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(&indoc::formatdoc! {r#\"\n        [[repos]]\n        repo = \"{}\"\n        rev = \"v1.0.0\" # frozen: v1.0.0\n        hooks = [\n          {{ id = \"test-hook\" }},\n        ]\n      \"#, repo_path.replace('\\\\', \"/\")})?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/test-repo-toml] updating v1.0.0 -> v2.0.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n      { filters => filters.clone() },\n      {\n        assert_snapshot!(context.read(PREK_TOML), @r#\"\n        [[repos]]\n        repo = \"[HOME]/test-repos/test-repo-toml\"\n        rev = \"v2.0.0\"\n        hooks = [\n          { id = \"test-hook\" },\n        ]\n        \"#);\n      }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_freeze_toml() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"freeze-repo\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    // Make sure the \"# frozen: v1.1.0\" comment works correctly by adding a tag without dot\n    git_cmd(&repo_path)\n        .arg(\"tag\")\n        .arg(\"v1\")\n        .arg(\"-m\")\n        .arg(\"v1\")\n        .arg(\"v1.1.0^{}\")\n        .assert()\n        .success();\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(&indoc::formatdoc! {r#\"\n        [[repos]]\n        repo = \"{}\"\n        rev = \"v1.0.0\"\n        hooks = [\n          {{ id = \"test-hook\" }},\n        ]\n    \"#, repo_path.replace('\\\\', \"/\")})?;\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"[a-f0-9]{40}\", r\"[COMMIT_SHA]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--freeze\").arg(\"--cooldown-days\").arg(\"0\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA]\n\n    ----- stderr -----\n    \");\n\n    // Should contain frozen comment\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PREK_TOML), @r#\"\n            [[repos]]\n            repo = \"[HOME]/test-repos/freeze-repo\"\n            rev = \"[COMMIT_SHA]\" # frozen: v1.1.0\n            hooks = [\n              { id = \"test-hook\" },\n            ]\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_equal_timestamp_tags_picks_highest_version() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo_fixed_ts(\n        &context,\n        \"mirror-repo\",\n        &[\"v1.0.0\", \"v1.0.1\", \"v1.0.2\", \"v1.0.3\", \"v1.0.4\", \"v1.0.5\"],\n    )?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.3\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/mirror-repo] updating v1.0.3 -> v1.0.5\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/mirror-repo\n                rev: v1.0.5\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n// When all tags share a timestamp and some are non-semver (e.g. \"latest\", \"stable\"),\n// semver tags should be preferred and sorted highest-first.\n#[test]\nfn auto_update_equal_timestamp_prefers_semver_over_nonsemver() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo_fixed_ts(\n        &context,\n        \"mixed-tags-repo\",\n        &[\"v1.0.0\", \"latest\", \"v2.0.0\", \"stable\"],\n    )?;\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/mixed-tags-repo] updating v1.0.0 -> v2.0.0\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/mixed-tags-repo\n                rev: v2.0.0\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n// When tags span multiple timestamp groups, the newest group should be selected first.\n// Within an equal-timestamp group, semver tiebreaker picks the highest version.\n#[test]\nfn auto_update_mixed_timestamps_with_equal_subgroups() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create base repo with v1.0.x tags at incrementing timestamps.\n    let repo_path = create_local_git_repo(&context, \"mixed-ts-repo\", &[\"v1.0.0\", \"v1.0.1\"])?;\n\n    // Add a second group of tags sharing a single newer timestamp\n    // (must be in the past so the cooldown filter doesn't exclude them).\n    let newer_ts = \"1500000000 +0000\";\n    for tag in &[\"v2.0.1\", \"v2.0.0\"] {\n        git_cmd(&repo_path)\n            .arg(\"commit\")\n            .arg(\"-m\")\n            .arg(format!(\"Release {tag}\"))\n            .arg(\"--allow-empty\")\n            .env(\"GIT_AUTHOR_DATE\", newer_ts)\n            .env(\"GIT_COMMITTER_DATE\", newer_ts)\n            .assert()\n            .success();\n        git_cmd(&repo_path)\n            .arg(\"tag\")\n            .arg(tag)\n            .arg(\"-m\")\n            .arg(tag)\n            .env(\"GIT_AUTHOR_DATE\", newer_ts)\n            .env(\"GIT_COMMITTER_DATE\", newer_ts)\n            .assert()\n            .success();\n    }\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v1.0.0\n            hooks:\n              - id: test-hook\n    \", repo_path});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--cooldown-days\").arg(\"0\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/mixed-ts-repo] updating v1.0.0 -> v2.0.1\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#\"\n            repos:\n              - repo: [HOME]/test-repos/mixed-ts-repo\n                rev: v2.0.1\n                hooks:\n                  - id: test-hook\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn auto_update_freeze_toml_with_comment() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_local_git_repo(&context, \"freeze-repo\", &[\"v1.0.0\", \"v1.1.0\"])?;\n    // Make sure the \"# frozen: v1.1.0\" comment works correctly by adding a tag without dot\n    git_cmd(&repo_path)\n        .arg(\"tag\")\n        .arg(\"v1\")\n        .arg(\"-m\")\n        .arg(\"v1\")\n        .arg(\"v1.1.0^{}\")\n        .assert()\n        .success();\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(&indoc::formatdoc! {r#\"\n        [[repos]]\n        repo = \"{}\"\n        # A comment above\n        rev = \"v1.0.0\" # This is a comment\n        # A comment below\n        hooks = [\n          {{ id = \"test-hook\" }},\n        ]\n    \"#, repo_path.replace('\\\\', \"/\")})?;\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"[a-f0-9]{40}\", r\"[COMMIT_SHA]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), context.auto_update().arg(\"--freeze\").arg(\"--cooldown-days\").arg(\"0\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA]\n\n    ----- stderr -----\n    \");\n\n    // Should contain frozen comment\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            assert_snapshot!(context.read(PREK_TOML), @r#\"\n            [[repos]]\n            repo = \"[HOME]/test-repos/freeze-repo\"\n            # A comment above\n            rev = \"[COMMIT_SHA]\" # frozen: v1.1.0\n            # A comment below\n            hooks = [\n              { id = \"test-hook\" },\n            ]\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/builtin_hooks.rs",
    "content": "#[cfg(unix)]\nuse prek_consts::env_vars::EnvVars;\n#[cfg(unix)]\nuse std::os::unix::fs::PermissionsExt;\n\nuse anyhow::Result;\nuse assert_fs::prelude::*;\nuse insta::assert_snapshot;\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\n/// Tests that `repo: builtin` hooks doesn't create hook env.\n#[test]\nfn builtin_hooks_not_create_env() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: end-of-file-fixer\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    fix end of files.........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    let hooks_dir = context\n        .home_dir()\n        .join(\"hooks\")\n        .read_dir()\n        .into_iter()\n        .flatten()\n        .flatten()\n        .collect::<Vec<_>>();\n    assert_eq!(hooks_dir.len(), 0);\n}\n\n#[test]\nfn builtin_hooks_unknown_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: this-hook-does-not-exist\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: error: line 4 column 9: unknown builtin hook id `this-hook-does-not-exist`\n     --> <input>:4:9\n      |\n    2 |   - repo: builtin\n    3 |     hooks:\n    4 |       - id: this-hook-does-not-exist\n      |         ^ unknown builtin hook id `this-hook-does-not-exist`\n    \");\n}\n\n#[test]\nfn end_of_file_fixer_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: end-of-file-fixer\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"correct_lf.txt\").write_str(\"Hello World\\n\")?;\n    cwd.child(\"correct_crlf.txt\").write_str(\"Hello World\\r\\n\")?;\n    cwd.child(\"no_newline.txt\")\n        .write_str(\"No trailing newline\")?;\n    cwd.child(\"multiple_lf.txt\")\n        .write_str(\"Multiple newlines\\n\\n\\n\")?;\n    cwd.child(\"multiple_crlf.txt\")\n        .write_str(\"Multiple newlines\\r\\n\\r\\n\")?;\n    cwd.child(\"empty.txt\").touch()?;\n    cwd.child(\"only_newlines.txt\").write_str(\"\\n\\n\")?;\n    cwd.child(\"only_win_newlines.txt\").write_str(\"\\r\\n\\r\\n\")?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail and fix the files\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing multiple_crlf.txt\n      Fixing only_newlines.txt\n      Fixing only_win_newlines.txt\n      Fixing no_newline.txt\n      Fixing multiple_lf.txt\n\n    ----- stderr -----\n    \");\n\n    // Assert that the files have been corrected\n    assert_snapshot!(context.read(\"correct_lf.txt\"), @\"Hello World\");\n    assert_snapshot!(context.read(\"correct_crlf.txt\"), @\"Hello World\");\n    assert_snapshot!(context.read(\"no_newline.txt\"), @\"No trailing newline\");\n    assert_snapshot!(context.read(\"multiple_lf.txt\"), @\"Multiple newlines\");\n    assert_snapshot!(context.read(\"multiple_crlf.txt\"), @\"Multiple newlines\");\n    assert_snapshot!(context.read(\"empty.txt\"), @\"\");\n    assert_snapshot!(context.read(\"only_newlines.txt\"), @\"\");\n    assert_snapshot!(context.read(\"only_win_newlines.txt\"), @\"\");\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass. The output will be stable.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    fix end of files.........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_yaml_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-yaml\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"valid.yaml\").write_str(\"a: 1\")?;\n    cwd.child(\"invalid.yaml\").write_str(\"a: b: c\")?;\n    cwd.child(\"duplicate.yaml\").write_str(\"a: 1\\na: 2\")?;\n    cwd.child(\"empty.yaml\").touch()?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check yaml...............................................................Failed\n    - hook id: check-yaml\n    - exit code: 1\n\n      duplicate.yaml: Failed to yaml decode (error: line 2 column 1: duplicate mapping key: a not allowed here\n       --> <input>:2:1\n        |\n      1 | a: 1\n      2 | a: 2\n        | ^ duplicate mapping key: a not allowed here)\n      invalid.yaml: Failed to yaml decode (error: line 1 column 5: mapping values are not allowed in this context\n       --> <input>:1:5\n        |\n      1 | a: b: c\n        |     ^ mapping values are not allowed in this context)\n\n    ----- stderr -----\n    \");\n\n    // Fix the files\n    cwd.child(\"invalid.yaml\").write_str(\"a:\\n  b: c\")?;\n    cwd.child(\"duplicate.yaml\").write_str(\"a: 1\\nb: 2\")?;\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check yaml...............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_yaml_multiple_document() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-yaml\n                name: allow multiple documents\n                args: [ --allow-multiple-documents ]\n              - id: check-yaml\n                name: disallow multiple documents\n    \"});\n\n    context\n        .work_dir()\n        .child(\"multiple.yaml\")\n        .write_str(indoc::indoc! {r\"\n        ---\n        a: 1\n        ---\n        b: 2\n        \"\n        })?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    allow multiple documents.................................................Passed\n    disallow multiple documents..............................................Failed\n    - hook id: check-yaml\n    - exit code: 1\n\n      multiple.yaml: Failed to yaml decode (error: line 4 column 1: only single YAML document expected but multiple found\n       --> <input>:4:1\n        |\n      2 | a: 1\n      3 | ---\n      4 | b: 2\n        | ^ only single YAML document expected but multiple found)\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_json_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-json\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"valid.json\").write_str(r#\"{\"a\": 1}\"#)?;\n    cwd.child(\"invalid.json\").write_str(r#\"{\"a\": 1,}\"#)?;\n    cwd.child(\"duplicate.json\")\n        .write_str(r#\"{\"a\": 1, \"a\": 2}\"#)?;\n    cwd.child(\"empty.json\").touch()?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check json...............................................................Failed\n    - hook id: check-json\n    - exit code: 1\n\n      duplicate.json: Failed to json decode (duplicate key `a` at line 1 column 12)\n      invalid.json: Failed to json decode (trailing comma at line 1 column 9)\n\n    ----- stderr -----\n    \");\n\n    // Fix the files\n    cwd.child(\"invalid.json\").write_str(r#\"{\"a\": 1}\"#)?;\n    cwd.child(\"duplicate.json\")\n        .write_str(r#\"{\"a\": 1, \"b\": 2}\"#)?;\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check json...............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn mixed_line_ending_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: mixed-line-ending\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"mixed.txt\")\n        .write_str(\"line1\\nline2\\r\\nline3\\r\\n\")?;\n    cwd.child(\"only_lf.txt\").write_str(\"line1\\nline2\\n\")?;\n    cwd.child(\"only_crlf.txt\").write_str(\"line1\\r\\nline2\\r\\n\")?;\n    cwd.child(\"no_endings.txt\").write_str(\"hello world\")?;\n    cwd.child(\"empty.txt\").touch()?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail and fix the files\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    mixed line ending........................................................Failed\n    - hook id: mixed-line-ending\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing mixed.txt\n\n    ----- stderr -----\n    \");\n\n    // Assert that the files have been corrected\n    assert_snapshot!(context.read(\"mixed.txt\"), @r\"\n    line1\n    line2\n    line3\n    \");\n    assert_snapshot!(context.read(\"only_lf.txt\"), @r\"\n    line1\n    line2\n    \");\n    assert_snapshot!(context.read(\"only_crlf.txt\"), @r\"\n    line1\n    line2\n    \");\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    mixed line ending........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Test with --fix=no\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: mixed-line-ending\n                args: ['--fix=no']\n    \"});\n    context\n        .work_dir()\n        .child(\"mixed.txt\")\n        .write_str(\"line1\\nline2\\r\\n\")?;\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    mixed line ending........................................................Failed\n    - hook id: mixed-line-ending\n    - exit code: 1\n\n      mixed.txt: mixed line endings\n\n    ----- stderr -----\n    \");\n    assert_snapshot!(context.read(\"mixed.txt\"), @r\"\n    line1\n    line2\n    \");\n\n    // Test with --fix=crlf\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: mixed-line-ending\n                args: ['--fix', 'crlf']\n    \"});\n    context\n        .work_dir()\n        .child(\"mixed.txt\")\n        .write_str(\"line1\\nline2\\r\\n\")?;\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    mixed line ending........................................................Failed\n    - hook id: mixed-line-ending\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing .pre-commit-config.yaml\n      Fixing mixed.txt\n      Fixing only_lf.txt\n\n    ----- stderr -----\n    \");\n    assert_snapshot!(context.read(\"mixed.txt\"), @r\"\n    line1\n    line2\n    \");\n\n    // Test mixed args with missing value for `--fix`\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: mixed-line-ending\n                args: ['--fix']\n    \"});\n    context\n        .work_dir()\n        .child(\"mixed.txt\")\n        .write_str(\"line1\\nline2\\r\\nline3\\n\")?;\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to run hook `mixed-line-ending`\n      caused by: error: a value is required for '--fix <FIX>' but none was supplied\n      [possible values: auto, no, lf, crlf, cr]\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_added_large_files_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create an initial commit\n    let cwd = context.work_dir();\n    cwd.child(\"README.md\").write_str(\"Initial commit\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-added-large-files\n                args: ['--maxkb', '1']\n    \"});\n\n    // Create test files\n    cwd.child(\"small_file.txt\").write_str(\"Hello World\\n\")?;\n    let large_file = cwd.child(\"large_file.txt\");\n    large_file.write_binary(&[0; 2048])?; // 2KB file\n\n    context.git_add(\".\");\n\n    // First run: hook should fail because of the large file\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for added large files..............................................Failed\n    - hook id: check-added-large-files\n    - exit code: 1\n\n      large_file.txt (2 KB) exceeds 1 KB\n\n    ----- stderr -----\n    \");\n\n    // Commit the files\n    context.git_add(\".\");\n    context.git_commit(\"Add large file\");\n\n    // Create a new unstaged large file\n    let unstaged_large_file = cwd.child(\"unstaged_large_file.txt\");\n    unstaged_large_file.write_binary(&[0; 2048])?; // 2KB file\n    context.git_add(\"unstaged_large_file.txt\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-added-large-files\n                args: ['--maxkb=1', '--enforce-all']\n    \"});\n\n    // Second run: the hook should check all files even if not staged\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for added large files..............................................Failed\n    - hook id: check-added-large-files\n    - exit code: 1\n\n      unstaged_large_file.txt (2 KB) exceeds 1 KB\n      large_file.txt (2 KB) exceeds 1 KB\n\n    ----- stderr -----\n    \");\n\n    context.git_rm(\"unstaged_large_file.txt\");\n    context.git_clean();\n\n    // Test git-lfs integration\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-added-large-files\n                args: ['--maxkb=1']\n    \"});\n    cwd.child(\".gitattributes\")\n        .write_str(\"*.dat filter=lfs diff=lfs merge=lfs -text\")?;\n    context.git_add(\".gitattributes\");\n    let lfs_file = cwd.child(\"lfs_file.dat\");\n    lfs_file.write_binary(&[0; 2048])?; // 2KB file\n    context.git_add(\".\");\n\n    // Third run: hook should pass because the large file is tracked by git-lfs\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for added large files..............................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn tracked_file_exceeds_large_file_limit() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-added-large-files\n                args: ['--maxkb', '1']\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create and commit a large file\n    let large_file = cwd.child(\"large_file.txt\");\n    large_file.write_binary(&[0; 2048])?; // 2KB file\n    context.git_add(\".\");\n    context.git_commit(\"Add large file\");\n    // Modify the large file\n    large_file.write_binary(&[0; 4096])?; // 4KB file\n    context.git_add(\".\");\n\n    // Run the hook: it should pass because the file is already tracked\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for added large files..............................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn builtin_hooks_workspace_mode() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: meta\n            hooks:\n              - id: identity\n    \"});\n\n    // Subproject with built-in hooks.\n    let app = context.work_dir().child(\"app\");\n    app.create_dir_all()?;\n    app.child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc::indoc! {r\"\n        repos:\n          - repo: meta\n            hooks:\n              - id: identity\n          - repo: builtin\n            hooks:\n              - id: end-of-file-fixer\n              - id: check-yaml\n              - id: check-json\n              - id: mixed-line-ending\n              - id: trailing-whitespace\n              - id: check-added-large-files\n                args: ['--maxkb', '1']\n    \"})?;\n\n    app.child(\"eof_no_newline.txt\")\n        .write_str(\"No trailing newline\")?;\n    app.child(\"eof_multiple_lf.txt\").write_str(\"Multiple\\n\\n\")?;\n    app.child(\"mixed.txt\").write_str(\"line1\\nline2\\r\\n\")?;\n    app.child(\"trailing_ws.txt\")\n        .write_str(\"line with trailing space \\n\")?;\n    app.child(\"correct.txt\").write_str(\"All good here\\n\")?;\n\n    app.child(\"invalid.yaml\").write_str(\"a: b: c\")?;\n    app.child(\"duplicate.yaml\").write_str(\"a: 1\\na: 2\")?;\n    app.child(\"empty.yaml\").touch()?;\n\n    app.child(\"invalid.json\").write_str(r#\"{\"a\": 1,}\"#)?;\n    app.child(\"duplicate.json\")\n        .write_str(r#\"{\"a\": 1, \"a\": 2}\"#)?;\n    app.child(\"empty.json\").touch()?;\n\n    // 2KB file to trigger check-added-large-files (1 KB threshold).\n    app.child(\"large.bin\").write_binary(&[0u8; 2048])?;\n\n    context.git_add(\".\");\n\n    // First run: expect failures and auto-fixes where applicable.\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Running hooks for `app`:\n    identity.................................................................Passed\n    - hook id: identity\n    - duration: [TIME]\n\n      correct.txt\n      invalid.yaml\n      empty.json\n      duplicate.json\n      trailing_ws.txt\n      large.bin\n      eof_multiple_lf.txt\n      duplicate.yaml\n      empty.yaml\n      mixed.txt\n      invalid.json\n      .pre-commit-config.yaml\n      eof_no_newline.txt\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing invalid.yaml\n      Fixing duplicate.json\n      Fixing eof_no_newline.txt\n      Fixing eof_multiple_lf.txt\n      Fixing duplicate.yaml\n      Fixing invalid.json\n    check yaml...............................................................Failed\n    - hook id: check-yaml\n    - exit code: 1\n\n      duplicate.yaml: Failed to yaml decode (error: line 2 column 1: duplicate mapping key: a not allowed here\n       --> <input>:2:1\n        |\n      1 | a: 1\n      2 | a: 2\n        | ^ duplicate mapping key: a not allowed here)\n      invalid.yaml: Failed to yaml decode (error: line 1 column 5: mapping values are not allowed in this context\n       --> <input>:1:5\n        |\n      1 | a: b: c\n        |     ^ mapping values are not allowed in this context)\n    check json...............................................................Failed\n    - hook id: check-json\n    - exit code: 1\n\n      duplicate.json: Failed to json decode (duplicate key `a` at line 1 column 12)\n      invalid.json: Failed to json decode (trailing comma at line 1 column 9)\n    mixed line ending........................................................Failed\n    - hook id: mixed-line-ending\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing mixed.txt\n    trim trailing whitespace.................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing trailing_ws.txt\n    check for added large files..............................................Passed\n\n    Running hooks for `.`:\n    identity.................................................................Passed\n    - hook id: identity\n    - duration: [TIME]\n\n      app/.pre-commit-config.yaml\n      app/invalid.json\n      app/duplicate.yaml\n      app/correct.txt\n      app/mixed.txt\n      app/invalid.yaml\n      app/empty.yaml\n      app/duplicate.json\n      app/empty.json\n      app/large.bin\n      app/eof_no_newline.txt\n      .pre-commit-config.yaml\n      app/eof_multiple_lf.txt\n      app/trailing_ws.txt\n\n    ----- stderr -----\n    \");\n\n    // Fix YAML and JSON issues, then stage.\n    app.child(\"invalid.yaml\").write_str(\"a:\\n  b: c\")?;\n    app.child(\"duplicate.yaml\").write_str(\"a: 1\\nb: 2\")?;\n    app.child(\"invalid.json\").write_str(r#\"{\"a\": 1}\"#)?;\n    app.child(\"duplicate.json\")\n        .write_str(r#\"{\"a\": 1, \"b\": 2}\"#)?;\n    context.git_add(\".\");\n\n    // Second run: all hooks should pass.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Running hooks for `app`:\n    identity.................................................................Passed\n    - hook id: identity\n    - duration: [TIME]\n\n      correct.txt\n      invalid.yaml\n      empty.json\n      duplicate.json\n      trailing_ws.txt\n      large.bin\n      eof_multiple_lf.txt\n      duplicate.yaml\n      empty.yaml\n      mixed.txt\n      invalid.json\n      .pre-commit-config.yaml\n      eof_no_newline.txt\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing invalid.yaml\n      Fixing duplicate.json\n      Fixing duplicate.yaml\n      Fixing invalid.json\n    check yaml...............................................................Passed\n    check json...............................................................Passed\n    mixed line ending........................................................Passed\n    trim trailing whitespace.................................................Passed\n    check for added large files..............................................Passed\n\n    Running hooks for `.`:\n    identity.................................................................Passed\n    - hook id: identity\n    - duration: [TIME]\n\n      app/.pre-commit-config.yaml\n      app/invalid.json\n      app/duplicate.yaml\n      app/correct.txt\n      app/mixed.txt\n      app/invalid.yaml\n      app/empty.yaml\n      app/duplicate.json\n      app/empty.json\n      app/large.bin\n      app/eof_no_newline.txt\n      .pre-commit-config.yaml\n      app/eof_multiple_lf.txt\n      app/trailing_ws.txt\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn fix_byte_order_marker_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: fix-byte-order-marker\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"without_bom.txt\").write_str(\"Hello, World!\")?;\n    cwd.child(\"with_bom.txt\").write_binary(&[\n        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',\n        b'!',\n    ])?;\n    cwd.child(\"bom_only.txt\")\n        .write_binary(&[0xef, 0xbb, 0xbf])?;\n    cwd.child(\"empty.txt\").touch()?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fix files with BOM\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    fix utf-8 byte order marker..............................................Failed\n    - hook id: fix-byte-order-marker\n    - exit code: 1\n    - files were modified by this hook\n\n      bom_only.txt: removed byte-order marker\n      with_bom.txt: removed byte-order marker\n\n    ----- stderr -----\n    \");\n\n    // Verify the content is correct\n    assert_eq!(context.read(\"with_bom.txt\"), \"Hello, World!\");\n    assert_eq!(context.read(\"bom_only.txt\"), \"\");\n    assert_eq!(context.read(\"without_bom.txt\"), \"Hello, World!\");\n    assert_eq!(context.read(\"empty.txt\"), \"\");\n\n    context.git_add(\".\");\n\n    // Second run: all should pass now\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    fix utf-8 byte order marker..............................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\n#[cfg(unix)]\nfn check_symlinks_hook_unix() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-symlinks\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"regular.txt\").write_str(\"regular file\")?;\n    cwd.child(\"target.txt\").write_str(\"target content\")?;\n\n    // Create valid symlink\n    std::os::unix::fs::symlink(\n        cwd.child(\"target.txt\").path(),\n        cwd.child(\"valid_link.txt\").path(),\n    )?;\n\n    // Create broken symlink\n    std::os::unix::fs::symlink(\n        cwd.child(\"nonexistent.txt\").path(),\n        cwd.child(\"broken_link.txt\").path(),\n    )?;\n\n    context.git_add(\".\");\n\n    // First run: should fail due to broken symlink\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for broken symlinks................................................Failed\n    - hook id: check-symlinks\n    - exit code: 1\n\n      broken_link.txt: Broken symlink\n\n    ----- stderr -----\n    \");\n\n    // Remove broken symlink\n    std::fs::remove_file(cwd.child(\"broken_link.txt\").path())?;\n    context.git_add(\".\");\n\n    // Second run: should pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for broken symlinks................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\n#[cfg(windows)]\nfn check_symlinks_hook_windows() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-symlinks\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"regular.txt\").write_str(\"regular file\")?;\n    cwd.child(\"target.txt\").write_str(\"target content\")?;\n\n    // Try to create valid symlink (may fail without admin/developer mode)\n    let valid_link_result = std::os::windows::fs::symlink_file(\n        cwd.child(\"target.txt\").path(),\n        cwd.child(\"valid_link.txt\").path(),\n    );\n\n    // Try to create broken symlink (may fail without admin/developer mode)\n    let broken_link_result = std::os::windows::fs::symlink_file(\n        cwd.child(\"nonexistent.txt\").path(),\n        cwd.child(\"broken_link.txt\").path(),\n    );\n\n    // Skip test if we can't create symlinks (insufficient permissions)\n    if valid_link_result.is_err() || broken_link_result.is_err() {\n        // Skipping test: insufficient permissions for symlink creation on Windows\n        return Ok(());\n    }\n\n    context.git_add(\".\");\n\n    // First run: should fail due to broken symlink\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for broken symlinks................................................Failed\n    - hook id: check-symlinks\n    - exit code: 1\n\n      broken_link.txt: Broken symlink\n\n    ----- stderr -----\n    \"#);\n\n    // Remove broken symlink\n    std::fs::remove_file(cwd.child(\"broken_link.txt\").path())?;\n    context.git_add(\".\");\n\n    // Second run: should pass\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for broken symlinks................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn detect_private_key_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: detect-private-key\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files - various private key types\n    cwd.child(\"id_rsa\")\n        .write_str(\"-----BEGIN RSA PRIVATE KEY-----\\nMIIE...\\n-----END RSA PRIVATE KEY-----\\n\")?;\n    cwd.child(\"id_dsa\")\n        .write_str(\"-----BEGIN DSA PRIVATE KEY-----\\nAAAAA...\\n-----END DSA PRIVATE KEY-----\\n\")?;\n    cwd.child(\"id_ecdsa\")\n        .write_str(\"-----BEGIN EC PRIVATE KEY-----\\nMHc...\\n-----END EC PRIVATE KEY-----\\n\")?;\n    cwd.child(\"id_ed25519\").write_str(\n        \"-----BEGIN OPENSSH PRIVATE KEY-----\\nb3BlbnNz...\\n-----END OPENSSH PRIVATE KEY-----\\n\",\n    )?;\n    cwd.child(\"key.ppk\")\n        .write_str(\"PuTTY-User-Key-File-2: ssh-rsa\\nEncryption: none\\n\")?;\n    cwd.child(\"private.asc\")\n        .write_str(\"-----BEGIN PGP PRIVATE KEY BLOCK-----\\nVersion: GnuPG...\\n\")?;\n    cwd.child(\"ta.key\").write_str(\n        \"#\\n# 2048 bit OpenVPN static key\\n#\\n-----BEGIN OpenVPN Static key V1-----\\n\",\n    )?;\n    cwd.child(\"doc.txt\").write_str(\n        \"Some documentation\\n\\nHere is a key:\\n-----BEGIN RSA PRIVATE KEY-----\\ndata\\n\",\n    )?;\n    cwd.child(\"safe1.txt\")\n        .write_str(\"This file talks about BEGIN_RSA_PRIVATE_KEY but doesn't contain one\\n\")?;\n\n    cwd.child(\"safe2.txt\")\n        .write_str(\"This is just a regular file\\nwith some content\\n\")?;\n    cwd.child(\"empty.txt\").touch()?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail due to private keys\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    detect private key.......................................................Failed\n    - hook id: detect-private-key\n    - exit code: 1\n\n      Private key found: doc.txt\n      Private key found: id_ecdsa\n      Private key found: key.ppk\n      Private key found: id_rsa\n      Private key found: id_dsa\n      Private key found: id_ed25519\n      Private key found: ta.key\n      Private key found: private.asc\n\n    ----- stderr -----\n    \");\n\n    // Remove all private keys\n    context.git_rm(\"id_rsa\");\n    context.git_rm(\"id_dsa\");\n    context.git_rm(\"id_ecdsa\");\n    context.git_rm(\"id_ed25519\");\n    context.git_rm(\"key.ppk\");\n    context.git_rm(\"private.asc\");\n    context.git_rm(\"ta.key\");\n    context.git_rm(\"doc.txt\");\n    context.git_clean();\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    detect private key.......................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_merge_conflict_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-merge-conflict\n                args: ['--assume-in-merge']\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files with conflict markers\n    cwd.child(\"conflict.txt\").write_str(indoc::indoc! {r\"\n        Before conflict\n        <<<<<<< HEAD\n        Our changes\n        =======\n        Their changes\n        >>>>>>> branch\n        After conflict\n    \"})?;\n\n    cwd.child(\"clean.txt\").write_str(\"No conflicts here\\n\")?;\n\n    cwd.child(\"partial_conflict.txt\")\n        .write_str(indoc::indoc! {r\"\n        Some content\n        <<<<<<< HEAD\n        Conflicting line\n    \"})?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail due to conflict markers\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for merge conflicts................................................Failed\n    - hook id: check-merge-conflict\n    - exit code: 1\n\n      partial_conflict.txt:2: Merge conflict string \"<<<<<<< \" found\n      conflict.txt:2: Merge conflict string \"<<<<<<< \" found\n      conflict.txt:4: Merge conflict string \"=======\" found\n      conflict.txt:6: Merge conflict string \">>>>>>> \" found\n\n    ----- stderr -----\n    \"#);\n\n    // Fix the files by removing conflict markers\n    cwd.child(\"conflict.txt\").write_str(indoc::indoc! {r\"\n        Before conflict\n        Our changes\n        After conflict\n    \"})?;\n\n    cwd.child(\"partial_conflict.txt\")\n        .write_str(\"Some content\\nResolved line\\n\")?;\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for merge conflicts................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_merge_conflict_without_assume_flag() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Without --assume-in-merge, hook should pass even with conflict markers\n    // if we're not actually in a merge state\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-merge-conflict\n    \"});\n\n    let cwd = context.work_dir();\n\n    cwd.child(\"conflict.txt\").write_str(indoc::indoc! {r\"\n        <<<<<<< HEAD\n        Our changes\n        =======\n        Their changes\n        >>>>>>> branch\n    \"})?;\n\n    context.git_add(\".\");\n\n    // Should pass because we're not in a merge state and no --assume-in-merge flag\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for merge conflicts................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_xml_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-xml\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"valid.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</element>\n</root>\"#,\n    )?;\n    cwd.child(\"invalid_unclosed.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value\n</root>\"#,\n    )?;\n    cwd.child(\"invalid_mismatched.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</different>\n</root>\"#,\n    )?;\n    cwd.child(\"multiple_roots.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<element>value</element>\n<another>value</another>\"#,\n    )?;\n    cwd.child(\"empty.xml\").touch()?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check xml................................................................Failed\n    - hook id: check-xml\n    - exit code: 1\n\n      invalid_mismatched.xml: Failed to xml parse (ill-formed document: expected `</element>`, but `</different>` was found)\n      empty.xml: Failed to xml parse (no element found)\n      invalid_unclosed.xml: Failed to xml parse (ill-formed document: expected `</element>`, but `</root>` was found)\n      multiple_roots.xml: Failed to xml parse (junk after document element)\n\n    ----- stderr -----\n    \");\n\n    // Fix the files\n    cwd.child(\"invalid_unclosed.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</element>\n</root>\"#,\n    )?;\n    cwd.child(\"invalid_mismatched.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</element>\n</root>\"#,\n    )?;\n    cwd.child(\"multiple_roots.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element>value</element>\n    <another>value</another>\n</root>\"#,\n    )?;\n\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check xml................................................................Failed\n    - hook id: check-xml\n    - exit code: 1\n\n      empty.xml: Failed to xml parse (no element found)\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_xml_with_features() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-xml\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files with various XML features\n    cwd.child(\"with_attributes.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root xmlns=\"http://example.com\">\n    <element id=\"1\" type=\"test\">value</element>\n</root>\"#,\n    )?;\n    cwd.child(\"with_cdata.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <element><![CDATA[Some <special> characters & symbols]]></element>\n</root>\"#,\n    )?;\n    cwd.child(\"with_comments.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n    <!-- This is a comment -->\n    <element>value</element>\n</root>\"#,\n    )?;\n    cwd.child(\"with_doctype.xml\").write_str(\n        r#\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE root SYSTEM \"root.dtd\">\n<root>\n    <element>value</element>\n</root>\"#,\n    )?;\n\n    context.git_add(\".\");\n\n    // All should pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check xml................................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn no_commit_to_branch_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: no-commit-to-branch\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create a test file\n    cwd.child(\"test.txt\").write_str(\"Hello World\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Test 1: Try to commit to master branch (should fail)\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'master'\n\n    ----- stderr -----\n    \");\n\n    // Test 2: Create and switch to a feature branch (should pass)\n    context.git_branch(\"feature/new-feature\");\n    context.git_checkout(\"feature/new-feature\");\n\n    cwd.child(\"feature.txt\").write_str(\"Feature content\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Add feature\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    don't commit to branch...................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Test 3: Try to commit to main branch (should fail)\n    context.git_branch(\"main\");\n    context.git_checkout(\"main\");\n\n    cwd.child(\"main.txt\").write_str(\"Main content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'main'\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn no_commit_to_branch_hook_with_custom_branches() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: no-commit-to-branch\n                args: ['--branch', 'develop', '--branch', 'production']\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create a test file\n    cwd.child(\"test.txt\").write_str(\"Hello World\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Test 1: Try to commit to master branch (should pass - not in custom list)\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    don't commit to branch...................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Test 2: Create and switch to develop branch (should fail)\n    context.git_branch(\"develop\");\n    context.git_checkout(\"develop\");\n\n    cwd.child(\"develop.txt\").write_str(\"Develop content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'develop'\n\n    ----- stderr -----\n    \");\n\n    // Test 3: Create and switch to production branch (should fail)\n    context.git_branch(\"production\");\n    context.git_checkout(\"production\");\n\n    cwd.child(\"production.txt\")\n        .write_str(\"Production content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'production'\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn no_commit_to_branch_hook_with_patterns() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: no-commit-to-branch\n                args: ['--pattern', '^feature/.*', '--pattern', '.*-wip$']\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create a test file\n    cwd.child(\"test.txt\").write_str(\"Hello World\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Test 1: Try to commit to master branch (should fail - If branch is not specified, branch defaults to master and main)\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'master'\n\n    ----- stderr -----\n    \");\n\n    // Test 2: Create and switch to feature branch (should fail - matches pattern)\n    context.git_branch(\"feature/new-feature\");\n    context.git_checkout(\"feature/new-feature\");\n\n    cwd.child(\"feature.txt\").write_str(\"Feature content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'feature/new-feature'\n\n    ----- stderr -----\n    \");\n\n    // Test 3: Create and switch to wip branch (should fail - matches pattern)\n    context.git_branch(\"my-branch-wip\");\n    context.git_checkout(\"my-branch-wip\");\n\n    cwd.child(\"wip.txt\").write_str(\"WIP content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    don't commit to branch...................................................Failed\n    - hook id: no-commit-to-branch\n    - exit code: 1\n\n      You are not allowed to commit to branch 'my-branch-wip'\n\n    ----- stderr -----\n    \");\n\n    // Test 4: Create and switch to normal branch (should pass - doesn't match patterns)\n    context.git_branch(\"normal-branch\");\n    context.git_checkout(\"normal-branch\");\n\n    cwd.child(\"normal.txt\").write_str(\"Normal content\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Add normal content\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    don't commit to branch...................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Test 5: Try to run with detached head pointer status (should pass - ignore this status)\n    context.git_checkout(\"HEAD~1\");\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    don't commit to branch...................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Test 6: Try to commit to branch with invalid pattern (should fail - invalid pattern)\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: no-commit-to-branch\n                args: ['--pattern', '*invalid-pattern*']\n    \"});\n\n    context.git_branch(\"invalid-branch\");\n    context.git_checkout(\"invalid-branch\");\n\n    cwd.child(\"invalid.txt\").write_str(\"Invalid content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to run hook `no-commit-to-branch`\n      caused by: Failed to compile regex patterns\n      caused by: Parsing error at position 0: Target of repeat operator is invalid\n    \");\n\n    Ok(())\n}\n\n#[cfg(unix)]\n#[test]\nfn check_executables_have_shebangs_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-executables-have-shebangs\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"script_with_shebang.sh\")\n        .write_str(\"#!/bin/bash\\necho ok\\n\")?;\n    cwd.child(\"script_without_shebang.sh\")\n        .write_str(\"echo missing shebang\\n\")?;\n    cwd.child(\"not_executable.txt\")\n        .write_str(\"not executable\\n\")?;\n    cwd.child(\"empty.sh\").touch()?;\n\n    // Mark scripts as executable\n    std::fs::set_permissions(\n        cwd.child(\"script_with_shebang.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n    std::fs::set_permissions(\n        cwd.child(\"script_without_shebang.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n    std::fs::set_permissions(\n        cwd.child(\"empty.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n\n    context.git_add(\".\");\n\n    // First run: should fail for script_without_shebang.sh and empty.sh\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check that executables have shebangs.....................................Failed\n    - hook id: check-executables-have-shebangs\n    - exit code: 1\n\n      empty.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x empty.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x empty.sh'\n        If it is supposed to be executable, double-check its shebang.\n      script_without_shebang.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x script_without_shebang.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x script_without_shebang.sh'\n        If it is supposed to be executable, double-check its shebang.\n\n    ----- stderr -----\n    \");\n\n    // Fix the files: remove executable bit or add shebang\n    cwd.child(\"script_without_shebang.sh\")\n        .write_str(\"#!/bin/sh\\necho fixed\\n\")?;\n    std::fs::set_permissions(\n        cwd.child(\"empty.sh\").path(),\n        std::fs::Permissions::from_mode(0o644),\n    )?;\n\n    context.git_add(\".\");\n\n    // Second run: should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check that executables have shebangs.....................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[cfg(windows)]\n#[test]\nfn check_executables_have_shebangs_win() -> Result<()> {\n    use crate::common::git_cmd;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-executables-have-shebangs\n    \"});\n\n    let cwd = context.work_dir();\n\n    cwd.child(\"win_script_with_shebang.sh\")\n        .write_str(\"#!/bin/bash\\necho ok\\n\")?;\n    cwd.child(\"win_script_without_shebang.sh\")\n        .write_str(\"missing shebang\\n\")?;\n\n    context.git_add(\".\");\n\n    git_cmd(repo_path)\n        .args([\"update-index\", \"--chmod=+x\", \"win_script_with_shebang.sh\"])\n        .status()?;\n\n    git_cmd(repo_path)\n        .args([\n            \"update-index\",\n            \"--chmod=+x\",\n            \"win_script_without_shebang.sh\",\n        ])\n        .status()?;\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check that executables have shebangs.....................................Failed\n    - hook id: check-executables-have-shebangs\n    - exit code: 1\n\n      win_script_without_shebang.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x win_script_without_shebang.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x win_script_without_shebang.sh'\n        If it is supposed to be executable, double-check its shebang.\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[cfg(unix)]\n#[test]\nfn check_executables_have_shebangs_various_cases() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-executables-have-shebangs\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"partial_shebang.sh\")\n        .write_str(\"#\\necho partial\\n\")?;\n    cwd.child(\"shebang_with_space.sh\")\n        .write_str(\"#! /bin/bash\\necho ok\\n\")?;\n    cwd.child(\"non_executable.txt\")\n        .write_str(\"not executable\\n\")?;\n    cwd.child(\"whitespace.sh\").write_str(\"   \\n\")?;\n    cwd.child(\"invalid_shebang.sh\")\n        .write_str(\"##!/bin/bash\\necho bad\\n\")?;\n\n    // Mark scripts as executable\n    std::fs::set_permissions(\n        cwd.child(\"partial_shebang.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n    std::fs::set_permissions(\n        cwd.child(\"shebang_with_space.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n    std::fs::set_permissions(\n        cwd.child(\"whitespace.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n    std::fs::set_permissions(\n        cwd.child(\"invalid_shebang.sh\").path(),\n        std::fs::Permissions::from_mode(0o755),\n    )?;\n    // non_executable.txt is not marked executable\n\n    context.git_add(\".\");\n\n    // Run: should fail for partial_shebang.sh, whitespace.sh, invalid_shebang.sh\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check that executables have shebangs.....................................Failed\n    - hook id: check-executables-have-shebangs\n    - exit code: 1\n\n      partial_shebang.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x partial_shebang.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x partial_shebang.sh'\n        If it is supposed to be executable, double-check its shebang.\n      invalid_shebang.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x invalid_shebang.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x invalid_shebang.sh'\n        If it is supposed to be executable, double-check its shebang.\n      whitespace.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x whitespace.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x whitespace.sh'\n        If it is supposed to be executable, double-check its shebang.\n\n    ----- stderr -----\n    \");\n\n    // Fix the files: add valid shebangs or remove executable bit\n    cwd.child(\"partial_shebang.sh\")\n        .write_str(\"#!/bin/sh\\necho fixed\\n\")?;\n    cwd.child(\"whitespace.sh\").write_str(\"#!/bin/sh\\n\")?;\n    cwd.child(\"invalid_shebang.sh\")\n        .write_str(\"#!/bin/bash\\necho fixed\\n\")?;\n\n    context.git_add(\".\");\n\n    // Second run: should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check that executables have shebangs.....................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[cfg(windows)]\n#[test]\nfn check_executables_have_shebangs_various_cases_win() -> Result<()> {\n    use crate::common::git_cmd;\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-executables-have-shebangs\n    \"});\n\n    let cwd = context.work_dir();\n\n    cwd.child(\"partial_shebang.sh\")\n        .write_str(\"#\\necho partial\\n\")?;\n    cwd.child(\"shebang_with_space.sh\")\n        .write_str(\"#! /bin/bash\\necho ok\\n\")?;\n    cwd.child(\"non_executable.txt\")\n        .write_str(\"not executable\\n\")?;\n    cwd.child(\"whitespace.sh\").write_str(\"   \\n\")?;\n    cwd.child(\"invalid_shebang.sh\")\n        .write_str(\"##!/bin/bash\\necho bad\\n\")?;\n\n    context.git_add(\".\");\n\n    let executable_files = [\n        \"partial_shebang.sh\",\n        \"shebang_with_space.sh\",\n        \"whitespace.sh\",\n        \"invalid_shebang.sh\",\n    ];\n\n    for file in &executable_files {\n        git_cmd(cwd.path())\n            .args([\"update-index\", \"--chmod=+x\", file])\n            .status()?;\n    }\n\n    // Run: should fail for partial_shebang.sh, whitespace.sh, invalid_shebang.sh\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check that executables have shebangs.....................................Failed\n    - hook id: check-executables-have-shebangs\n    - exit code: 1\n\n      invalid_shebang.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x invalid_shebang.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x invalid_shebang.sh'\n        If it is supposed to be executable, double-check its shebang.\n      partial_shebang.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x partial_shebang.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x partial_shebang.sh'\n        If it is supposed to be executable, double-check its shebang.\n      whitespace.sh marked executable but has no (or invalid) shebang!\n        If it isn't supposed to be executable, try: 'chmod -x whitespace.sh'\n        If on Windows, you may also need to: 'git add --chmod=-x whitespace.sh'\n        If it is supposed to be executable, double-check its shebang.\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\nfn is_case_sensitive_filesystem(context: &TestContext) -> Result<bool> {\n    let test_lower = context.work_dir().child(\"case_test_file.txt\");\n    test_lower.write_str(\"test\")?;\n    let test_upper = context.work_dir().child(\"CASE_TEST_FILE.txt\");\n    let is_sensitive = !test_upper.exists();\n    fs_err::remove_file(test_lower.path())?;\n    Ok(is_sensitive)\n}\n\n#[test]\nfn check_case_conflict_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    if !is_case_sensitive_filesystem(&context)? {\n        // Skipping test on case-insensitive filesystem\n        return Ok(());\n    }\n\n    // Create initial files and commit\n    let cwd = context.work_dir();\n    cwd.child(\"README.md\").write_str(\"Initial commit\")?;\n    cwd.child(\"src/foo.txt\").write_str(\"existing file\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-case-conflict\n    \"});\n\n    // Try to add a file with conflicting case\n    cwd.child(\"src/FOO.txt\").write_str(\"conflicting case\")?;\n    context.git_add(\".\");\n\n    // First run: should fail due to case conflict\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for case conflicts.................................................Failed\n    - hook id: check-case-conflict\n    - exit code: 1\n\n      Case-insensitivity conflict found: src/FOO.txt\n      Case-insensitivity conflict found: src/foo.txt\n\n    ----- stderr -----\n    \"#);\n\n    // Remove the conflicting file\n    context.git_rm(\"src/FOO.txt\");\n\n    // Add a non-conflicting file\n    cwd.child(\"src/bar.txt\").write_str(\"no conflict\")?;\n    context.git_add(\".\");\n\n    // Second run: should pass\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check for case conflicts.................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn check_case_conflict_directory() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    if !is_case_sensitive_filesystem(&context)? {\n        // Skipping test on case-insensitive filesystem\n        return Ok(());\n    }\n\n    // Create directory with file\n    let cwd = context.work_dir();\n    cwd.child(\"src/utils/helper.py\").write_str(\"helper\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-case-conflict\n    \"});\n\n    // Try to add a file that conflicts with directory name\n    cwd.child(\"src/UTILS/other.py\").write_str(\"conflict\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for case conflicts.................................................Failed\n    - hook id: check-case-conflict\n    - exit code: 1\n\n      Case-insensitivity conflict found: src/UTILS\n      Case-insensitivity conflict found: src/utils\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn check_case_conflict_among_new_files() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    if !is_case_sensitive_filesystem(&context)? {\n        // Skipping test on case-insensitive filesystem\n        return Ok(());\n    }\n\n    let cwd = context.work_dir();\n    cwd.child(\"README.md\").write_str(\"Initial\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-case-conflict\n    \"});\n\n    // Add multiple new files with conflicting cases\n    cwd.child(\"NewFile.txt\").write_str(\"file 1\")?;\n    cwd.child(\"newfile.txt\").write_str(\"file 2\")?;\n    cwd.child(\"NEWFILE.TXT\").write_str(\"file 3\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check for case conflicts.................................................Failed\n    - hook id: check-case-conflict\n    - exit code: 1\n\n      Case-insensitivity conflict found: NEWFILE.TXT\n      Case-insensitivity conflict found: NewFile.txt\n      Case-insensitivity conflict found: newfile.txt\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn check_json5() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: check-json5\n    \"});\n\n    let cwd = context.work_dir();\n\n    // Create test files\n    cwd.child(\"valid.json5\").write_str(indoc::indoc! {r\"\n        // This is a comment\n        {\n            unquotedKey: 'value', // Trailing comma\n            anotherKey: 12345,\n        }\n    \"})?;\n    cwd.child(\"invalid_missing_comma.json5\")\n        .write_str(indoc::indoc! {r\"\n        {\n            key1: 'value1'\n            key2: 'value2', // Missing comma between key-value pairs\n        }\n    \"})?;\n\n    context.git_add(\".\");\n\n    // First run: hooks should fail\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check json5..............................................................Failed\n    - hook id: check-json5\n    - exit code: 1\n\n      invalid_missing_comma.json5: Failed to json5 decode (expected comma at line 3 column 5)\n\n    ----- stderr -----\n    \");\n\n    // Fix the files\n    cwd.child(\"invalid_missing_comma.json5\")\n        .write_str(indoc::indoc! {r\"\n        {\n            key1: 'value1',\n            key2: 'value2',\n        }\n    \"})?;\n    context.git_add(\".\");\n\n    // Second run: hooks should now pass\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check json5..............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that builtin hooks work correctly even when a system-wide binary with the\n/// same name exists on PATH (regression test for <https://github.com/j178/prek/issues/1412>).\n///\n/// When pre-commit-hooks is installed system-wide via pip, binaries like\n/// `trailing-whitespace-fixer` are placed in PATH. These binaries have shebangs\n/// (e.g., `#!/usr/bin/python3`). Before the fix, `resolve(None)` would find these\n/// binaries, parse their shebangs, and corrupt argument parsing.\n#[test]\n#[cfg(unix)]\nfn builtin_hooks_ignore_system_path_binaries() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a fake \"trailing-whitespace-fixer\" binary with a shebang in a temp dir.\n    // This simulates `pip install pre-commit-hooks` which places such binaries in PATH.\n    let fake_bin_dir = context.home_dir().child(\"fake_bin\");\n    fake_bin_dir.create_dir_all()?;\n\n    let fake_binary = fake_bin_dir.child(\"trailing-whitespace-fixer\");\n    fake_binary.write_str(\"#!/usr/bin/python3\\n# fake binary\\n\")?;\n    std::fs::set_permissions(fake_binary.path(), std::fs::Permissions::from_mode(0o755))?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: trailing-whitespace\n    \"});\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.txt\").write_str(\"hello world   \\n\")?;\n    context.git_add(\".\");\n\n    // Prepend the fake bin directory to PATH so the fake binary is found first.\n    let original_path = EnvVars::var_os(EnvVars::PATH).unwrap_or_default();\n    let mut new_path = std::ffi::OsString::from(fake_bin_dir.path());\n    new_path.push(\":\");\n    new_path.push(&original_path);\n\n    // Run prek with the modified PATH.\n    // Before the fix: this would fail with a clap argument parsing error like:\n    //   \"unexpected argument '/path/to/trailing-whitespace-fixer' found\"\n    // After the fix: this should pass because builtin hooks use split() not resolve(None).\n    cmd_snapshot!(context.filters(), context.run().env(\"PATH\", new_path), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trim trailing whitespace.................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing test.txt\n\n    ----- stderr -----\n    \");\n\n    // Verify the file was fixed (trailing whitespace removed).\n    assert_eq!(context.read(\"test.txt\"), \"hello world\\n\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/cache.rs",
    "content": "use assert_fs::assert::PathAssert;\nuse assert_fs::fixture::{ChildPath, PathChild, PathCreateDir};\nuse assert_fs::prelude::FileWriteStr;\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\nuse serde_json::json;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\n#[test]\nfn cache_dir() {\n    let context = TestContext::new();\n    let home = context.work_dir().child(\"home\");\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"dir\").env(\"PREK_HOME\", &*home), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [TEMP_DIR]/home\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn cache_gc_verbose_shows_removed_entries() {\n    let context = TestContext::new();\n\n    context.write_pre_commit_config(\"repos: []\\n\");\n    let home = context.home_dir();\n\n    // Seed store entries that will be removed.\n    home.child(\"repos/deadbeef\")\n        .create_dir_all()\n        .expect(\"create repo dir\");\n    home.child(\"repos/deadbeef/.prek-repo.json\")\n        .write_str(\n            &serde_json::to_string_pretty(&json!({\n                \"repo\": \"https://github.com/pre-commit/pre-commit-hooks\",\n                \"rev\": \"v1.0.0\",\n            }))\n            .expect(\"serialize repo marker\"),\n        )\n        .expect(\"write repo marker\");\n    home.child(\"hooks/hook-env-dead\")\n        .create_dir_all()\n        .expect(\"create hook env dir\");\n    home.child(\"hooks/hook-env-dead/.prek-hook.json\")\n        .write_str(\n            &serde_json::to_string_pretty(&json!({\n                \"language\": \"python\",\n                \"language_version\": \"3.12.0\",\n                \"dependencies\": [\n                    \"https://example.com/repo@v1.0.0\",\n                    \"dep1\",\n                    \"dep2\",\n                    \"dep3\",\n                    \"dep4\",\n                    \"dep5\",\n                    \"dep6\",\n                    \"dep7\",\n                ],\n                \"env_path\": home.child(\"hooks/hook-env-dead\").path(),\n                \"toolchain\": \"/usr/bin/python3\",\n                \"extra\": {},\n            }))\n            .expect(\"serialize hook marker\"),\n        )\n        .expect(\"write hook marker\");\n\n    home.child(\"cache/go\")\n        .create_dir_all()\n        .expect(\"create cache dir\");\n\n    // Have a tracked config that exists but references nothing (so everything above is unreferenced).\n    let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);\n    write_config_tracking_file(home, &[config_path.path()]).expect(\"write tracking file\");\n\n    cmd_snapshot!(context.filters(), context.command().args([\"cache\", \"gc\", \"-v\"]),@r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 1 repo, 1 hook env, 1 cache entry ([SIZE])\n\n    Removed 1 repo:\n    - https://github.com/pre-commit/pre-commit-hooks@v1.0.0\n      path: [HOME]/repos/deadbeef\n\n    Removed 1 hook env:\n    - python env\n      path: [HOME]/hooks/hook-env-dead\n      language: python (3.12.0)\n      repo: https://example.com/repo@v1.0.0\n      deps: dep1, dep2, dep3, dep4, dep5, dep6, … (+1 more)\n\n    Removed 1 cache entry:\n    - go\n      path: [HOME]/cache/go\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn cache_clean() -> anyhow::Result<()> {\n    let context = TestContext::new().with_filtered_cache_clean_summary();\n\n    let home = context.work_dir().child(\"home\");\n    home.create_dir_all()?;\n    home.child(\"cache/nested\").create_dir_all()?;\n    home.child(\"cache/data.bin\").write_str(\"hello\")?;\n    home.child(\"cache/nested/data.bin\").write_str(\"world!\")?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"clean\").env(\"PREK_HOME\", &*home), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    Removed [N] file(s) ([SIZE])\n    \");\n\n    home.assert(predicates::path::missing());\n\n    // Test `prek clean` works for backward compatibility\n    home.create_dir_all()?;\n    home.child(\"cache\").create_dir_all()?;\n    home.child(\"cache/one.txt\").write_str(\"abc\")?;\n    cmd_snapshot!(context.filters(), context.command().arg(\"clean\").env(\"PREK_HOME\", &*home), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    Removed [N] file(s) ([SIZE])\n    \");\n\n    home.assert(predicates::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn cache_size() -> anyhow::Result<()> {\n    let context = TestContext::new().with_filtered_cache_size();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: end-of-file-fixer\n    \"});\n\n    cwd.child(\"file.txt\").write_str(\"Hello, world!\\n\")?;\n    context.git_add(\".\");\n\n    context.run();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"size\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [SIZE]\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"size\").arg(\"-H\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [SIZE]\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_removes_unreferenced_entries() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v6.0.0\n            hooks:\n              - id: check-yaml\n          - repo: local\n            hooks:\n              - id: python-hook\n                name: Python Hook\n                entry: python -c \"print('Hello from Python')\"\n                language: python\n    \"#});\n\n    cwd.child(\"valid.yaml\").write_str(\"a: 1\\n\")?;\n    context.git_add(\".\");\n\n    let home = context.home_dir();\n    // Populate store + config tracking.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check yaml...............................................................Passed\n    Python Hook..............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Add a few obviously-unused entries.\n    home.child(\"repos/unused-repo\").create_dir_all()?;\n    home.child(\"hooks/unused-hook-env\").create_dir_all()?;\n    home.child(\"tools/node\").create_dir_all()?;\n    home.child(\"cache/go\").create_dir_all()?;\n\n    // Reduce hooks\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v6.0.0\n            hooks:\n              - id: check-yaml\n    \"});\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"gc\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 1 repo, 2 hook envs, 1 tool, 1 cache entry ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    home.child(\"repos/unused-repo\")\n        .assert(predicates::path::missing());\n    home.child(\"hooks/unused-hook-env\")\n        .assert(predicates::path::missing());\n    home.child(\"tools/node\").assert(predicates::path::missing());\n    home.child(\"cache/go\").assert(predicates::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-python\n                name: Local Python Hook\n                entry: \"python -c \\\"print(1)\\\"\"\n                language: python\n              - id: local-pygrep\n                name: Local Pygrep Hook\n                entry: \"python -c \\\"print(1)\\\"\"\n                language: pygrep\n              - id: local-node\n                name: Local Node Hook\n                entry: \"node -e \\\"console.log(1)\\\"\"\n                language: node\n              - id: local-go\n                name: Local Go Hook\n                entry: \"go version\"\n                language: golang\n              - id: local-ruby\n                name: Local Ruby Hook\n                entry: \"ruby -e 'puts 1'\"\n                language: ruby\n              - id: local-rust\n                name: Local Rust Hook\n                entry: \"rustc --version\"\n                language: rust\n    \"#});\n\n    let home = context.home_dir();\n\n    // Track the config so GC has something to mark from.\n    let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);\n    write_config_tracking_file(home, &[config_path.path()])?;\n\n    // Seed \"used\" hook env markers so GC can read `.prek-hook.json` and retain the\n    // corresponding tool versions per language.\n    let env_py = home.child(\"hooks/python-keep\");\n    let env_node = home.child(\"hooks/node-keep\");\n    let env_go = home.child(\"hooks/go-keep\");\n    let env_ruby = home.child(\"hooks/ruby-remove\");\n    let env_rust = home.child(\"hooks/rust-remove\");\n    env_py.create_dir_all()?;\n    env_node.create_dir_all()?;\n    env_go.create_dir_all()?;\n    env_ruby.create_dir_all()?;\n    env_rust.create_dir_all()?;\n\n    let py_keep = home.child(\"tools/python/3.12.0\");\n    let py_remove = home.child(\"tools/python/3.11.0\");\n    py_keep.create_dir_all()?;\n    py_remove.create_dir_all()?;\n\n    let node_keep = home.child(\"tools/node/22.0.0\");\n    let node_remove = home.child(\"tools/node/21.0.0\");\n    node_keep.create_dir_all()?;\n    node_remove.create_dir_all()?;\n\n    let go_keep = home.child(\"tools/go/1.24.0\");\n    let go_remove = home.child(\"tools/go/1.23.0\");\n    go_keep.create_dir_all()?;\n    go_remove.create_dir_all()?;\n\n    // Match logic for local hooks: empty deps + language request is `Any` by default.\n    let marker_py = json!({\n        \"language\": \"python\",\n        \"language_version\": \"3.12.0\",\n        \"dependencies\": [],\n        \"env_path\": env_py.path(),\n        \"toolchain\": py_keep.child(\"bin/python\").path(),\n        \"extra\": {},\n    });\n    env_py\n        .child(\".prek-hook.json\")\n        .write_str(&serde_json::to_string_pretty(&marker_py)?)?;\n\n    let marker_node = json!({\n        \"language\": \"node\",\n        \"language_version\": \"22.0.0\",\n        \"dependencies\": [],\n        \"env_path\": env_node.path(),\n        \"toolchain\": node_keep.child(\"bin/node\").path(),\n        \"extra\": {},\n    });\n    env_node\n        .child(\".prek-hook.json\")\n        .write_str(&serde_json::to_string_pretty(&marker_node)?)?;\n\n    let marker_go = json!({\n        \"language\": \"golang\",\n        \"language_version\": \"1.24.0\",\n        \"dependencies\": [],\n        \"env_path\": env_go.path(),\n        \"toolchain\": go_keep.child(\"bin/go\").path(),\n        \"extra\": {},\n    });\n    env_go\n        .child(\".prek-hook.json\")\n        .write_str(&serde_json::to_string_pretty(&marker_go)?)?;\n\n    cmd_snapshot!(context.filters(), context.command().args([\"cache\", \"gc\", \"--dry-run\", \"-v\"]), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Would remove 2 hook envs, 3 tools ([SIZE])\n\n    Would remove 2 hook envs:\n    - ruby-remove\n      path: [HOME]/hooks/ruby-remove\n    - rust-remove\n      path: [HOME]/hooks/rust-remove\n\n    Would remove 3 tools:\n    - go/1.23.0\n      path: [HOME]/tools/go/1.23.0\n    - node/21.0.0\n      path: [HOME]/tools/node/21.0.0\n    - python/3.11.0\n      path: [HOME]/tools/python/3.11.0\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.command().args([\"cache\", \"gc\", \"-v\"]), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 2 hook envs, 3 tools ([SIZE])\n\n    Removed 2 hook envs:\n    - ruby-remove\n      path: [HOME]/hooks/ruby-remove\n    - rust-remove\n      path: [HOME]/hooks/rust-remove\n\n    Removed 3 tools:\n    - go/1.23.0\n      path: [HOME]/tools/go/1.23.0\n    - node/21.0.0\n      path: [HOME]/tools/node/21.0.0\n    - python/3.11.0\n      path: [HOME]/tools/python/3.11.0\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_prunes_tool_versions_without_positive_identification() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-python\n                name: Local Python Hook\n                entry: \"python -c \\\"print(1)\\\"\"\n                language: python\n    \"#});\n\n    let home = context.home_dir();\n\n    // Track the config so GC has something to mark from.\n    let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);\n    write_config_tracking_file(home, &[config_path.path()])?;\n\n    // Seed a matching installed hook env marker, but use a toolchain path that is *not* inside\n    // PREK_HOME/tools. This means we cannot positively identify a used tool version, so all\n    // tool versions under the bucket are unused and should be pruned.\n    let env_py = home.child(\"hooks/python-keep\");\n    env_py.create_dir_all()?;\n    let marker_py = json!({\n        \"language\": \"python\",\n        \"language_version\": \"3.12.0\",\n        \"dependencies\": [],\n        \"env_path\": env_py.path(),\n        \"toolchain\": \"/usr/bin/python3\",\n        \"extra\": {},\n    });\n    env_py\n        .child(\".prek-hook.json\")\n        .write_str(&serde_json::to_string_pretty(&marker_py)?)?;\n\n    // Seed tool versions that should be removed.\n    let py_312 = home.child(\"tools/python/3.12.0\");\n    let py_311 = home.child(\"tools/python/3.11.0\");\n    py_312.create_dir_all()?;\n    py_311.create_dir_all()?;\n\n    // Add a temp dir to ensure it is not removed.\n    home.child(\"repos/.temp\").create_dir_all()?;\n    home.child(\"tools/.temp\").create_dir_all()?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context.command().args([\"cache\", \"gc\", \"--dry-run\", \"-v\"]),\n        @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Would remove 2 tools ([SIZE])\n\n    Would remove 2 tools:\n    - python/3.11.0\n      path: [HOME]/tools/python/3.11.0\n    - python/3.12.0\n      path: [HOME]/tools/python/3.12.0\n\n    ----- stderr -----\n    \"\n    );\n\n    cmd_snapshot!(context.filters(), context.command().args([\"cache\", \"gc\"]), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 2 tools ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    py_312.assert(predicates::path::missing());\n    py_311.assert(predicates::path::missing());\n    home.child(\"tools/python\")\n        .assert(predicates::path::is_dir());\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_keeps_local_hook_env() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-python\n                name: Local Python Hook\n                entry: python -c \"print('hello')\"\n                language: python\n    \"#});\n\n    cwd.child(\"file.txt\").write_str(\"Hello\\n\")?;\n    context.git_add(\".\");\n\n    // Install + run the local hook so it creates a hook env under PREK_HOME/hooks.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Local Python Hook........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    let home = context.home_dir();\n    let hooks_dir = home.child(\"hooks\");\n\n    let mut local_envs = Vec::new();\n    for entry in fs_err::read_dir(hooks_dir.path())? {\n        let entry = entry?;\n        if !entry.file_type()?.is_dir() {\n            continue;\n        }\n\n        let name = entry.file_name().to_string_lossy().to_string();\n        if name.starts_with(\"python-\") {\n            local_envs.push(name);\n        }\n    }\n\n    assert!(\n        !local_envs.is_empty(),\n        \"expected at least one local hook env\"\n    );\n\n    // Add an obviously-unused entry to ensure GC does work.\n    home.child(\"hooks/unused-hook-env\").create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().args([\"cache\", \"gc\"]), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 1 hook env ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    // The local hook env(s) should remain.\n    for env in local_envs {\n        home.child(format!(\"hooks/{env}\"))\n            .assert(predicates::path::is_dir());\n    }\n    // Unused should be swept.\n    home.child(\"hooks/unused-hook-env\")\n        .assert(predicates::path::missing());\n\n    Ok(())\n}\n\nfn write_config_tracking_file(\n    home: &ChildPath,\n    configs: &[&std::path::Path],\n) -> anyhow::Result<()> {\n    let configs: Vec<String> = configs\n        .iter()\n        .map(|p| p.to_string_lossy().to_string())\n        .collect();\n    let content = serde_json::to_string_pretty(&configs)?;\n    home.child(\"config-tracking.json\").write_str(&content)?;\n    Ok(())\n}\n\nfn write_workspace_cache_file(\n    home: &ChildPath,\n    workspace_root: &std::path::Path,\n) -> anyhow::Result<()> {\n    use std::hash::{Hash as _, Hasher as _};\n    use std::time::SystemTime;\n\n    let config_path = workspace_root.join(PRE_COMMIT_CONFIG_YAML);\n    let metadata = fs_err::metadata(&config_path)?;\n    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);\n    let size = metadata.len();\n\n    let mut hasher = std::collections::hash_map::DefaultHasher::new();\n    workspace_root.hash(&mut hasher);\n    let digest = hex::encode(hasher.finish().to_le_bytes());\n\n    let cache_path = home.child(\"cache/prek/workspace\").child(digest);\n    let parent = cache_path.parent().expect(\"cache path has parent\");\n    fs_err::create_dir_all(parent)?;\n\n    let content = json!({\n        \"version\": 1u32,\n        \"workspace_root\": workspace_root,\n        \"created_at\": serde_json::to_value(SystemTime::now())?,\n        \"config_files\": [\n            {\n                \"path\": config_path,\n                \"modified\": serde_json::to_value(modified)?,\n                \"size\": size,\n            }\n        ],\n    });\n\n    cache_path.write_str(&serde_json::to_string_pretty(&content)?)?;\n    Ok(())\n}\n\n#[test]\nfn cache_gc_bootstraps_tracking_from_workspace_cache() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(\"repos: []\\n\");\n    context.git_add(\".\");\n\n    let home = context.home_dir();\n    write_workspace_cache_file(home, context.work_dir().path())?;\n\n    // Seed store entries that should be swept, even if `config-tracking.json` is missing.\n    home.child(\"repos/deadbeef\").create_dir_all()?;\n    home.child(\"hooks/hook-env-dead\").create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"gc\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 1 repo, 1 hook env ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    home.child(\"repos/deadbeef\")\n        .assert(predicates::path::missing());\n    home.child(\"hooks/hook-env-dead\")\n        .assert(predicates::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_drops_missing_tracked_config() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(\"repos: []\\n\");\n    context.git_add(\".\");\n\n    let home = context.home_dir();\n    let config_path = cwd.child(PRE_COMMIT_CONFIG_YAML);\n    write_config_tracking_file(home, &[config_path.path()])?;\n\n    // Simulate config being deleted between runs.\n    fs_err::remove_file(config_path.path())?;\n\n    // Add a few obviously-unused entries to ensure GC sweeps.\n    home.child(\"repos/unused-repo\").create_dir_all()?;\n    home.child(\"hooks/unused-hook-env\").create_dir_all()?;\n    home.child(\"tools/node\").create_dir_all()?;\n    home.child(\"cache/go\").create_dir_all()?;\n    home.child(\"scratch/some-temp\").create_dir_all()?;\n    home.child(\"patches/some-patch\").create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"gc\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    // Tracking file should be updated to drop the missing config.\n    let content = fs_err::read_to_string(home.child(\"config-tracking.json\").path())?;\n    let tracked: Vec<String> = serde_json::from_str(&content)?;\n    assert!(tracked.is_empty());\n\n    // Scratch and patches are always cleared when GC runs.\n    home.child(\"scratch\").assert(predicates::path::missing());\n    home.child(\"patches\").assert(predicates::path::is_dir());\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_keeps_tracked_config_on_parse_error() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    // Intentionally invalid YAML.\n    cwd.child(PRE_COMMIT_CONFIG_YAML).write_str(\"repos: [\\n\")?;\n    context.git_add(\".\");\n\n    let home = context.home_dir();\n    let config_path = cwd.child(PRE_COMMIT_CONFIG_YAML);\n    write_config_tracking_file(home, &[config_path.path()])?;\n\n    // Add a few obviously-unused entries to ensure GC sweeps even when config is unparsable.\n    home.child(\"repos/unused-repo\").create_dir_all()?;\n    home.child(\"hooks/unused-hook-env\").create_dir_all()?;\n    home.child(\"tools/node\").create_dir_all()?;\n    home.child(\"cache/go\").create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"gc\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Removed 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    // Parse errors should not drop the config from tracking.\n    let content = fs_err::read_to_string(home.child(\"config-tracking.json\").path())?;\n    let tracked: Vec<String> = serde_json::from_str(&content)?;\n    assert_eq!(tracked.len(), 1);\n\n    Ok(())\n}\n\n#[test]\nfn cache_gc_dry_run_does_not_remove_entries() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(\"repos: []\\n\");\n    context.git_add(\".\");\n\n    let home = context.home_dir();\n    // Seed tracking with a missing config to force sweeping everything.\n    let missing_config_path = cwd.child(\"missing-config.yaml\");\n    write_config_tracking_file(home, &[missing_config_path.path()])?;\n\n    home.child(\"repos/unused-repo\").create_dir_all()?;\n    home.child(\"hooks/unused-hook-env\").create_dir_all()?;\n    home.child(\"tools/node\").create_dir_all()?;\n    home.child(\"cache/go\").create_dir_all()?;\n    home.child(\"scratch/some-temp\").create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"cache\").arg(\"gc\").arg(\"--dry-run\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Would remove 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE])\n\n    ----- stderr -----\n    \");\n\n    // Nothing should be removed in dry-run mode.\n    home.child(\"repos/unused-repo\")\n        .assert(predicates::path::is_dir());\n    home.child(\"hooks/unused-hook-env\")\n        .assert(predicates::path::is_dir());\n    home.child(\"tools/node\").assert(predicates::path::is_dir());\n    home.child(\"cache/go\").assert(predicates::path::is_dir());\n    home.child(\"scratch/some-temp\")\n        .assert(predicates::path::is_dir());\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/common/mod.rs",
    "content": "#![allow(dead_code, unreachable_pub)]\n\nuse std::ffi::{OsStr, OsString};\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::fixture::{ChildPath, FileWriteStr, PathChild, PathCreateDir};\nuse etcetera::BaseStrategy;\nuse rustc_hash::FxHashSet;\n\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\nuse prek_consts::env_vars::EnvVars;\n\npub fn git_cmd(dir: impl AsRef<Path>) -> Command {\n    let mut cmd = Command::new(\"git\");\n    cmd.current_dir(dir)\n        .args([\"-c\", \"commit.gpgsign=false\"])\n        .args([\"-c\", \"tag.gpgsign=false\"])\n        .args([\"-c\", \"core.autocrlf=false\"])\n        .args([\"-c\", \"user.name=Prek Test\"])\n        .args([\"-c\", \"user.email=test@prek.dev\"]);\n    cmd\n}\n\npub struct TestContext {\n    temp_dir: ChildPath,\n    home_dir: ChildPath,\n\n    /// Standard filters for this test context.\n    filters: Vec<(String, String)>,\n\n    // To keep the directory alive.\n    #[allow(dead_code)]\n    _root: tempfile::TempDir,\n}\n\nimpl TestContext {\n    pub fn new() -> Self {\n        let bucket = Self::test_bucket_dir();\n        fs_err::create_dir_all(&bucket).expect(\"Failed to create test bucket\");\n\n        let root = tempfile::TempDir::new_in(bucket).expect(\"Failed to create test root directory\");\n\n        let temp_dir = ChildPath::new(root.path()).child(\"temp\");\n        fs_err::create_dir_all(&temp_dir).expect(\"Failed to create test working directory\");\n\n        Self::from_root(root, temp_dir)\n    }\n\n    pub fn new_at(path: PathBuf) -> Self {\n        let bucket = Self::test_bucket_dir();\n        fs_err::create_dir_all(&bucket).expect(\"Failed to create test bucket\");\n\n        let root = tempfile::TempDir::new_in(bucket).expect(\"Failed to create test root directory\");\n\n        let temp_dir = ChildPath::new(path);\n        fs_err::create_dir_all(&temp_dir).expect(\"Failed to create test working directory\");\n\n        Self::from_root(root, temp_dir)\n    }\n\n    fn from_root(root: tempfile::TempDir, temp_dir: ChildPath) -> Self {\n        let home_dir = ChildPath::new(root.path()).child(\"home\");\n        fs_err::create_dir_all(&home_dir).expect(\"Failed to create test home directory\");\n\n        let mut filters = Vec::new();\n\n        filters.extend(\n            Self::path_patterns(&temp_dir)\n                .into_iter()\n                .map(|pattern| (pattern, \"[TEMP_DIR]/\".to_string())),\n        );\n        filters.extend(\n            Self::path_patterns(&home_dir)\n                .into_iter()\n                .map(|pattern| (pattern, \"[HOME]/\".to_string())),\n        );\n\n        if let Some(current_exe) = EnvVars::var_os(\"NEXTEST_BIN_EXE_prek\") {\n            filters.extend(\n                Self::path_patterns(current_exe)\n                    .into_iter()\n                    .map(|pattern| (pattern, \"[CURRENT_EXE]\".to_string())),\n            );\n        }\n        let current_exe = assert_cmd::cargo::cargo_bin!(\"prek\");\n        filters.extend(\n            Self::path_patterns(current_exe)\n                .into_iter()\n                .map(|pattern| (pattern, \"[CURRENT_EXE]\".to_string())),\n        );\n\n        Self {\n            temp_dir,\n            home_dir,\n            filters,\n            _root: root,\n        }\n    }\n\n    pub fn test_bucket_dir() -> PathBuf {\n        EnvVars::var(EnvVars::PREK_INTERNAL__TEST_DIR)\n            .map(PathBuf::from)\n            .unwrap_or_else(|_| {\n                etcetera::base_strategy::choose_base_strategy()\n                    .expect(\"Failed to find base strategy\")\n                    .data_dir()\n                    .join(\"prek\")\n                    .join(\"tests\")\n            })\n    }\n\n    /// Generate an escaped regex pattern for the given path.\n    fn path_pattern(path: impl AsRef<Path>) -> String {\n        format!(\n            // Trim the trailing separator for cross-platform directories filters\n            r\"{}\\\\?/?\",\n            regex::escape(&path.as_ref().display().to_string())\n                // Make separators platform-agnostic because on Windows we will display\n                // paths with Unix-style separators sometimes\n                .replace('/', r\"(\\\\|\\/)\")\n                .replace(r\"\\\\\", r\"(\\\\|\\/)\")\n        )\n    }\n\n    /// Generate various escaped regex patterns for the given path.\n    pub fn path_patterns(path: impl AsRef<Path>) -> Vec<String> {\n        let mut patterns = Vec::new();\n\n        // We can only canonicalize paths that exist already\n        if path.as_ref().exists() {\n            patterns.push(Self::path_pattern(\n                path.as_ref()\n                    .canonicalize()\n                    .expect(\"Failed to create canonical path\"),\n            ));\n        }\n\n        // Include a non-canonicalized version\n        patterns.push(Self::path_pattern(path));\n\n        patterns\n    }\n\n    /// Read a file in the temporary directory\n    pub fn read(&self, file: impl AsRef<Path>) -> String {\n        fs_err::read_to_string(self.temp_dir.join(&file))\n            .unwrap_or_else(|_| panic!(\"Missing file: `{}`\", file.as_ref().display()))\n    }\n\n    pub fn command(&self) -> Command {\n        let mut cmd = if EnvVars::is_set(EnvVars::PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT) {\n            // Run the original pre-commit to check compatibility.\n            let mut cmd = Command::new(\"pre-commit\");\n            cmd.current_dir(self.work_dir());\n            cmd.env(EnvVars::PRE_COMMIT_HOME, &**self.home_dir());\n            cmd\n        } else {\n            // The absolute path to a binary target's executable. This is only set when running an integration test or benchmark.\n            // When reusing builds from an archive, this is set to the remapped path within the target directory.\n            let bin = EnvVars::var_os(\"NEXTEST_BIN_EXE_prek\")\n                .map(PathBuf::from)\n                .unwrap_or_else(|| PathBuf::from(assert_cmd::cargo::cargo_bin!(\"prek\")));\n            let mut cmd = Command::new(bin);\n            cmd.current_dir(self.work_dir());\n            cmd.env(EnvVars::PREK_HOME, &**self.home_dir());\n            cmd.env(EnvVars::PREK_INTERNAL__SORT_FILENAMES, \"1\");\n            cmd\n        };\n\n        // Disable git autocrlf to avoid line ending issues in tests.\n        cmd.env(\"GIT_CONFIG_COUNT\", \"1\")\n            .env(\"GIT_CONFIG_KEY_0\", \"core.autocrlf\")\n            .env(\"GIT_CONFIG_VALUE_0\", \"false\");\n\n        cmd\n    }\n\n    pub fn run(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"run\");\n        command\n    }\n\n    pub fn validate_config(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"validate-config\");\n        command\n    }\n\n    pub fn validate_manifest(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"validate-manifest\");\n        command\n    }\n\n    pub fn install(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"install\");\n        command\n    }\n\n    pub fn prepare_hooks(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"prepare-hooks\");\n        command\n    }\n\n    pub fn uninstall(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"uninstall\");\n        command\n    }\n\n    pub fn sample_config(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"sample-config\");\n        command\n    }\n\n    pub fn list(&self) -> Command {\n        let mut command = self.command();\n        command.arg(\"list\");\n        command\n    }\n\n    pub fn auto_update(&self) -> Command {\n        let mut cmd = self.command();\n        cmd.arg(\"auto-update\");\n        cmd\n    }\n\n    pub fn try_repo(&self) -> Command {\n        let mut cmd = self.command();\n        cmd.arg(\"try-repo\");\n        cmd\n    }\n\n    /// Standard snapshot filters _plus_ those for this test context.\n    pub fn filters(&self) -> Vec<(&str, &str)> {\n        // Put test context snapshots before the default filters\n        // This ensures we don't replace other patterns inside paths from the test context first\n        self.filters\n            .iter()\n            .map(|(p, r)| (p.as_str(), r.as_str()))\n            .chain(INSTA_FILTERS.iter().copied())\n            .collect()\n    }\n\n    /// Get the working directory for the test context.\n    pub fn work_dir(&self) -> &ChildPath {\n        &self.temp_dir\n    }\n\n    /// Get the home directory for the test context.\n    pub fn home_dir(&self) -> &ChildPath {\n        &self.home_dir\n    }\n\n    /// Initialize a sample project for prek.\n    pub fn init_project(&self) {\n        git_cmd(&self.temp_dir)\n            .arg(\"-c\")\n            .arg(\"init.defaultBranch=master\")\n            .arg(\"init\")\n            .assert()\n            .success();\n    }\n\n    /// Run `git add`.\n    pub fn git_add(&self, path: impl AsRef<OsStr>) {\n        git_cmd(&self.temp_dir)\n            .arg(\"add\")\n            .arg(path)\n            .assert()\n            .success();\n    }\n\n    /// Run `git commit`.\n    pub fn git_commit(&self, message: &str) {\n        git_cmd(&self.temp_dir)\n            .arg(\"commit\")\n            .arg(\"-m\")\n            .arg(message)\n            .env(EnvVars::PREK_HOME, &**self.home_dir())\n            .assert()\n            .success();\n    }\n\n    /// Run `git tag`.\n    pub fn git_tag(&self, tag: &str) {\n        git_cmd(&self.temp_dir)\n            .arg(\"tag\")\n            .arg(tag)\n            .arg(\"-m\")\n            .arg(format!(\"Tag {tag}\"))\n            .assert()\n            .success();\n    }\n\n    /// Run `git reset`.\n    pub fn git_reset(&self, target: &str) {\n        git_cmd(&self.temp_dir)\n            .arg(\"reset\")\n            .arg(target)\n            .assert()\n            .success();\n    }\n\n    /// Run `git rm`.\n    pub fn git_rm(&self, path: &str) {\n        git_cmd(&self.temp_dir)\n            .arg(\"rm\")\n            .arg(\"--cached\")\n            .arg(path)\n            .assert()\n            .success();\n        let file_path = self.temp_dir.child(path);\n        if file_path.exists() {\n            fs_err::remove_file(file_path).unwrap();\n        }\n    }\n\n    /// Run `git clean`.\n    pub fn git_clean(&self) {\n        git_cmd(&self.temp_dir)\n            .arg(\"clean\")\n            .arg(\"-fdx\")\n            .assert()\n            .success();\n    }\n\n    /// Create a new git branch.\n    pub fn git_branch(&self, branch_name: &str) {\n        git_cmd(&self.temp_dir)\n            .arg(\"branch\")\n            .arg(branch_name)\n            .assert()\n            .success();\n    }\n\n    /// Switch to a git branch.\n    pub fn git_checkout(&self, branch_name: &str) {\n        git_cmd(&self.temp_dir)\n            .arg(\"checkout\")\n            .arg(branch_name)\n            .assert()\n            .success();\n    }\n\n    /// Write a `.pre-commit-config.yaml` file in the temporary directory.\n    pub fn write_pre_commit_config(&self, content: &str) {\n        self.temp_dir\n            .child(PRE_COMMIT_CONFIG_YAML)\n            .write_str(content)\n            .expect(\"Failed to write pre-commit config\");\n    }\n\n    /// Setup a workspace with multiple projects, each with the same config.\n    /// This creates a tree-like directory structure for testing workspace functionality.\n    pub fn setup_workspace(&self, project_paths: &[&str], config: &str) -> anyhow::Result<()> {\n        // Always create root config\n        self.temp_dir\n            .child(PRE_COMMIT_CONFIG_YAML)\n            .write_str(config)?;\n\n        // Create each project directory and config\n        for path in project_paths {\n            let project_dir = self.temp_dir.child(path);\n            project_dir.create_dir_all()?;\n            project_dir\n                .child(PRE_COMMIT_CONFIG_YAML)\n                .write_str(config)?;\n        }\n\n        Ok(())\n    }\n\n    /// Add extra filtering for cache size output\n    #[must_use]\n    pub fn with_filtered_cache_size(mut self) -> Self {\n        // Filter raw byte counts (numbers on their own line)\n        self.filters\n            .push((r\"(?m)^\\d+\\n\".to_string(), \"[SIZE]\\n\".to_string()));\n        // Filter human-readable sizes (e.g., \"384.2 KiB\")\n        self.filters.push((\n            r\"(?m)^\\d+(\\.\\d+)? ([KMGTPE]i)?B\\n\".to_string(),\n            \"[SIZE]\\n\".to_string(),\n        ));\n        self\n    }\n\n    /// Add extra filtering for `cache clean` summary output.\n    #[must_use]\n    pub fn with_filtered_cache_clean_summary(mut self) -> Self {\n        self.filters.push((\n            r\"(?m)^Removed \\d+ files? \\([^)]+\\)\\n\".to_string(),\n            \"Removed [N] file(s) ([SIZE])\\n\".to_string(),\n        ));\n        self\n    }\n}\n\n#[doc(hidden)] // Macro and test context only, don't use directly.\npub const INSTA_FILTERS: &[(&str, &str)] = &[\n    // File sizes\n    (r\"(\\s|\\()(\\d+\\.)?\\d+\\s?([KMGTPE]i)?B\", \"$1[SIZE]\"),\n    // Rewrite Windows output to Unix output\n    (r\"\\\\([\\w\\d]|\\.\\.|\\.)\", \"/$1\"),\n    // The exact message is host language dependent\n    (\n        r\"Caused by: .* \\(os error 2\\)\",\n        \"Caused by: No such file or directory (os error 2)\",\n    ),\n    // Time seconds\n    (r\"\\b(\\d+\\.)?\\d+(ms|s)\\b\", \"[TIME]\"),\n    // Strip non-deterministic lock contention warnings from parallel test execution\n    (r\"(?m)^warning: Waiting to acquire lock.*\\n\", \"\"),\n];\n\n#[allow(unused_macros)]\nmacro_rules! cmd_snapshot {\n    ($spawnable:expr, @$snapshot:literal) => {{\n        cmd_snapshot!($crate::common::INSTA_FILTERS.iter().copied().collect::<Vec<_>>(), $spawnable, @$snapshot)\n    }};\n    ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{\n        let mut settings = insta::Settings::clone_current();\n        for (matcher, replacement) in $filters {\n            settings.add_filter(matcher, replacement);\n        }\n        let _guard = settings.bind_to_scope();\n        insta_cmd::assert_cmd_snapshot!($spawnable, @$snapshot);\n    }};\n}\n\n#[allow(unused_imports)]\npub(crate) use cmd_snapshot;\n\npub(crate) fn remove_bin_from_path(bin: &str, path: Option<OsString>) -> anyhow::Result<OsString> {\n    let path = path.unwrap_or(EnvVars::var_os(EnvVars::PATH).expect(\"Path must be set\"));\n    let Ok(dirs) = which::which_all(bin) else {\n        return Ok(path);\n    };\n\n    let dirs: FxHashSet<_> = dirs\n        .filter_map(|path| path.parent().map(Path::to_path_buf))\n        .collect();\n\n    let new_path_entries: Vec<_> = std::env::split_paths(&path)\n        .filter(|path| !dirs.contains(path.as_path()))\n        .collect();\n\n    Ok(std::env::join_paths(new_path_entries)?)\n}\n"
  },
  {
    "path": "crates/prek/tests/fixtures/go.yaml",
    "content": "repos:\n  - repo: local\n    hooks:\n      - id: golang\n        name: golang\n        language: golang\n        entry: gofumpt -h\n        additional_dependencies: [\"mvdan.cc/gofumpt@v0.8.0\"]\n        always_run: true\n        verbose: true\n        pass_filenames: false\n      - id: golang\n        name: golang\n        language: golang\n        entry: go version\n        language_version: '1.23.5'\n        always_run: true\n        pass_filenames: false\n"
  },
  {
    "path": "crates/prek/tests/fixtures/issue227.yaml",
    "content": "repos:\n  - repo: https://github.com/tox-dev/tox-ini-fmt\n    rev: 1.5.0\n    hooks:\n      - id: tox-ini-fmt\n"
  },
  {
    "path": "crates/prek/tests/fixtures/issue253/biome.json",
    "content": "{\n    \"root\": false,\n    \"formatter\": {\n        \"indentStyle\": \"space\",\n        \"indentWidth\": 4\n    }\n}\n"
  },
  {
    "path": "crates/prek/tests/fixtures/issue253/input.json",
    "content": "{ \"hello\": { \"name\": \"world\"} }\n"
  },
  {
    "path": "crates/prek/tests/fixtures/issue253/issue253.yaml",
    "content": "repos:\n  - repo: https://github.com/biomejs/pre-commit\n    rev: v0.6.1\n    hooks:\n      - id: biome-check\n        additional_dependencies: [ \"@biomejs/biome@2.0.6\" ]\n"
  },
  {
    "path": "crates/prek/tests/fixtures/issue265.yaml",
    "content": "repos:\n  - repo: https://github.com/Lucas-C/pre-commit-hooks\n    rev: v1.4.2\n    hooks:\n      - id: remove-crlf\n        stages: [ manual ]\n"
  },
  {
    "path": "crates/prek/tests/fixtures/node-dependencies.yaml",
    "content": "repos:\n  - repo: local\n    hooks:\n      - id: node\n        name: node\n        language: node\n        entry: cowsay Hello World!\n        additional_dependencies: [\"cowsay\"]\n        always_run: true\n        verbose: true\n        pass_filenames: false\n"
  },
  {
    "path": "crates/prek/tests/fixtures/node-version.yaml",
    "content": "repos:\n  - repo: local\n    hooks:\n      - id: node\n        name: node\n        language: node\n        entry: node -p 'process.version'\n        language_version: '19'\n        always_run: true\n        pass_filenames: false\n      - id: node\n        name: node\n        language: node\n        entry: node -p 'process.version'\n        language_version: 'lts/hydrogen'\n        always_run: true\n        pass_filenames: false\n"
  },
  {
    "path": "crates/prek/tests/fixtures/python-version.yaml",
    "content": "repos:\n  - repo: local\n    hooks:\n      - id: python-3.11\n        name: Python 3.11\n        entry: python -c \"import sys; print(sys.version_info[:3])\"\n        language: python\n        language_version: '3.11'\n        always_run: true\n        pass_filenames: false\n"
  },
  {
    "path": "crates/prek/tests/fixtures/repeated-repos.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: trailing-whitespace\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: trailing-whitespace\n"
  },
  {
    "path": "crates/prek/tests/fixtures/uv-pre-commit-config.yaml",
    "content": "fail_fast: true\n\nexclude: |\n  (?x)^(\n    .*/(snapshots)/.*|\n  )$\n\nrepos:\n  - repo: https://github.com/abravalheri/validate-pyproject\n    rev: v0.20.2\n    hooks:\n      - id: validate-pyproject\n\n  - repo: https://github.com/crate-ci/typos\n    rev: v1.26.0\n    hooks:\n      - id: typos\n        priority: 10\n\n  - repo: local\n    hooks:\n      - id: cargo-fmt\n        name: cargo fmt\n        entry: cargo fmt --\n        language: system\n        types: [rust]\n        pass_filenames: false # This makes it a lot faster\n\n  - repo: local\n    hooks:\n      - id: cargo-dev-generate-all\n        name: cargo dev generate-all\n        entry: cargo dev generate-all\n        language: system\n        types: [rust]\n        pass_filenames: false\n        files: ^crates/(uv-cli|uv-settings)/\n\n  - repo: https://github.com/pre-commit/mirrors-prettier\n    rev: v3.1.0\n    hooks:\n      - id: prettier\n        types_or: [yaml, json5]\n\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.6.9\n    hooks:\n      - id: ruff-format\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n"
  },
  {
    "path": "crates/prek/tests/fixtures/uv-pre-commit-hooks.yaml",
    "content": "- id: pip-compile\n  name: pip-compile\n  description: \"Automatically run 'uv pip compile' on your requirements\"\n  entry: uv pip compile\n  language: python\n  files: ^requirements\\.(in|txt)$\n  args: []\n  pass_filenames: false\n  additional_dependencies: []\n  minimum_pre_commit_version: \"2.9.2\"\n- id: uv-lock\n  name: uv-lock\n  description: \"Automatically run 'uv lock' on your project dependencies\"\n  entry: uv lock\n  language: python\n  files: ^(uv\\.lock|pyproject\\.toml|uv\\.toml)$\n  args: []\n  pass_filenames: false\n  additional_dependencies: []\n  minimum_pre_commit_version: \"2.9.2\"\n- id: uv-export\n  name: uv-export\n  description: \"Automatically run 'uv export' on your project dependencies\"\n  entry: uv export\n  language: python\n  files: ^uv\\.lock$\n  args: [\"--frozen\", \"--output-file=requirements.txt\"]\n  pass_filenames: false\n  additional_dependencies: []\n  minimum_pre_commit_version: \"2.9.2\"\n"
  },
  {
    "path": "crates/prek/tests/hook_impl.rs",
    "content": "#[cfg(unix)]\nuse std::os::unix::fs::PermissionsExt;\n#[cfg(unix)]\nuse std::path::Path;\n\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\nuse indoc::indoc;\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\nmod common;\n\n#[test]\nfn hook_impl() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc! { r\"\n        repos:\n        - repo: local\n          hooks:\n           - id: fail\n             name: fail\n             language: fail\n             entry: always fail\n             always_run: true\n    \"});\n\n    context.git_add(\".\");\n\n    let mut commit = git_cmd(context.work_dir());\n    commit\n        .arg(\"commit\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"-m\")\n        .arg(\"Initial commit\");\n\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), commit, @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      always fail\n\n      .pre-commit-config.yaml\n    \");\n}\n\n#[test]\nfn hook_impl_pre_push() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc! { r#\"\n        repos:\n        - repo: local\n          hooks:\n           - id: success\n             name: success\n             language: system\n             entry: echo \"hook ran successfully\"\n             always_run: true\n    \"#});\n    context.git_add(\".\");\n\n    let mut commit = git_cmd(context.work_dir());\n    commit\n        .arg(\"commit\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"-m\")\n        .arg(\"Initial commit\");\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--hook-type\").arg(\"pre-push\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-push`\n\n    ----- stderr -----\n    \"#);\n\n    let mut filters = context.filters();\n    filters.push((r\"\\b[0-9a-f]{7}\\b\", \"[SHA1]\"));\n    cmd_snapshot!(filters, commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master (root-commit) [SHA1]] Initial commit\n     1 file changed, 8 insertions(+)\n     create mode 100644 .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n\n    // Set up a bare remote repository\n    let remote_repo_path = context.home_dir().join(\"remote.git\");\n    std::fs::create_dir_all(&remote_repo_path)?;\n\n    let mut init_remote = git_cmd(&remote_repo_path);\n    init_remote\n        .arg(\"-c\")\n        .arg(\"init.defaultBranch=master\")\n        .arg(\"init\")\n        .arg(\"--bare\");\n    cmd_snapshot!(context.filters(), init_remote, @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Initialized empty Git repository in [HOME]/remote.git/\n\n    ----- stderr -----\n    \"#);\n\n    // Add remote to local repo\n    let mut add_remote = git_cmd(context.work_dir());\n    add_remote\n        .arg(\"remote\")\n        .arg(\"add\")\n        .arg(\"origin\")\n        .arg(&remote_repo_path);\n    cmd_snapshot!(context.filters(), add_remote, @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n\n    // First push - should trigger the hook\n    let mut push_cmd = git_cmd(context.work_dir());\n    push_cmd\n        .arg(\"push\")\n        .arg(\"origin\")\n        .arg(\"master\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir());\n\n    cmd_snapshot!(context.filters(), push_cmd, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    success..................................................................Passed\n\n    ----- stderr -----\n    To [HOME]/remote.git\n     * [new branch]      master -> master\n    \");\n\n    // Second push - should not trigger the hook (nothing new to push)\n    let mut push_cmd2 = git_cmd(context.work_dir());\n    push_cmd2\n        .arg(\"push\")\n        .arg(\"origin\")\n        .arg(\"master\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir());\n\n    cmd_snapshot!(context.filters(), push_cmd2, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    Everything up-to-date\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn hook_impl_runs_legacy_hook() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: manual-only\n                name: manual-only\n                language: system\n                entry: echo manual-only\n                stages: [ manual ]\n  \"})?;\n\n    context.work_dir().child(\"file.txt\").write_str(\"x\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.install(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n  \");\n\n    let legacy_hook = context.work_dir().child(\".git/hooks/pre-commit.legacy\");\n    legacy_hook.write_str(indoc::indoc! {r#\"\n        #!/bin/sh\n        python3 -c 'print(\"legacy pre-commit ran\")'\n        exit 1\n    \"#})?;\n    #[cfg(unix)]\n    set_executable(legacy_hook.path())?;\n\n    let mut commit = git_cmd(context.work_dir());\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit\");\n\n    cmd_snapshot!(context.filters(), commit, @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    legacy pre-commit ran\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn hook_impl_pre_push_runs_legacy_and_prek() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc! { r#\"\n    repos:\n    - repo: local\n      hooks:\n          - id: success\n            name: success\n            language: system\n            entry: echo \"hook ran successfully\"\n            always_run: true\n  \"#});\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--hook-type\").arg(\"pre-push\"), @r#\"\n  success: true\n  exit_code: 0\n  ----- stdout -----\n  prek installed at `.git/hooks/pre-push`\n\n  ----- stderr -----\n  \"#);\n\n    let legacy_hook = context.work_dir().child(\".git/hooks/pre-push.legacy\");\n    legacy_hook.write_str(indoc::indoc! {r#\"\n        #!/bin/sh\n        python3 -c 'print(\"legacy pre-push ran\")'\n        exit 1\n    \"#})?;\n    #[cfg(unix)]\n    set_executable(legacy_hook.path())?;\n\n    let remote_repo_path = context.home_dir().join(\"remote.git\");\n    std::fs::create_dir_all(&remote_repo_path)?;\n\n    let mut init_remote = git_cmd(&remote_repo_path);\n    init_remote\n        .arg(\"-c\")\n        .arg(\"init.defaultBranch=master\")\n        .arg(\"init\")\n        .arg(\"--bare\");\n    init_remote.output()?.assert().success();\n\n    let mut add_remote = git_cmd(context.work_dir());\n    add_remote\n        .arg(\"remote\")\n        .arg(\"add\")\n        .arg(\"origin\")\n        .arg(&remote_repo_path);\n    add_remote.output()?.assert().success();\n\n    context.work_dir().child(\"file.txt\").write_str(\"x\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Second commit\");\n\n    let mut push_cmd = git_cmd(context.work_dir());\n    push_cmd\n        .arg(\"push\")\n        .arg(\"origin\")\n        .arg(\"master\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir());\n\n    cmd_snapshot!(context.filters(), push_cmd, @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    legacy pre-push ran\n    success..................................................................Passed\n\n    ----- stderr -----\n    error: failed to push some refs to '[HOME]/remote.git'\n    \");\n\n    Ok(())\n}\n\n#[cfg(unix)]\nfn set_executable(path: &Path) -> anyhow::Result<()> {\n    let mut perms = fs_err::metadata(path)?.permissions();\n    perms.set_mode(0o755);\n    fs_err::set_permissions(path, perms)?;\n    Ok(())\n}\n\n/// Test prek hook runs in the correct worktree.\n#[test]\nfn run_worktree() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc! { r\"\n        repos:\n        - repo: local\n          hooks:\n           - id: fail\n             name: fail\n             language: fail\n             entry: always fail\n             always_run: true\n    \"});\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    // Create a new worktree.\n    git_cmd(context.work_dir())\n        .arg(\"worktree\")\n        .arg(\"add\")\n        .arg(\"worktree\")\n        .arg(\"HEAD\")\n        .output()?\n        .assert()\n        .success();\n\n    // Modify the config in the main worktree\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(\"\")?;\n\n    let mut commit = git_cmd(context.work_dir().child(\"worktree\"));\n    commit\n        .arg(\"commit\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"-m\")\n        .arg(\"Initial commit\")\n        .arg(\"--allow-empty\");\n\n    cmd_snapshot!(context.filters(), commit, @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      always fail\n    \");\n\n    Ok(())\n}\n\n/// Test prek hooks runs with `GIT_DIR` respected.\n#[test]\nfn git_dir_respected() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc! { r#\"\n        repos:\n        - repo: local\n          hooks:\n           - id: print-git-dir\n             name: Print Git Dir\n             language: python\n             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)'\n             pass_filenames: false\n    \"#});\n    context.git_add(\".\");\n    let cwd = context.work_dir();\n\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    let mut commit = git_cmd(context.home_dir());\n    commit\n        .arg(\"--git-dir\")\n        .arg(cwd.join(\".git\"))\n        .arg(\"--work-tree\")\n        .arg(&**cwd)\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit with GIT_DIR set\");\n\n    cmd_snapshot!(context.filters(), commit, @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    Print Git Dir............................................................Failed\n    - hook id: print-git-dir\n    - exit code: 1\n\n      GIT_DIR: [TEMP_DIR]/.git\n      GIT_WORK_TREE: .\n    \");\n}\n\n#[test]\nfn workspace_hook_impl_root() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'import os; print(\"cwd:\", os.getcwd())'\n          verbose: true\n    \"#};\n\n    context.setup_workspace(&[\"project2\", \"project3\"], config)?;\n    context.git_add(\".\");\n\n    // Install from root\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    let mut commit = git_cmd(context.work_dir());\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit from subdirectory\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{7}\", \"abc1234\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master (root-commit) abc1234] Test commit from subdirectory\n     3 files changed, 24 insertions(+)\n     create mode 100644 .pre-commit-config.yaml\n     create mode 100644 project2/.pre-commit-config.yaml\n     create mode 100644 project3/.pre-commit-config.yaml\n\n    ----- stderr -----\n    Running hooks for `project2`:\n    Test Hook................................................................Passed\n    - hook id: test-hook\n    - duration: [TIME]\n\n      cwd: [TEMP_DIR]/project2\n\n    Running hooks for `project3`:\n    Test Hook................................................................Passed\n    - hook id: test-hook\n    - duration: [TIME]\n\n      cwd: [TEMP_DIR]/project3\n\n    Running hooks for `.`:\n    Test Hook................................................................Passed\n    - hook id: test-hook\n    - duration: [TIME]\n\n      cwd: [TEMP_DIR]/\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn workspace_hook_impl_subdirectory() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'import os; print(\"cwd:\", os.getcwd())'\n          verbose: true\n    \"#};\n\n    context.setup_workspace(&[\"project2\", \"project3\"], config)?;\n    context.git_add(\".\");\n\n    // Install from a subdirectory\n    cmd_snapshot!(context.filters(), context.install().current_dir(cwd.join(\"project2\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `../.git/hooks/pre-commit` for workspace `[TEMP_DIR]/project2`\n\n    hint: this hook installed for `[TEMP_DIR]/project2` only; run `prek install` from `[TEMP_DIR]/` to install for the entire repo.\n\n    ----- stderr -----\n    \");\n\n    let mut commit = git_cmd(cwd);\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit from subdirectory\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{7}\", \"abc1234\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master (root-commit) abc1234] Test commit from subdirectory\n     3 files changed, 24 insertions(+)\n     create mode 100644 .pre-commit-config.yaml\n     create mode 100644 project2/.pre-commit-config.yaml\n     create mode 100644 project3/.pre-commit-config.yaml\n\n    ----- stderr -----\n    Running in workspace: `[TEMP_DIR]/project2`\n    Test Hook................................................................Passed\n    - hook id: test-hook\n    - duration: [TIME]\n\n      cwd: [TEMP_DIR]/project2\n    \");\n\n    Ok(())\n}\n\n/// Install from a subdirectory, and run commit in another worktree.\n#[test]\nfn workspace_hook_impl_worktree_subdirectory() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'import os; print(\"cwd:\", os.getcwd())'\n          verbose: true\n    \"#};\n\n    context.setup_workspace(&[\"project2\", \"project3\"], config)?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Install from a subdirectory\n    cmd_snapshot!(context.filters(), context.install().current_dir(cwd.join(\"project2\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `../.git/hooks/pre-commit` for workspace `[TEMP_DIR]/project2`\n\n    hint: this hook installed for `[TEMP_DIR]/project2` only; run `prek install` from `[TEMP_DIR]/` to install for the entire repo.\n\n    ----- stderr -----\n    \");\n\n    // Create a new worktree.\n    git_cmd(cwd)\n        .arg(\"worktree\")\n        .arg(\"add\")\n        .arg(\"worktree\")\n        .arg(\"HEAD\")\n        .output()?\n        .assert()\n        .success();\n\n    // Modify the config in the main worktree\n    context\n        .work_dir()\n        .child(\"project2\")\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(\"\")?;\n\n    let mut commit = git_cmd(cwd.child(\"worktree\"));\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit from subdirectory\")\n        .arg(\"--allow-empty\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{7}\", \"abc1234\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [detached HEAD abc1234] Test commit from subdirectory\n\n    ----- stderr -----\n    Running in workspace: `[TEMP_DIR]/worktree/project2`\n    Test Hook............................................(no files to check)Skipped\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn workspace_hook_impl_no_project_found() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a directory without .pre-commit-config.yaml\n    let empty_dir = context.work_dir().child(\"empty\");\n    empty_dir.create_dir_all()?;\n    empty_dir.child(\"file.txt\").write_str(\"Some content\")?;\n    context.git_add(\".\");\n\n    // Install hook that allows missing config\n    cmd_snapshot!(context.filters(), context.install(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n\n    // Try to run hook-impl from directory without config\n    let mut commit = git_cmd(&empty_dir);\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit\");\n\n    cmd_snapshot!(context.filters(), commit, @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.\n\n    hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.\n    - To temporarily silence this, run `PREK_ALLOW_NO_CONFIG=1 git ...`\n    - To permanently silence this, install hooks with the `--allow-missing-config` flag\n    - To uninstall hooks, run `prek uninstall`\n    \");\n\n    // Commit with `PREK_ALLOW_NO_CONFIG=1`\n    let mut commit = git_cmd(&empty_dir);\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .env(EnvVars::PREK_ALLOW_NO_CONFIG, \"1\")\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{7}\", \"1d5e501\")])\n        .collect::<Vec<_>>();\n\n    // The hook should simply succeed because there is no config\n    cmd_snapshot!(filters.clone(), commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master (root-commit) 1d5e501] Test commit\n     1 file changed, 1 insertion(+)\n     create mode 100644 empty/file.txt\n\n    ----- stderr -----\n    \");\n\n    // Create the root `.pre-commit-config.yaml`\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: fail\n                name: fail\n                entry: fail\n                language: fail\n    \"})?;\n    context.git_add(\".\");\n\n    // Commit with `PREK_ALLOW_NO_CONFIG=1` again, the hooks should run (and fail)\n    let mut commit = git_cmd(&empty_dir);\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .env(EnvVars::PREK_ALLOW_NO_CONFIG, \"1\")\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit\");\n\n    cmd_snapshot!(filters.clone(), commit, @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      fail\n\n      .pre-commit-config.yaml\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn hook_impl_does_not_fail_when_no_hooks_match_stage() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Only a manual-stage hook; a pre-commit hook run should find nothing for the stage.\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: manual-only\n                name: manual-only\n                language: system\n                entry: echo manual-only\n                stages: [ manual ]\n    \"})?;\n\n    context.work_dir().child(\"file.txt\").write_str(\"x\")?;\n    context.git_add(\".\");\n\n    // Install the git hook (which invokes `prek hook-impl`).\n    cmd_snapshot!(context.filters(), context.install(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n\n    // Commit should succeed; the hook should not error just because no hooks match pre-commit.\n    let mut commit = git_cmd(context.work_dir());\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{7}\", \"abc1234\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master (root-commit) abc1234] Test commit\n     2 files changed, 9 insertions(+)\n     create mode 100644 .pre-commit-config.yaml\n     create mode 100644 file.txt\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn workspace_hook_impl_with_selectors() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'import os; print(\"cwd:\", os.getcwd())'\n          verbose: true\n    \"#};\n\n    context.setup_workspace(&[\"project2\", \"project3\"], config)?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n\n    let mut commit = git_cmd(cwd);\n    commit\n        .env(EnvVars::PREK_HOME, &**context.home_dir())\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Test commit from subdirectory\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\"[a-f0-9]{7}\", \"abc1234\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master (root-commit) abc1234] Test commit from subdirectory\n     3 files changed, 24 insertions(+)\n     create mode 100644 .pre-commit-config.yaml\n     create mode 100644 project2/.pre-commit-config.yaml\n     create mode 100644 project3/.pre-commit-config.yaml\n\n    ----- stderr -----\n    Running hooks for `project2`:\n    Test Hook................................................................Passed\n    - hook id: test-hook\n    - duration: [TIME]\n\n      cwd: [TEMP_DIR]/project2\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/identify.rs",
    "content": "#[cfg(unix)]\nuse crate::common::{TestContext, cmd_snapshot};\n#[cfg(unix)]\nuse assert_fs::fixture::{FileWriteStr, PathChild};\n\nmod common;\n\n#[cfg(unix)] // \"executable\" tag is different on Windows\n#[test]\nfn identify_text_with_missing_paths() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context\n        .work_dir()\n        .child(\"hello.py\")\n        .write_str(\"print('hi')\\n\")?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context\n            .command()\n            .arg(\"util\")\n            .arg(\"identify\")\n            .arg(\".\")\n            .arg(\"hello.py\")\n            .arg(\"missing.py\"),\n        @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    .: directory\n    hello.py: file, non-executable, python, text\n\n    ----- stderr -----\n    error: missing.py: No such file or directory (os error 2)\n    \"\n    );\n\n    Ok(())\n}\n\n#[cfg(unix)] // \"executable\" tag is different on Windows\n#[test]\nfn identify_json_with_missing_paths() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context\n        .work_dir()\n        .child(\"hello.py\")\n        .write_str(\"print('hi')\\n\")?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context\n            .command()\n            .arg(\"util\")\n            .arg(\"identify\")\n            .arg(\"--output-format\")\n            .arg(\"json\")\n            .arg(\".\")\n            .arg(\"hello.py\")\n            .arg(\"missing.py\"),\n        @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    [\n      {\n        \"path\": \".\",\n        \"tags\": [\n          \"directory\"\n        ]\n      },\n      {\n        \"path\": \"hello.py\",\n        \"tags\": [\n          \"file\",\n          \"non-executable\",\n          \"python\",\n          \"text\"\n        ]\n      }\n    ]\n\n    ----- stderr -----\n    error: missing.py: No such file or directory (os error 2)\n    \"#);\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/install.rs",
    "content": "use crate::common::{TestContext, cmd_snapshot, git_cmd};\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::assert::PathAssert;\nuse assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\nuse indoc::indoc;\nuse insta::assert_snapshot;\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\nuse prek_consts::env_vars::EnvVars;\n\nmod common;\n\n#[test]\nfn install() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Install `prek` hook.\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    // Install `pre-commit` and `post-commit` hook.\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .write_str(\"#!/bin/sh\\necho 'pre-commit'\\n\")?;\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--hook-type\").arg(\"pre-commit\").arg(\"--hook-type\").arg(\"post-commit\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Hook already exists at `.git/hooks/pre-commit`, moved it to `.git/hooks/pre-commit.legacy`\n    Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--overwrite` to remove legacy hooks.\n    prek installed at `.git/hooks/pre-commit`\n    prek installed at `.git/hooks/post-commit`\n\n    ----- stderr -----\n    \");\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    assert_snapshot!(context.read(\".git/hooks/pre-commit.legacy\"), @r##\"\n    #!/bin/sh\n    echo 'pre-commit'\n    \"##);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/post-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=post-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    // Overwrite existing hooks.\n    cmd_snapshot!(context.filters(), context.install().arg(\"-t\").arg(\"pre-commit\").arg(\"--hook-type\").arg(\"post-commit\").arg(\"--overwrite\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Overwriting existing hook at `.git/hooks/pre-commit`\n    prek installed at `.git/hooks/pre-commit`\n    Overwriting existing hook at `.git/hooks/post-commit`\n    prek installed at `.git/hooks/post-commit`\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/post-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=post-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn install_with_quiet_flag() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"-q\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" -q hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n}\n\n#[test]\nfn install_with_silent_flag() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"-qq\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" -qq hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n}\n\n#[test]\nfn install_with_verbose_flag() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"-v\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" -v hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n}\n\n#[test]\nfn install_with_no_progress_flag() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--no-progress\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" --no-progress hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n}\n\n#[test]\nfn install_with_git_dir() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--git-dir\").arg(\"custom-git-dir\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `custom-git-dir/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .assert(predicates::path::missing());\n    context\n        .work_dir()\n        .child(\"custom-git-dir/hooks/pre-commit\")\n        .assert(predicates::path::exists());\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\"custom-git-dir/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n}\n\n#[test]\nfn install_with_git_dir_allows_hooks_path_set() {\n    let context = TestContext::new();\n    context.init_project();\n\n    git_cmd(context.work_dir())\n        .args([\"config\", \"core.hooksPath\", \"custom-hooks\"])\n        .assert()\n        .success();\n\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Cowardly refusing to install hooks with `core.hooksPath` set.\n    hint: Run these commands to remove core.hooksPath:\n    hint:   git config --unset-all --local core.hooksPath\n    hint:   git config --unset-all --global core.hooksPath\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--git-dir\").arg(\".git\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n}\n\n/// Hook permissions should be standard when `core.sharedRepository` is not set.\n#[test]\n#[cfg(unix)]\nfn install_uses_standard_permissions_by_default() {\n    use std::os::unix::fs::PermissionsExt;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context.install().assert().success();\n\n    // Verify hook permissions are 0o755 (standard)\n    let hook_path = context.work_dir().join(\".git/hooks/pre-commit\");\n    let metadata = std::fs::metadata(&hook_path).unwrap();\n    let mode = metadata.permissions().mode() & 0o777;\n    assert_eq!(\n        mode, 0o755,\n        \"Hook should have standard permissions (0o755), got {mode:o}\"\n    );\n}\n\n/// Hook permissions should be group-writable when `core.sharedRepository` is set.\n#[test]\n#[cfg(unix)]\nfn install_uses_group_permissions_for_shared_repository() {\n    use std::os::unix::fs::PermissionsExt;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    // Set core.sharedRepository = group\n    git_cmd(context.work_dir())\n        .args([\"config\", \"core.sharedRepository\", \"group\"])\n        .assert()\n        .success();\n\n    context.install().assert().success();\n\n    // Verify hook permissions are 0o775 (group-writable)\n    let hook_path = context.work_dir().join(\".git/hooks/pre-commit\");\n    let metadata = std::fs::metadata(&hook_path).unwrap();\n    let mode = metadata.permissions().mode() & 0o777;\n    assert_eq!(\n        mode, 0o775,\n        \"Hook should have group-writable permissions (0o775), got {mode:o}\"\n    );\n}\n\n/// Hook permissions should respect explicit octal `core.sharedRepository` values.\n#[test]\n#[cfg(unix)]\nfn install_uses_explicit_shared_repository_mode() {\n    use std::os::unix::fs::PermissionsExt;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    git_cmd(context.work_dir())\n        .args([\"config\", \"core.sharedRepository\", \"0640\"])\n        .assert()\n        .success();\n\n    context.install().assert().success();\n\n    let hook_path = context.work_dir().join(\".git/hooks/pre-commit\");\n    let metadata = std::fs::metadata(&hook_path).unwrap();\n    let mode = metadata.permissions().mode() & 0o777;\n    assert_eq!(\n        mode, 0o750,\n        \"Hook should respect explicit shared mode (0o750), got {mode:o}\"\n    );\n}\n\n/// Run `prek install --prepare-hooks` to install the git hook and prepare prek hook environments.\n#[test]\nfn install_with_hooks() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n    \"});\n\n    context\n        .home_dir()\n        .child(\"repos\")\n        .assert(predicates::path::missing());\n    context\n        .home_dir()\n        .child(\"hooks\")\n        .assert(predicates::path::missing());\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--prepare-hooks\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    // Check that repos and hooks are created.\n    assert_eq!(context.home_dir().child(\"repos\").read_dir()?.count(), 1);\n    assert_eq!(context.home_dir().child(\"hooks\").read_dir()?.count(), 1);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn install_with_legacy_install_hooks_flag_alias() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: test-hook\n                name: Test Hook\n                language: python\n                entry: python -c 'print(\"test\")'\n    \"#});\n\n    context\n        .home_dir()\n        .child(\"hooks\")\n        .assert(predicates::path::missing());\n\n    cmd_snapshot!(context.filters(), context.install().arg(\"--install-hooks\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    assert_eq!(context.home_dir().child(\"hooks\").read_dir()?.count(), 1);\n\n    Ok(())\n}\n\n#[test]\nfn install_with_existing_legacy_hook() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Install our hook script first.\n    context.install().assert().success();\n\n    // Simulate an existing migrated legacy hook.\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit.legacy\")\n        .write_str(\"#!/bin/sh\\necho 'legacy'\\n\")?;\n\n    // Without --overwrite, we should stay in migration mode.\n    cmd_snapshot!(context.filters(), context.install(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--overwrite` to remove legacy hooks.\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit.legacy\")\n        .assert(predicates::path::exists());\n\n    // With --overwrite, the legacy script should be removed.\n    cmd_snapshot!(context.filters(), context.install().arg(\"--overwrite\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Overwriting existing hook at `.git/hooks/pre-commit`\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit.legacy\")\n        .assert(predicates::path::missing());\n\n    Ok(())\n}\n\n/// Run `prek prepare-hooks` to prepare prek hook environments without installing the git hook.\n#[test]\nfn install_hooks_only() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n    \"});\n\n    context\n        .home_dir()\n        .child(\"repos\")\n        .assert(predicates::path::missing());\n    context\n        .home_dir()\n        .child(\"hooks\")\n        .assert(predicates::path::missing());\n\n    cmd_snapshot!(context.filters(), context.prepare_hooks(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n\n    // Check that repos and hooks are created.\n    assert_eq!(context.home_dir().child(\"repos\").read_dir()?.count(), 1);\n    assert_eq!(context.home_dir().child(\"hooks\").read_dir()?.count(), 1);\n\n    // Ensure the git hook is not installed.\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .assert(predicates::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn install_with_legacy_install_hooks_subcommand_alias() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: test-hook\n                name: Test Hook\n                language: python\n                entry: python -c 'print(\"test\")'\n        \"#});\n\n    context\n        .home_dir()\n        .child(\"hooks\")\n        .assert(predicates::path::missing());\n\n    cmd_snapshot!(\n        context.filters(),\n        context.command().arg(\"install-hooks\"),\n        @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n\n    assert_eq!(context.home_dir().child(\"hooks\").read_dir()?.count(), 1);\n\n    Ok(())\n}\n\n#[test]\nfn uninstall() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    // Hook does not exist.\n    cmd_snapshot!(context.filters(), context.uninstall(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    `.git/hooks/pre-commit` does not exist, skipping.\n    \"#);\n\n    // Uninstall `pre-commit` hook.\n    context.install().assert().success();\n    cmd_snapshot!(context.filters(), context.uninstall(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-commit`\n\n    ----- stderr -----\n    \"#);\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .assert(predicates::path::missing());\n\n    // Hook is not managed by `pre-commit`.\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .write_str(\"#!/bin/sh\\necho 'pre-commit'\\n\")?;\n    cmd_snapshot!(context.filters(), context.uninstall(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    `.git/hooks/pre-commit` is not managed by prek, skipping.\n    \"#);\n\n    // Restore previous hook.\n    context.install().assert().success();\n    cmd_snapshot!(context.filters(), context.uninstall(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-commit`\n    Restored `.git/hooks/pre-commit.legacy` to `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n\n    // Uninstall multiple hooks.\n    context\n        .install()\n        .arg(\"-t\")\n        .arg(\"pre-commit\")\n        .arg(\"-t\")\n        .arg(\"post-commit\")\n        .assert()\n        .success();\n    cmd_snapshot!(context.filters(), context.uninstall().arg(\"-t\").arg(\"pre-commit\").arg(\"-t\").arg(\"post-commit\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-commit`\n    Restored `.git/hooks/pre-commit.legacy` to `.git/hooks/pre-commit`\n    Uninstalled `post-commit`\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// `prek uninstall --all` should remove all prek-managed hooks.\n#[test]\nfn uninstall_all_managed_hooks() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Install both pre-commit and pre-push hooks.\n    context\n        .install()\n        .arg(\"-t\")\n        .arg(\"pre-commit\")\n        .arg(\"-t\")\n        .arg(\"pre-push\")\n        .assert()\n        .success();\n    assert!(context.work_dir().join(\".git/hooks/pre-commit\").exists());\n    assert!(context.work_dir().join(\".git/hooks/pre-push\").exists());\n\n    let custom_hook = \"#!/bin/sh\\necho 'custom pre-commit'\\n\";\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .write_str(custom_hook)?;\n\n    // Uninstall with `--all` should only remove managed hooks.\n    cmd_snapshot!(context.filters(), context.uninstall().arg(\"--all\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-push`\n\n    ----- stderr -----\n    \");\n\n    assert_eq!(context.read(\".git/hooks/pre-commit\"), custom_hook);\n    assert!(!context.work_dir().join(\".git/hooks/pre-push\").exists());\n\n    Ok(())\n}\n\n#[test]\nfn uninstall_remove_legacy_hook() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create the `pre-commit` hook.\n    context.install().assert().success();\n    // Create the `pre-commit.legacy` file.\n    fs_err::copy(\n        context.work_dir().join(\".git/hooks/pre-commit\"),\n        context.work_dir().join(\".git/hooks/pre-commit.legacy\"),\n    )?;\n\n    // Uninstall should remove the `pre-commit.legacy` file too.\n    cmd_snapshot!(context.filters(), context.uninstall(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-commit`\n\n    ----- stderr -----\n    Found legacy hook at `.git/hooks/pre-commit.legacy`, removing it.\n    \");\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .assert(predicates::path::missing());\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit.legacy\")\n        .assert(predicates::path::missing());\n\n    // Create a legacy script that is not ours and ensure it is not removed.\n    context.install().assert().success();\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit.legacy\")\n        .write_str(\"#!/bin/sh\\necho 'legacy'\\n\")?;\n\n    cmd_snapshot!(context.filters(), context.uninstall(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-commit`\n    Restored `.git/hooks/pre-commit.legacy` to `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit\")\n        .assert(predicates::path::exists());\n    context\n        .work_dir()\n        .child(\".git/hooks/pre-commit.legacy\")\n        .assert(predicates::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn init_template_dir() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"init-templatedir\").arg(\".git\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'`\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- \"$@\"\n            \"#);\n        }\n    );\n\n    // Run from a subdirectory.\n    let child = context.work_dir().child(\"subdir\");\n    child.create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"init-templatedir\").arg(\"temp-dir\").current_dir(child), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `temp-dir/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir 'temp-dir'`\n    \"#);\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\"subdir/temp-dir/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- \"$@\"\n            \"#);\n        }\n    );\n\n    // `--config` points to non-existing file.\n    cmd_snapshot!(context.filters(), context.command().arg(\"init-templatedir\").arg(\"-c\").arg(\"non-exist-config\").arg(\"subdir2\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `subdir2/hooks/pre-commit` with specified config `non-exist-config`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir 'subdir2'`\n    \");\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\"subdir2/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit --config=\"non-exist-config\" --skip-on-missing-config -- \"$@\"\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n/// Tests `prek util init-template-dir` works.\n#[test]\nfn util_init_template_dir() {\n    let context = TestContext::new();\n    context.init_project();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"util\").arg(\"init-templatedir\").arg(\".git\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'`\n    \"#);\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- \"$@\"\n            \"#);\n        }\n    );\n}\n\n/// Tests `prek init-template-dir` in a non-git repository.\n#[test]\nfn init_template_dir_non_git_repo() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"init-template-dir\").arg(\".git\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'`\n    \"#);\n\n    context.write_pre_commit_config(indoc::indoc! {\"\n        default_install_hook_types:\n          - pre-commit\n          - commit-msg\n          - pre-push\n        repos:\n    \"});\n    cmd_snapshot!(context.filters(), context.command().arg(\"init-template-dir\").arg(\"-c\").arg(context.work_dir().join(PRE_COMMIT_CONFIG_YAML)).arg(\".git\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Overwriting existing hook at `.git/hooks/pre-commit`\n    prek installed at `.git/hooks/pre-commit` with specified config `[TEMP_DIR]/.pre-commit-config.yaml`\n    prek installed at `.git/hooks/commit-msg` with specified config `[TEMP_DIR]/.pre-commit-config.yaml`\n    prek installed at `.git/hooks/pre-push` with specified config `[TEMP_DIR]/.pre-commit-config.yaml`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'`\n    \");\n}\n\n#[test]\nfn workspace_install() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'print(\"test\")'\n    \"#};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    // Install from root directory.\n    cmd_snapshot!(context.filters(), context.install(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    // Install from a subdirectory.\n    cmd_snapshot!(context.filters(), context.install().current_dir(context.work_dir().join(\"project3\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `../.git/hooks/pre-commit` for workspace `[TEMP_DIR]/project3`\n\n    hint: this hook installed for `[TEMP_DIR]/project3` only; run `prek install` from `[TEMP_DIR]/` to install for the entire repo.\n\n    ----- stderr -----\n    \");\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit --cd=\"project3\" -- \"$@\"\n            \"#);\n        }\n    );\n\n    // Install with selectors\n    cmd_snapshot!(context.filters(), context.install().arg(\"project3/\").arg(\"--skip\").arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \");\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 project3/ --skip=project2/ --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    // Invalid selectors\n    cmd_snapshot!(context.filters(), context.install().arg(\":\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Invalid selector: `:`\n      caused by: hook ID part is empty\n    \");\n\n    // SKIP env var is ignored\n    cmd_snapshot!(context.filters(), context.install().arg(\"project3/\").env(EnvVars::SKIP, \"project5/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: Skip selectors from environment variables `SKIP` are ignored during installing hooks.\n    \");\n\n    insta::with_settings!(\n        { filters => context.filters() },\n        {\n            assert_snapshot!(context.read(\".git/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 project3/ --hook-type=pre-commit -- \"$@\"\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n#[test]\nfn workspace_install_hooks() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'print(\"test\")'\n    \"#};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    // Install by selectors\n    cmd_snapshot!(context.filters(), context.prepare_hooks().arg(\"project3\").arg(\"--skip\").arg(\"project3/project5/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \");\n\n    // Install all hooks\n    cmd_snapshot!(context.filters(), context.prepare_hooks(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \");\n\n    // Check that hooks are created.\n    assert_eq!(context.home_dir().child(\"hooks\").read_dir()?.count(), 1);\n\n    Ok(())\n}\n\n/// Only install root config's hook types in a workspace.\n#[test]\nfn workspace_install_only_root_hook_types() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let root_config = indoc! {r#\"\n    default_install_hook_types: [pre-commit, post-commit]\n    repos:\n      - repo: local\n        hooks:\n        - id: root-hook\n          name: Root Hook\n          language: python\n          entry: python -c 'print(\"root\")'\n    \"#};\n\n    let nested_config = indoc! {r#\"\n    default_install_hook_types: [pre-push, post-merge]\n    repos:\n      - repo: local\n        hooks:\n        - id: nested-hook\n          name: Nested Hook\n          language: python\n          entry: python -c 'print(\"nested\")'\n    \"#};\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(root_config)?;\n    context.work_dir().child(\"project2\").create_dir_all()?;\n    context\n        .work_dir()\n        .child(\"project2\")\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(nested_config)?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.install(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n    prek installed at `.git/hooks/post-commit`\n\n    ----- stderr -----\n    \");\n\n    // Should only install pre-commit and post-commit hooks from root config\n    assert!(context.work_dir().join(\".git/hooks/pre-commit\").exists());\n    assert!(context.work_dir().join(\".git/hooks/post-commit\").exists());\n    assert!(!context.work_dir().join(\".git/hooks/pre-push\").exists());\n    assert!(!context.work_dir().join(\".git/hooks/post-merge\").exists());\n\n    Ok(())\n}\n\n#[test]\nfn workspace_uninstall() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c 'print(\"test\")'\n    \"#};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    // Install first\n    context.install().assert().success();\n\n    // Then uninstall\n    cmd_snapshot!(context.filters(), context.uninstall(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Uninstalled `pre-commit`\n\n    ----- stderr -----\n    \");\n\n    // Verify hooks are removed\n    assert!(!context.work_dir().join(\".git/hooks/pre-commit\").exists());\n\n    Ok(())\n}\n\n#[test]\nfn workspace_init_template_dir() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n        - id: test-hook\n          name: Test Hook\n          language: python\n          entry: python -c \"print('test')\"\n    \"#};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    // Create a template directory\n    let template_dir = context.work_dir().child(\"template\");\n    template_dir.create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"init-template-dir\").arg(&*template_dir), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `template/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '[TEMP_DIR]/template'`\n    \");\n\n    // Check that hooks are created in the template directory\n    assert!(template_dir.join(\"hooks/pre-commit\").exists());\n\n    let filters = context.filters();\n    insta::with_settings!(\n        { filters => filters.clone() },\n        {\n            insta::assert_snapshot!(context.read(\"template/hooks/pre-commit\"), @r#\"\n            #!/bin/sh\n            # File generated by prek: https://github.com/j178/prek\n            # ID: 182c10f181da4464a3eec51b83331688\n\n            HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n            PREK=\"[CURRENT_EXE]\"\n\n            # Check if the full path to prek is executable, otherwise fallback to PATH\n            if [ ! -x \"$PREK\" ]; then\n                PREK=\"prek\"\n            fi\n\n            exec \"$PREK\" hook-impl --hook-dir \"$HERE\" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- \"$@\"\n            \"#);\n        }\n    );\n\n    Ok(())\n}\n\n/// Test that a warning is shown when the config file exists but is invalid.\n#[test]\nfn install_invalid_config_warning() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Write an invalid config (missing required `rev` field).\n    context.write_pre_commit_config(indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            hooks:\n              - id: trailing-whitespace\n    \"});\n\n    // Install should succeed but show a warning about the invalid config.\n    cmd_snapshot!(context.filters(), context.install(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    warning: Failed to parse `.pre-commit-config.yaml`: error: line 3 column 5: missing field `rev`\n     --> <input>:3:5\n      |\n    1 | repos:\n    2 |   - repo: https://github.com/pre-commit/pre-commit-hooks\n    3 |     hooks:\n      |     ^ missing field `rev`\n    4 |       - id: trailing-whitespace\n      |\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/bun.rs",
    "content": "use anyhow::Result;\nuse assert_fs::assert::PathAssert;\nuse assert_fs::fixture::PathChild;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n/// Test basic Bun hook execution.\n#[test]\nfn basic_bun() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: bun-check\n                name: bun check\n                language: bun\n                entry: bun -e 'console.log(\"Hello from Bun!\")'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    bun check................................................................Passed\n    - hook id: bun-check\n    - duration: [TIME]\n\n      Hello from Bun!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that `additional_dependencies` are installed correctly.\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: bun-cowsay\n                name: bun cowsay\n                language: bun\n                entry: cowsay Hello World!\n                additional_dependencies: [\"cowsay\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    bun cowsay...............................................................Passed\n    - hook id: bun-cowsay\n    - duration: [TIME]\n\n      ______________\n      < Hello World! >\n       --------------\n              \\   ^__^\n               \\  (oo)/_______\n                  (__)\\       )\\/\\\n                      ||----w |\n                      ||     ||\n\n    ----- stderr -----\n    \");\n\n    // Run again to check `health_check` works correctly (cache reuse).\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    bun cowsay...............................................................Passed\n    - hook id: bun-cowsay\n    - duration: [TIME]\n\n      ______________\n      < Hello World! >\n       --------------\n              \\   ^__^\n               \\  (oo)/_______\n                  (__)\\       )\\/\\\n                      ||----w |\n                      ||     ||\n\n    ----- stderr -----\n    \");\n}\n\n/// Test `language_version` specification and bun installation.\n/// In CI, we ensure bun 1.3 is installed.\n#[test]\nfn language_version() -> Result<()> {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may have other go versions installed locally.\n        return Ok(());\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: bun-version\n                name: bun version check\n                language: bun\n                language_version: \">1.2\"\n                entry: bun -e 'console.log(`Bun ${Bun.version}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n              - id: bun-version\n                name: bun version check\n                language: bun\n                language_version: \"1.3\"\n                entry: bun -e 'console.log(`Bun ${Bun.version}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n              - id: bun-version\n                name: bun version check\n                language: bun\n                language_version: \"1.2\" # will auto download\n                entry: bun -e 'console.log(`Bun ${Bun.version}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n              - id: bun-version\n                name: bun version check\n                language: bun\n                language_version: \"bun@1.2\"\n                entry: bun -e 'console.log(`Bun ${Bun.version}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n                additional_dependencies: [\"cowsay\"] # different dep to force create separate env\n    \"#});\n\n    context.git_add(\".\");\n\n    let bun_dir = context.home_dir().child(\"tools\").child(\"bun\");\n    bun_dir.assert(predicates::path::missing());\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"Bun (\\d+\\.\\d+)\\.\\d+\", \"Bun $1.X\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    bun version check........................................................Passed\n    - hook id: bun-version\n    - duration: [TIME]\n\n      Bun 1.3.X\n    bun version check........................................................Passed\n    - hook id: bun-version\n    - duration: [TIME]\n\n      Bun 1.3.X\n    bun version check........................................................Passed\n    - hook id: bun-version\n    - duration: [TIME]\n\n      Bun 1.2.X\n    bun version check........................................................Passed\n    - hook id: bun-version\n    - duration: [TIME]\n\n      Bun 1.2.X\n\n    ----- stderr -----\n    \");\n\n    // Check that only bun 1.2 is installed.\n    let installed_versions = bun_dir\n        .read_dir()?\n        .flatten()\n        .filter_map(|d| {\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one Bun version to be installed, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.contains(\"1.2\")),\n        \"Expected Bun 1.2 to be installed, but found: {installed_versions:?}\"\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/deno.rs",
    "content": "use assert_fs::assert::PathAssert;\nuse assert_fs::fixture::{FileWriteStr, PathChild};\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot, remove_bin_from_path};\n\n/// Test basic Deno hook execution with an inline script.\n#[test]\nfn basic_deno() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-check\n                name: deno check\n                language: deno\n                entry: deno eval 'console.log(\"Hello from Deno!\")'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno check...............................................................Passed\n    - hook id: deno-check\n    - duration: [TIME]\n\n      Hello from Deno!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test running a TypeScript script file with an explicit `deno run` entry.\n#[test]\nfn script_file() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a TypeScript script\n    context\n        .work_dir()\n        .child(\"check.ts\")\n        .write_str(indoc::indoc! {r#\"\n            console.log(\"Script executed successfully!\");\n        \"#})\n        .expect(\"Failed to write check.ts\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ts-script\n                name: ts script\n                language: deno\n                entry: deno run ./check.ts\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ts script................................................................Passed\n    - hook id: ts-script\n    - duration: [TIME]\n\n      Script executed successfully!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test running Deno built-in subcommands with an explicit `deno` prefix.\n#[test]\nfn builtin_commands() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a TypeScript file for formatting check\n    context\n        .work_dir()\n        .child(\"example.ts\")\n        .write_str(indoc::indoc! {r\"\n        const x = 1;\n        console.log(x);\n    \"})\n        .expect(\"Failed to write example.ts\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-fmt-check\n                name: deno fmt check\n                language: deno\n                entry: deno fmt --check\n                types: [ts]\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno fmt check...........................................................Passed\n    - hook id: deno-fmt-check\n    - duration: [TIME]\n\n      Checked 1 file\n\n    ----- stderr -----\n    \");\n}\n\n/// Test a remote Deno hook whose manifest installs its own executable.\n#[test]\nfn remote_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/deno-hooks\n            rev: v3.1.0\n            hooks:\n              - id: deno-eval\n                always_run: true\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno-eval................................................................Passed\n    - hook id: deno-eval\n    - duration: [TIME]\n\n      This is a remote deno hook\n\n    ----- stderr -----\n    \");\n}\n\n/// Test a remote Deno hook whose configured additional dependency installs the executable it runs.\n#[test]\nfn remote_hook_with_additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: https://github.com/prek-test-repos/deno-hooks\n            rev: v3.1.0\n            hooks:\n              - id: deno-semver\n                additional_dependencies: [\"npm:semver@7:semver-tool\"]\n                always_run: true\n                verbose: true\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno-semver..............................................................Passed\n    - hook id: deno-semver\n    - duration: [TIME]\n\n      1.2.3\n\n    ----- stderr -----\n    \");\n}\n\n/// Test a remote Deno hook whose manifest installs a local file as an executable dependency.\n#[test]\nfn remote_hook_with_local_file_additional_dependency() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/deno-hooks\n            rev: v3.1.0\n            hooks:\n              - id: deno-local-dep\n                always_run: true\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno-local-dep...........................................................Passed\n    - hook id: deno-local-dep\n    - duration: [TIME]\n\n      Hello from remote local additional dependency!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that `additional_dependencies` are installed as CLI executables.\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: semver-version\n                name: semver version\n                language: deno\n                entry: semver-tool 1.2.3\n                additional_dependencies: [\"npm:semver@7:semver-tool\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    let filters = context.filters().into_iter().collect::<Vec<_>>();\n\n    cmd_snapshot!(filters.clone(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    semver version...........................................................Passed\n    - hook id: semver-version\n    - duration: [TIME]\n\n      1.2.3\n\n    ----- stderr -----\n    \");\n\n    // Run again to ensure the existing environment is reused cleanly.\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    semver version...........................................................Passed\n    - hook id: semver-version\n    - duration: [TIME]\n\n      1.2.3\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that a local file can be installed as an executable additional dependency.\n#[test]\nfn additional_dependencies_local_file() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(\"tool.ts\")\n        .write_str(indoc::indoc! {r#\"\n            console.log(\"Hello from local additional dependency!\");\n        \"#})\n        .expect(\"Failed to write tool.ts\");\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-tool\n                name: local tool\n                language: deno\n                entry: echo-tool\n                additional_dependencies: [\"./tool.ts:echo-tool\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local tool...............................................................Passed\n    - hook id: local-tool\n    - duration: [TIME]\n\n      Hello from local additional dependency!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test `language_version` specification and deno installation.\n/// In CI, we ensure deno 2.x is installed via setup-deno action.\n#[test]\nfn language_version() {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may have other deno versions installed locally.\n        return;\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-version\n                name: deno version check (system)\n                language: deno\n                language_version: '2'\n                entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n              - id: deno-version\n                name: deno version check (deno@2)\n                language: deno\n                language_version: deno@2\n                entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n              - id: deno-version\n                name: deno version check (1.46 - will auto download)\n                language: deno\n                language_version: '1.46'\n                entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n              - id: deno-version\n                name: deno version check (deno@1.46)\n                language: deno\n                language_version: deno@1.46\n                entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"});\n\n    context.git_add(\".\");\n\n    let deno_dir = context.home_dir().child(\"tools\").child(\"deno\");\n    deno_dir.assert(predicates::path::missing());\n\n    // Use two filters: first masks minor+patch for Deno 2.x (major-only request),\n    // then masks only patch for specific minor versions like 1.46.x\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([\n            (r\"Deno 2\\.\\d+\\.\\d+\", \"Deno 2.X.X\"),\n            (r\"Deno (\\d+\\.\\d+)\\.\\d+\", \"Deno $1.X\"),\n        ])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno version check (system)..............................................Passed\n    - hook id: deno-version\n    - duration: [TIME]\n\n      Deno 2.X.X\n    deno version check (deno@2)..............................................Passed\n    - hook id: deno-version\n    - duration: [TIME]\n\n      Deno 2.X.X\n    deno version check (1.46 - will auto download)...........................Passed\n    - hook id: deno-version\n    - duration: [TIME]\n\n      Deno 1.46.X\n    deno version check (deno@1.46)...........................................Passed\n    - hook id: deno-version\n    - duration: [TIME]\n\n      Deno 1.46.X\n\n    ----- stderr -----\n    \");\n\n    // Check that only deno 1.46 is installed (2.x uses system).\n    let installed_versions = deno_dir\n        .read_dir()\n        .expect(\"Failed to read deno tools directory\")\n        .flatten()\n        .filter_map(|d| {\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one Deno version to be installed, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.contains(\"1.46\")),\n        \"Expected Deno 1.46 to be installed, but found: {installed_versions:?}\"\n    );\n}\n\n/// Test that deno hooks work without system deno in PATH.\n/// Regression test ensuring run-time resolution still finds the managed toolchain.\n#[test]\nfn without_system_deno() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-check\n                name: deno check\n                language: deno\n                entry: deno eval 'console.log(\"Hello\")'\n                always_run: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    let new_path = remove_bin_from_path(\"deno\", None).expect(\"Failed to remove deno from PATH\");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"PATH\", new_path), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno check...............................................................Passed\n\n    ----- stderr -----\n    \");\n}\n\n/// Test semver range version specification.\n#[test]\nfn version_range() {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI.\n        return;\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-version\n                name: deno version range\n                language: deno\n                language_version: \">=2.0\"\n                entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"Deno \\d+\\.\\d+\\.\\d+\", \"Deno [VERSION]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno version range.......................................................Passed\n    - hook id: deno-version\n    - duration: [TIME]\n\n      Deno [VERSION]\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that hook failure is properly reported.\n#[test]\nfn hook_failure() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a TypeScript file with a lint error\n    context\n        .work_dir()\n        .child(\"bad.ts\")\n        .write_str(indoc::indoc! {r\"\n        // This has a lint error: no-explicit-any\n        let x: any = 1;\n        console.log(x);\n    \"})\n        .expect(\"Failed to write bad.ts\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-lint\n                name: deno lint\n                language: deno\n                entry: deno lint\n                types: [ts]\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    // The lint should fail due to no-explicit-any\n    let output = context.run().output().expect(\"Failed to run hook\");\n    assert!(!output.status.success(), \"Expected lint to fail\");\n}\n\n/// Test script with Deno permissions.\n/// Note: Permissions must come before the script in the entry, so use explicit `deno run`.\n#[test]\nfn script_with_permissions() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a script that reads an environment variable\n    context\n        .work_dir()\n        .child(\"read_env.ts\")\n        .write_str(indoc::indoc! {r#\"\n        console.log(Deno.env.get(\"TEST_VAR\") ?? \"not set\");\n    \"#})\n        .expect(\"Failed to write read_env.ts\");\n\n    // Permissions must be specified before the script path when using deno run\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: deno-env\n                name: deno env\n                language: deno\n                entry: deno run --allow-env ./read_env.ts\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"TEST_VAR\", \"hello\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    deno env.................................................................Passed\n    - hook id: deno-env\n    - duration: [TIME]\n\n      hello\n\n    ----- stderr -----\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/docker.rs",
    "content": "use assert_fs::fixture::{FileWriteStr, PathChild};\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n/// GitHub Action only has docker for linux hosted runners.\n#[test]\nfn docker() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: https://github.com/prek-test-repos/docker-hooks\n            rev: v1.0\n            hooks:\n              - id: hello-world\n                entry: \"sh -c 'echo $MESSAGE! $*' --\"\n                env:\n                    MESSAGE: \"Hello, world\"\n                verbose: true\n                always_run: true\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Hello World..............................................................Passed\n    - hook id: hello-world\n    - duration: [TIME]\n\n      Hello, world! .pre-commit-config.yaml\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn workspace_docker() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/docker-hooks\n            rev: v1.0\n            hooks:\n              - id: hello-world\n                entry: echo\n                verbose: true\n    \"};\n\n    context.setup_workspace(&[\"project1\", \"project2\"], config)?;\n    cwd.child(\"project1\").child(\"project1.txt\").write_str(\"\")?;\n    cwd.child(\"project2\").child(\"project2.txt\").write_str(\"\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project1`:\n    Hello World..............................................................Passed\n    - hook id: hello-world\n    - duration: [TIME]\n\n      project1.txt .pre-commit-config.yaml\n\n    Running hooks for `project2`:\n    Hello World..............................................................Passed\n    - hook id: hello-world\n    - duration: [TIME]\n\n      project2.txt .pre-commit-config.yaml\n\n    Running hooks for `.`:\n    Hello World..............................................................Passed\n    - hook id: hello-world\n    - duration: [TIME]\n\n      project1/.pre-commit-config.yaml .pre-commit-config.yaml project2/project2.txt project1/project1.txt\n      project2/.pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/docker_image.rs",
    "content": "use std::os::unix::fs::PermissionsExt;\n\nuse anyhow::Result;\nuse assert_cmd::Command;\nuse assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::prepend_paths;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n#[test]\nfn docker_image() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    // Test suite from https://github.com/super-linter/super-linter/tree/main/test/linters/gitleaks/bad\n    cwd.child(\"gitleaks_bad_01.txt\")\n        .write_str(indoc::indoc! {r\"\n        aws_access_key_id = AROA47DSWDEZA3RQASWB\n        aws_secret_access_key = wQwdsZDiWg4UA5ngO0OSI2TkM4kkYxF6d2S1aYWM\n    \"})?;\n\n    // Use fully qualified image name for Podman/Docker compatibility\n    Command::new(\"docker\")\n        .args([\"pull\", \"docker.io/zricethezav/gitleaks:v8.21.2\"])\n        .assert()\n        .success();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: gitleaks-docker\n                name: Detect hardcoded secrets\n                language: docker_image\n                entry: docker.io/zricethezav/gitleaks:v8.21.2 git --pre-commit --redact --staged --verbose\n                pass_filenames: false\n    \"});\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"\\d\\d?:\\d\\d(AM|PM)\", \"[TIME]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Detect hardcoded secrets.................................................Failed\n    - hook id: gitleaks-docker\n    - exit code: 1\n\n      Finding:     aws_access_key_id = REDACTED\n      Secret:      REDACTED\n      RuleID:      generic-api-key\n      Entropy:     3.521928\n      File:        gitleaks_bad_01.txt\n      Line:        1\n      Fingerprint: gitleaks_bad_01.txt:generic-api-key:1\n\n      Finding:     aws_secret_access_key = REDACTED\n      Secret:      REDACTED\n      RuleID:      generic-api-key\n      Entropy:     4.703056\n      File:        gitleaks_bad_01.txt\n      Line:        2\n      Fingerprint: gitleaks_bad_01.txt:generic-api-key:2\n\n\n          ○\n          │╲\n          │ ○\n          ○ ░\n          ░    gitleaks\n\n      [TIME] INF 1 commits scanned.\n      [TIME] INF scan completed in [TIME]\n      [TIME] WRN leaks found: 2\n\n    ----- stderr -----\n    \"#);\n    Ok(())\n}\n\n/// Test that `docker_image` does not try to resolve entry in the host system PATH.\n#[test]\nfn docker_image_does_not_resolve_entry() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    let bin_dir = cwd.child(\"bin\");\n    bin_dir.create_dir_all()?;\n\n    let alpine_stub = bin_dir.child(\"alpine\");\n    alpine_stub.write_str(\"#!/bin/sh\\necho host\\n\")?;\n\n    let mut perms = std::fs::metadata(alpine_stub.path())?.permissions();\n    perms.set_mode(0o755);\n    std::fs::set_permissions(alpine_stub.path(), perms)?;\n\n    Command::new(\"docker\")\n        .args([\"pull\", \"docker.io/library/alpine:latest\"])\n        .assert()\n        .success();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: alpine-echo\n                name: Alpine echo\n                language: docker_image\n                entry: alpine /bin/sh -c 'echo ok'\n                pass_filenames: false\n                always_run: true\n                verbose: true\n    \"});\n    context.git_add(\".\");\n\n    let mut cmd = context.run();\n    cmd.env(EnvVars::PATH, prepend_paths(&[bin_dir.path()])?);\n\n    cmd_snapshot!(context.filters(), cmd, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Alpine echo..............................................................Passed\n    - hook id: alpine-echo\n    - duration: [TIME]\n\n      ok\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/fail.rs",
    "content": "use anyhow::Result;\nuse assert_fs::prelude::*;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n/// GitHub Action only has docker for linux hosted runners.\n#[test]\nfn fail() -> Result<()> {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"changelog\").create_dir_all()?;\n    cwd.child(\"changelog/changelog.md\").touch()?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n            - id: changelogs-rst\n              name: changelogs must be rst\n              entry: changelog filenames must end in .rst\n              language: fail\n              files: 'changelog/.*(?<!\\.rst)$'\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    changelogs must be rst...................................................Failed\n    - hook id: changelogs-rst\n    - exit code: 1\n\n      changelog filenames must end in .rst\n\n      changelog/changelog.md\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/golang.rs",
    "content": "use assert_fs::assert::PathAssert;\nuse assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_HOOKS_YAML};\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\n/// Test `language_version` parsing and installation for golang hooks.\n/// We use `setup-go` action to install go 1.24 in CI, so go 1.23 will be auto downloaded.\n#[test]\nfn language_version() -> anyhow::Result<()> {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may have other go versions installed locally.\n        return Ok(());\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: golang\n                name: golang\n                language: golang\n                entry: go version\n                language_version: '1.24'\n                pass_filenames: false\n                always_run: true\n              - id: golang\n                name: golang\n                language: golang\n                entry: go version\n                language_version: go1.24\n                always_run: true\n                pass_filenames: false\n              - id: golang\n                name: golang\n                language: golang\n                entry: go version\n                language_version: '1.23' # will auto download\n                always_run: true\n                pass_filenames: false\n              - id: golang\n                name: golang\n                language: golang\n                entry: go version\n                language_version: go1.23\n                always_run: true\n                pass_filenames: false\n              - id: golang\n                name: golang\n                language: golang\n                entry: go version\n                language_version: go1.23\n                always_run: true\n                pass_filenames: false\n              - id: golang\n                name: golang\n                language: golang\n                entry: go version\n                language_version: '<1.25'\n                always_run: true\n                pass_filenames: false\n    \"});\n    context.git_add(\".\");\n\n    let go_dir = context.home_dir().child(\"tools\").child(\"go\");\n    go_dir.assert(predicates::path::missing());\n\n    let filters = [(\n        r\"go version (go1\\.\\d{1,2})\\.\\d{1,2} ([\\w]+/[\\w]+)\",\n        \"go version $1.X [OS]/[ARCH]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      go version go1.24.X [OS]/[ARCH]\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      go version go1.24.X [OS]/[ARCH]\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      go version go1.23.X [OS]/[ARCH]\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      go version go1.23.X [OS]/[ARCH]\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      go version go1.23.X [OS]/[ARCH]\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      go version go1.24.X [OS]/[ARCH]\n\n    ----- stderr -----\n    \"#);\n\n    // Check that only go 1.23 is installed.\n    let installed_versions = go_dir\n        .read_dir()?\n        .flatten()\n        .filter_map(|d| {\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one Go version to be installed, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.contains(\"1.23\")),\n        \"Expected Go 1.23 to be installed, but found: {installed_versions:?}\"\n    );\n\n    Ok(())\n}\n\n/// Test a remote go hook.\n#[test]\nfn remote_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Run hooks with system found go.\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/golang-hooks\n            rev: v1.0\n            hooks:\n              - id: echo\n                verbose: true\n        \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo.....................................................................Passed\n    - hook id: echo\n    - duration: [TIME]\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n\n    // Test that `additional_dependencies` are installed correctly.\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: golang\n                name: golang\n                language: golang\n                entry: gofumpt -h\n                additional_dependencies: [\"mvdan.cc/gofumpt@v0.8.0\"]\n                always_run: true\n                verbose: true\n                language_version: '1.23.11' # will auto download\n                pass_filenames: false\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    golang...................................................................Passed\n    - hook id: golang\n    - duration: [TIME]\n\n      usage: gofumpt [flags] [path ...]\n      \t-version  show version and exit\n\n      \t-d        display diffs instead of rewriting files\n      \t-e        report all errors (not just the first 10 on different lines)\n      \t-l        list files whose formatting differs from gofumpt's\n      \t-w        write result to (source) file instead of stdout\n      \t-extra    enable extra rules which should be vetted by a human\n\n      \t-lang       str    target Go version in the form \"go1.X\" (default from go.mod)\n      \t-modpath    str    Go module path containing the source file (default from go.mod)\n\n    ----- stderr -----\n    \"#);\n\n    // Run hooks with newly downloaded go.\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/golang-hooks\n            rev: v1.0\n            hooks:\n              - id: echo\n                verbose: true\n                language_version: '1.23.11' # will auto download\n        \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo.....................................................................Passed\n    - hook id: echo\n    - duration: [TIME]\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n}\n\n/// Fix <https://github.com/j178/prek/issues/901>\n#[test]\nfn local_additional_deps() -> anyhow::Result<()> {\n    let go_hook = TestContext::new();\n    go_hook.init_project();\n\n    // Create a local go hook with additional_dependencies.\n    go_hook\n        .work_dir()\n        .child(\"go.mod\")\n        .write_str(indoc::indoc! {r\"\n        module example.com/go-hook\n    \"})?;\n    go_hook\n        .work_dir()\n        .child(\"main.go\")\n        .write_str(indoc::indoc! {r#\"\n        package main\n\n        func main() {\n            println(\"Hello, World!\")\n        }\n    \"#})?;\n    go_hook.work_dir().child(\"cmd\").create_dir_all()?;\n    go_hook\n        .work_dir()\n        .child(\"cmd/main.go\")\n        .write_str(indoc::indoc! {r#\"\n        package main\n\n        func main() {\n            println(\"Hello, Utility!\")\n        }\n    \"#})?;\n    go_hook\n        .work_dir()\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r\"\n        - id: go-hook\n          name: go-hook\n          entry: cmd\n          language: golang\n          additional_dependencies: [ ./cmd ]\n    \"})?;\n    go_hook.git_add(\".\");\n    go_hook.git_commit(\"Initial commit\");\n    git_cmd(go_hook.work_dir())\n        .args([\"tag\", \"v1.0\", \"-m\", \"v1.0\"])\n        .output()?;\n\n    let context = TestContext::new();\n    context.init_project();\n    let work_dir = context.work_dir();\n\n    let hook_url = go_hook.work_dir().to_str().unwrap();\n    work_dir\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {hook_url}\n            rev: v1.0\n            hooks:\n              - id: go-hook\n                verbose: true\n   \", hook_url = hook_url})?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    go-hook..................................................................Passed\n    - hook id: go-hook\n    - duration: [TIME]\n\n      Hello, Utility!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Ensure `go.mod` metadata (go/toolchain directives) is used to constrain\n/// the Go version for remote hooks.\n#[test]\nfn remote_go_mod_metadata_sets_language_version() -> anyhow::Result<()> {\n    // Create a remote repo containing a golang hook.\n    let go_hook = TestContext::new();\n    go_hook.init_project();\n\n    go_hook\n        .work_dir()\n        .child(\"go.mod\")\n        .write_str(indoc::indoc! {r\"\n      module example.com/go-hook\n\n      go 2.100 // unrealistic version to ensure the downloading fails\n      \"})?;\n\n    go_hook\n        .work_dir()\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r\"\n      - id: echo\n        name: echo\n        entry: echo\n        language: golang\n        verbose: true\n      \"})?;\n\n    go_hook.git_add(\".\");\n    go_hook.git_commit(\"Initial commit\");\n    git_cmd(go_hook.work_dir())\n        .args([\"tag\", \"v1.0\", \"-m\", \"v1.0\"])\n        .output()?;\n\n    // Use it as a remote repo in a separate project.\n    let context = TestContext::new();\n    context.init_project();\n\n    let hook_url = go_hook.work_dir().to_str().unwrap();\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n      repos:\n        - repo: {hook_url}\n          rev: v1.0\n          hooks:\n            - id: echo\n              verbose: true\n      \", hook_url = hook_url});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `echo`\n      caused by: Failed to install go\n      caused by: Failed to resolve go version `>= 2.100.0`\n      caused by: Version `>= 2.100.0` not found on remote\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/haskell.rs",
    "content": "use assert_fs::fixture::{FileWriteStr, PathChild};\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n#[test]\nfn local_hook() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: hello\n                name: hello\n                language: haskell\n                entry: hello\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"});\n\n    context\n        .work_dir()\n        .child(\"hello.cabal\")\n        .write_str(indoc::indoc! {r\"\n            cabal-version:       3.0\n            name:                hello\n            version:             0.1.0.0\n            build-type:          Simple\n\n            executable hello\n              main-is:             Main.hs\n              default-language:    GHC2021\n              build-depends:       base >= 4.19 && < 5\n        \"})?;\n\n    context\n        .work_dir()\n        .child(\"Main.hs\")\n        .write_str(indoc::indoc! {r#\"\n            module Main where\n            main :: IO ()\n            main = putStrLn \"Hello Haskell!\"\n        \"#})?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, \"1\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello....................................................................Passed\n    - hook id: hello\n    - duration: [TIME]\n\n      Hello Haskell!\n\n    ----- stderr -----\n    \");\n\n    // Run again to check `health_check` works correctly.\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, \"1\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello....................................................................Passed\n    - hook id: hello\n    - duration: [TIME]\n\n      Hello Haskell!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: hello\n                name: hello\n                language: haskell\n                entry: hello\n                additional_dependencies: [\"hello\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters, context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, \"1\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello....................................................................Passed\n    - hook id: hello\n    - duration: [TIME]\n\n      Hello, World!\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn remote_hook() {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/haskell-hooks\n            rev: v1.0.0\n            hooks:\n              - id: hello\n                always_run: true\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters, context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, \"1\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello....................................................................Passed\n    - hook id: hello\n    - duration: [TIME]\n\n      This is a remote haskell hook\n\n    ----- stderr -----\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/julia.rs",
    "content": "use crate::common::{TestContext, cmd_snapshot};\n\n#[test]\nfn local_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: julia-test\n                name: julia-test\n                language: julia\n                entry: -e 'println(\"Hello from Julia!\")'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    julia-test...............................................................Passed\n    - hook id: julia-test\n    - duration: [TIME]\n\n      Hello from Julia!\n\n    ----- stderr -----\n    \");\n\n    // Run again to check `health_check` works correctly.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    julia-test...............................................................Passed\n    - hook id: julia-test\n    - duration: [TIME]\n\n      Hello from Julia!\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: julia-deps\n                name: julia-deps\n                language: julia\n                entry: -e 'using JSON; println(\"JSON module loaded\")'\n                additional_dependencies: [\"JSON\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    julia-deps...............................................................Passed\n    - hook id: julia-deps\n    - duration: [TIME]\n\n      JSON module loaded\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn project_toml() -> anyhow::Result<()> {\n    use assert_fs::fixture::{FileWriteStr, PathChild};\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(\"Project.toml\")\n        .write_str(indoc::indoc! {r#\"\n            [deps]\n            Example = \"7876af07-990d-54b4-ab0e-23690620f79a\"\n        \"#})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: julia-project\n                name: julia-project\n                language: julia\n                entry: -e 'using Example; println(\"Example module loaded\")'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    julia-project............................................................Passed\n    - hook id: julia-project\n    - duration: [TIME]\n\n      Example module loaded\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn script_file() -> anyhow::Result<()> {\n    use assert_fs::fixture::{FileWriteStr, PathChild};\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(\"my_script.jl\")\n        .write_str(r#\"println(\"Hello from script file!\")\"#)?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: julia-script\n                name: julia-script\n                language: julia\n                entry: my_script.jl\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    julia-script.............................................................Passed\n    - hook id: julia-script\n    - duration: [TIME]\n\n      Hello from script file!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn remote_hook() {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/julia-hooks\n            rev: v1.0.0\n            hooks:\n              - id: hello\n                always_run: true\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    let filters = context.filters();\n\n    cmd_snapshot!(filters, context.run(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello....................................................................Passed\n    - hook id: hello\n    - duration: [TIME]\n\n      This is a remote julia hook\n      Args: hello\n\n    ----- stderr -----\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/lua.rs",
    "content": "use assert_fs::fixture::{FileWriteStr, PathChild};\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n#[test]\nfn health_check() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: lua\n                name: lua\n                language: lua\n                entry: lua -e 'print(\"Hello from Lua!\")'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua......................................................................Passed\n    - hook id: lua\n    - duration: [TIME]\n\n      Hello from Lua!\n\n    ----- stderr -----\n    \");\n\n    // Run again to check `health_check` works correctly.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua......................................................................Passed\n    - hook id: lua\n    - duration: [TIME]\n\n      Hello from Lua!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test specifying `language_version` for Lua hooks which is not supported for now.\n#[test]\nfn language_version() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: lua\n                entry: lua -v\n                language_version: '5.4'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to init hooks\n      caused by: Invalid hook `local`\n      caused by: Hook specified `language_version: 5.4` but the language `lua` does not support toolchain installation for now\n    \");\n}\n\n/// Test that stderr from hooks is captured and shown to the user.\n#[test]\nfn hook_stderr() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: lua\n                entry: lua ./hook.lua\n    \"});\n\n    context\n        .work_dir()\n        .child(\"hook.lua\")\n        .write_str(\"io.stderr:write('How are you\\\\n'); os.exit(1)\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    local....................................................................Failed\n    - hook id: local\n    - exit code: 1\n\n      How are you\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test Lua script execution with file arguments.\n#[test]\nfn script_with_files() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: lua\n                name: lua\n                language: lua\n                entry: lua ./script.lua\n                verbose: true\n    \"});\n\n    context\n        .work_dir()\n        .child(\"script.lua\")\n        .write_str(indoc::indoc! {r#\"\n        for i, arg in ipairs(arg) do\n            print(\"Processing file:\", arg)\n        end\n    \"#})?;\n\n    context\n        .work_dir()\n        .child(\"test1.lua\")\n        .write_str(\"print('test1')\")?;\n\n    context\n        .work_dir()\n        .child(\"test2.lua\")\n        .write_str(\"print('test2')\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua......................................................................Passed\n    - hook id: lua\n    - duration: [TIME]\n\n      Processing file:\tscript.lua\n      Processing file:\t.pre-commit-config.yaml\n      Processing file:\ttest2.lua\n      Processing file:\ttest1.lua\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test Lua environment variables (`LUA_PATH` and `LUA_CPATH`)\n#[test]\nfn lua_environment() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: lua\n                name: lua\n                language: lua\n                entry: lua -e 'print(\"LUA_PATH:\", os.getenv(\"LUA_PATH\")); print(\"LUA_CPATH:\", os.getenv(\"LUA_CPATH\"))'\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"lua-[A-Za-z0-9]+\", \"lua-[HASH]\")])\n        .collect::<Vec<_>>();\n\n    #[cfg(not(target_os = \"windows\"))]\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua......................................................................Passed\n    - hook id: lua\n    - duration: [TIME]\n\n      LUA_PATH:\t[HOME]/hooks/lua-[HASH]/share/lua/5.4/?.lua;[HOME]/hooks/lua-[HASH]/share/lua/5.4/?/init.lua;;\n      LUA_CPATH:\t[HOME]/hooks/lua-[HASH]/lib/lua/5.4/?.so;;\n\n    ----- stderr -----\n    \");\n\n    #[cfg(target_os = \"windows\")]\n    cmd_snapshot!(filters, context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua......................................................................Passed\n    - hook id: lua\n    - duration: [TIME]\n\n      LUA_PATH:\t[HOME]/hooks/lua-[HASH]/share/lua/5.4\\?.lua;[HOME]/hooks/lua-[HASH]/share/lua/5.4\\?/init.lua;;\n      LUA_CPATH:\t[HOME]/hooks/lua-[HASH]/lib/lua/5.4\\?.dll;;\n\n    ----- stderr -----\n    \"#);\n}\n\n/// Test Lua hook with additional dependencies.\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: lua\n                name: lua\n                language: lua\n                entry: lua -e 'require(\"lfs\"); print(\"LuaFileSystem module loaded successfully\")'\n                additional_dependencies: [\"luafilesystem\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua......................................................................Passed\n    - hook id: lua\n    - duration: [TIME]\n\n      LuaFileSystem module loaded successfully\n\n    ----- stderr -----\n    \");\n}\n\n/// Test remote Lua hook from GitHub repository.\n#[test]\nfn remote_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/lua-hooks\n            rev: v1.0.0\n            hooks:\n              - id: lua-hooks\n                always_run: true\n                verbose: true\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    lua-hooks................................................................Passed\n    - hook id: lua-hooks\n    - duration: [TIME]\n\n      this is a lua remote hook\n\n    ----- stderr -----\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/main.rs",
    "content": "#[path = \"../common/mod.rs\"]\nmod common;\n\nmod bun;\nmod deno;\n#[cfg(all(feature = \"docker\", target_os = \"linux\"))]\nmod docker;\n#[cfg(all(feature = \"docker\", target_os = \"linux\"))]\nmod docker_image;\nmod fail;\nmod golang;\nmod haskell;\nmod julia;\nmod lua;\nmod node;\nmod pygrep;\nmod python;\nmod ruby;\nmod rust;\nmod script;\nmod swift;\nmod unimplemented;\nmod unsupported;\n"
  },
  {
    "path": "crates/prek/tests/languages/node.rs",
    "content": "use assert_fs::assert::PathAssert;\nuse assert_fs::fixture::PathChild;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot, remove_bin_from_path};\n\n/// Test `language_version` parsing and auto downloading works correctly.\n/// We use `setup-node` action to install node 20 in CI, so node 19 should be downloaded by prek.\n#[test]\nfn language_version() -> anyhow::Result<()> {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may have other node versions installed locally.\n        return Ok(());\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: node\n                name: node\n                language: node\n                entry: node -p 'process.version'\n                language_version: '20'\n                always_run: true\n              - id: node\n                name: node\n                language: node\n                entry: node -p 'process.version'\n                language_version: node20\n                always_run: true\n              - id: node\n                name: node\n                language: node\n                entry: node -p 'process.version'\n                language_version: '19' # will auto download\n                always_run: true\n              - id: node\n                name: node\n                language: node\n                entry: node -p 'process.version'\n                language_version: node19\n                always_run: true\n              - id: node\n                name: node\n                language: node\n                entry: node -p 'process.version'\n                language_version: '<20'\n                always_run: true\n              - id: node\n                name: node\n                language: node\n                entry: node -p 'process.version'\n                language_version: 'lts/iron' # node 20\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let node_dir = context.home_dir().child(\"tools\").child(\"node\");\n    node_dir.assert(predicates::path::missing());\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"v(\\d+)\\.\\d+.\\d+\", \"v$1.X.X\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      v20.X.X\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      v20.X.X\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      v19.X.X\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      v19.X.X\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      v19.X.X\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      v20.X.X\n\n    ----- stderr -----\n    \"#);\n\n    // Check that only node 19 is installed.\n    let installed_versions = node_dir\n        .read_dir()?\n        .flatten()\n        .filter_map(|d| {\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one node version to be installed, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.starts_with(\"19\")),\n        \"Expected node v19 to be installed, but found: {installed_versions:?}\"\n    );\n\n    Ok(())\n}\n\n/// Test that `additional_dependencies` are installed correctly.\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: node\n                name: node\n                language: node\n                entry: cowsay Hello World!\n                additional_dependencies: [\"cowsay\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      ______________\n      < Hello World! >\n       --------------\n              \\   ^__^\n               \\  (oo)/_______\n                  (__)\\       )\\/\\\n                      ||----w |\n                      ||     ||\n\n    ----- stderr -----\n    \");\n\n    // Run again to check `health_check` works correctly.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    node.....................................................................Passed\n    - hook id: node\n    - duration: [TIME]\n\n      ______________\n      < Hello World! >\n       --------------\n              \\   ^__^\n               \\  (oo)/_______\n                  (__)\\       )\\/\\\n                      ||----w |\n                      ||     ||\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that npm install works without system node in PATH.\n/// Regression test for #1492: `install()` must use the provisioned toolchain.\n#[test]\nfn additional_dependencies_without_system_node() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: node\n                name: node\n                language: node\n                entry: cowsay Hello\n                additional_dependencies: [\"cowsay\"]\n                always_run: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    let new_path = remove_bin_from_path(\"node\", None)?;\n\n    cmd_snapshot!(context.filters(), context.run().env(\"PATH\", new_path), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    node.....................................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that `npm.cmd` can be found on Windows.\n#[test]\nfn npm_version() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: npm-version\n                name: npm-version\n                language: system\n                entry: npm --version\n                always_run: true\n                pass_filenames: false\n                verbose: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"\\d+\\.\\d+\\.\\d+\", \"[NPM_VERSION]\")])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    npm-version..............................................................Passed\n    - hook id: npm-version\n    - duration: [TIME]\n\n      [NPM_VERSION]\n\n    ----- stderr -----\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/pygrep.rs",
    "content": "use anyhow::Result;\n\nuse assert_fs::prelude::*;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n/// Test basic pygrep functionality - case-sensitive matching\n#[test]\nfn basic_case_sensitive() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"TODO: implement this\\nprint('Hello World')\\n# todo: fix later\")?;\n    cwd.child(\"other.py\")\n        .write_str(\"print('No issues here')\\n\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-todo\n                name: check-todo\n                language: pygrep\n                entry: \"TODO\"\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check-todo...............................................................Failed\n    - hook id: check-todo\n    - exit code: 1\n\n      test.py:1:TODO: implement this\n\n    ----- stderr -----\n    \");\n\n    // Run again to ensure `health_check` works correctly.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check-todo...............................................................Failed\n    - hook id: check-todo\n    - exit code: 1\n\n      test.py:1:TODO: implement this\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test case-insensitive matching\n#[test]\nfn case_insensitive() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"TODO: implement this\\nprint('Hello World')\\n# todo: fix later\")?;\n    cwd.child(\"other.py\")\n        .write_str(\"print('No issues here')\\n\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-todo-insensitive\n                name: check-todo-insensitive\n                language: pygrep\n                entry: \"TODO\"\n                args: [\"--ignore-case\"]\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check-todo-insensitive...................................................Failed\n    - hook id: check-todo-insensitive\n    - exit code: 1\n\n      test.py:1:TODO: implement this\n      test.py:3:# todo: fix later\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test multiline mode\n#[test]\nfn multiline_mode() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\").write_str(\n        \"def function():\\n    \\\"\\\"\\\"A function\\n    with multiline docstring\\n    \\\"\\\"\\\"\\n    pass\",\n    )?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-multiline-docstring\n                name: check-multiline-docstring\n                language: pygrep\n                entry: '\"\"\".*\\n.*docstring.*\\n.*\"\"\"'\n                args: [\"--multiline\"]\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check-multiline-docstring................................................Failed\n    - hook id: check-multiline-docstring\n    - exit code: 1\n\n      test.py:2:    \"\"\"A function\n          with multiline docstring\n          \"\"\"\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n/// Test negate mode - passes when pattern is NOT found\n#[test]\nfn negate_mode() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"good.py\").write_str(\"print('Hello World')\\n\")?;\n    cwd.child(\"bad.py\")\n        .write_str(\"TODO: implement this\\nprint('Hello World')\\n\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: no-todo\n                name: no-todo\n                language: pygrep\n                entry: \"TODO\"\n                args: [\"--negate\"]\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    no-todo..................................................................Failed\n    - hook id: no-todo\n    - exit code: 1\n\n      good.py\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test negate mode with multiline - should output filename if pattern not found\n#[test]\nfn negate_multiline_mode() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"no_pattern.py\")\n        .write_str(\"print('Hello World')\\n\")?;\n    cwd.child(\"has_pattern.py\").write_str(\n        \"def function():\\n    \\\"\\\"\\\"A function\\n    with multiline docstring\\n    \\\"\\\"\\\"\\n    pass\",\n    )?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-no-multiline-docstring\n                name: check-no-multiline-docstring\n                language: pygrep\n                entry: '\"\"\".*\\n.*docstring.*\\n.*\"\"\"'\n                args: [\"--multiline\", \"--negate\"]\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check-no-multiline-docstring.............................................Failed\n    - hook id: check-no-multiline-docstring\n    - exit code: 1\n\n      no_pattern.py\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test invalid regex pattern\n#[test]\nfn invalid_regex() {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"print('Hello World')\\n\")\n        .unwrap();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: invalid-regex\n                name: invalid-regex\n                language: pygrep\n                entry: \"[unclosed\"\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to run hook `invalid-regex`\n      caused by: Failed to parse regex: unterminated character set at position 0\n    \");\n}\n\n#[test]\nfn python_regex_quirks() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"def function(arg1, arg2):\\n    pass\\ndef bad_function():\\n    pass\")?;\n\n    // Test lookbehind assertion - function with arguments\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: function-with-args\n                name: function-with-args\n                language: pygrep\n                entry: \"def\\\\s+\\\\w+\\\\([^)]*\\\\w[^)]*\\\\):\"\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    function-with-args.......................................................Failed\n    - hook id: function-with-args\n    - exit code: 1\n\n      test.py:1:def function(arg1, arg2):\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test complex regex with word boundaries and character classes\n#[test]\nfn complex_regex_patterns() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"import sys\\nfrom os import path\\nimport json\\nfrom typing import Dict\")?;\n\n    // Match import statements but not 'from' imports\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: direct-imports\n                name: direct-imports\n                language: pygrep\n                entry: \"^import\\\\s+[a-zA-Z_][a-zA-Z0-9_]*$\"\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    direct-imports...........................................................Failed\n    - hook id: direct-imports\n    - exit code: 1\n\n      test.py:1:import sys\n      test.py:3:import json\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test combination of case insensitive and multiline\n#[test]\nfn case_insensitive_multiline() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"# TODO: fix this\\ndef function():\\n    # todo: implement\\n    pass\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-todos\n                name: check-todos\n                language: pygrep\n                entry: \"todo.*\\n.*implement\"\n                args: [\"--ignore-case\", \"--multiline\"]\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check-todos..............................................................Failed\n    - hook id: check-todos\n    - exit code: 1\n\n      test.py:1:# TODO: fix this\n      def function():\n          # todo: implement\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test successful case where pattern is not found\n#[test]\nfn pattern_not_found() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"print('Hello World')\\n# All good here\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-todo\n                name: check-todo\n                language: pygrep\n                entry: \"TODO\"\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-todo...............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn invalid_args() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"test.py\")\n        .write_str(\"print('Hello World')\\n# All good here\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-todo\n                name: check-todo\n                language: pygrep\n                entry: \"TODO\"\n                args: [\"--hello\"]\n                files: \"\\\\.py$\"\n        \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to run hook `check-todo`\n      caused by: Failed to parse `args`\n      caused by: Unknown argument: --hello\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/python.rs",
    "content": "use assert_fs::assert::PathAssert;\nuse assert_fs::fixture::{FileWriteStr, PathChild};\nuse prek_consts::PRE_COMMIT_HOOKS_YAML;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n/// Test `language_version` parsing and downloading.\n/// We use `setup-python` action to install Python 3.12 in CI, when running tests uv can find them.\n/// Other versions may need to be downloaded while running the tests.\n#[test]\nfn language_version() -> anyhow::Result<()> {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may have other Python versions installed locally.\n        return Ok(());\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: python3\n                name: python3\n                language: python\n                entry: python -c 'print(\"Hello, World!\")'\n                language_version: python3\n                always_run: true\n              - id: python3.12\n                name: python3.12\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:2])'\n                language_version: python3.12\n                always_run: true\n              - id: python3.12\n                name: python3.12\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:2])'\n                language_version: '3.12'\n                always_run: true\n              - id: python3.12\n                name: python3.12\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:2])'\n                language_version: 'python312'\n              - id: python3.12\n                name: python3.12\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:2])'\n                language_version: '312'\n                always_run: true\n              - id: python3.12\n                name: python3.12\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:2])'\n                language_version: python3.12\n                always_run: true\n              - id: python3.12\n                name: python3.12\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:2])'\n                language_version: '3.11.1' # will auto download\n                always_run: true\n    \"#});\n    context.git_add(\".\");\n\n    let python_dir = context.home_dir().child(\"tools\").child(\"python\");\n    python_dir.assert(predicates::path::missing());\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"-v\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    python3..................................................................Passed\n    - hook id: python3\n    - duration: [TIME]\n\n      Hello, World!\n    python3.12...............................................................Passed\n    - hook id: python3.12\n    - duration: [TIME]\n\n      (3, 12)\n    python3.12...............................................................Passed\n    - hook id: python3.12\n    - duration: [TIME]\n\n      (3, 12)\n    python3.12...............................................................Passed\n    - hook id: python3.12\n    - duration: [TIME]\n\n      (3, 12)\n    python3.12...............................................................Passed\n    - hook id: python3.12\n    - duration: [TIME]\n\n      (3, 12)\n    python3.12...............................................................Passed\n    - hook id: python3.12\n    - duration: [TIME]\n\n      (3, 12)\n    python3.12...............................................................Passed\n    - hook id: python3.12\n    - duration: [TIME]\n\n      (3, 11)\n\n    ----- stderr -----\n    \"#);\n\n    // Check that only Python 3.11 is installed.\n    let installed_versions = python_dir\n        .read_dir()?\n        .flatten()\n        .filter_map(|d| {\n            if d.file_type().ok()?.is_symlink() {\n                // Skip symlinks, which may point to other versions.\n                return None;\n            }\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one Python version to be installed, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.contains(\"3.11\")),\n        \"Expected Python 3.11 to be installed, but found: {installed_versions:?}\"\n    );\n\n    Ok(())\n}\n\n#[test]\nfn invalid_version() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: python\n                entry: python -c 'print(\"Hello, world!\")'\n                language_version: 'invalid-version' # invalid version\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to init hooks\n      caused by: Invalid hook `local`\n      caused by: Invalid `language_version` value: `invalid-version`\n    \");\n}\n\n/// Request a version that neither can be found nor downloaded.\n#[test]\nfn can_not_download() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: less-than-3.6\n                name: less-than-3.6\n                language: python\n                entry: python -c 'import sys; print(sys.version_info[:3])'\n                language_version: '<=3.6' # not supported version\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let mut filters = context\n        .filters()\n        .into_iter()\n        .chain([(\n            \"managed installations, search path, or registry\",\n            \"managed installations or search path\",\n        )])\n        .collect::<Vec<_>>();\n    if cfg!(windows) {\n        // Unix uses \"exit status\", Windows uses \"exit code\"\n        filters.push((r\"exit code: \", \"exit status: \"));\n    }\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r#\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `less-than-3.6`\n      caused by: Failed to create Python virtual environment\n      caused by: Command `create venv` exited with an error:\n\n    [status]\n    exit status: 2\n\n    [stderr]\n    error: No interpreter found for Python <=3.6 in managed installations or search path\n    \"#);\n}\n\n/// Test that `additional_dependencies` are installed correctly.\n#[test]\nfn additional_dependencies() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: python\n                language_version: '3.11' # will auto download\n                entry: pyecho Hello, world!\n                additional_dependencies: [\"pyecho-cli\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local....................................................................Passed\n    - hook id: local\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn additional_dependencies_in_remote_repo() -> anyhow::Result<()> {\n    // Create a remote repo with a python hook that has additional dependencies.\n    let repo = TestContext::new();\n    repo.init_project();\n\n    let repo_path = repo.work_dir();\n    repo_path\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r#\"\n        - id: hello\n          name: hello\n          language: python\n          entry: pyecho Greetings from hook\n          additional_dependencies: [\".[cli]\"]\n    \"#})?;\n    repo_path.child(\"module.py\").write_str(indoc::indoc! {r#\"\n        def greet():\n            print(\"Greetings from module\")\n    \"#})?;\n    repo_path.child(\"setup.py\").write_str(indoc::indoc! {r#\"\n        from setuptools import setup, find_packages\n\n        setup(\n            name=\"remote-hooks\",\n            version=\"0.1.0\",\n            py_modules=[\"module\"],\n            extras_require={\n                \"cli\": [\"pyecho-cli\"]\n            }\n        )\n    \"#})?;\n    repo.git_add(\".\");\n    repo.git_commit(\"Add manifest\");\n    repo.git_tag(\"v0.1.0\");\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {}\n            rev: v0.1.0\n            hooks:\n              - id: hello\n                name: hello\n                verbose: true\n    \", repo_path.display()});\n\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello....................................................................Passed\n    - hook id: hello\n    - duration: [TIME]\n\n      Greetings from hook .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Ensure that stderr from hooks is captured and shown to the user.\n#[test]\nfn hook_stderr() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: python\n                entry: python ./hook.py\n    \"});\n\n    context\n        .work_dir()\n        .child(\"hook.py\")\n        .write_str(\"import sys; print('How are you', file=sys.stderr); sys.exit(1)\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    local....................................................................Failed\n    - hook id: local\n    - exit code: 1\n\n      How are you\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that pep723 script for local hook is installed correctly.\n/// Only if no additional dependencies are specified.\n#[test]\nfn pep723_script() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: other-hook\n                name: other-hook\n                language: python\n                entry: python -c 'print(\"hello from other-hook\")'\n                verbose: true\n                pass_filenames: false\n              - id: local\n                name: local\n                language: python\n                entry: ./script.py hello world\n                verbose: true\n                pass_filenames: false\n    \"#});\n    // On Windows, uv venv does not create `python3.exe`, `python3.12.exe` symlink,\n    // be sure to use `python` as the interpreter name.\n    context\n        .work_dir()\n        .child(\"script.py\")\n        .write_str(indoc::indoc! {r#\"\n        #!/usr/bin/env python\n        # /// script\n        # requires-python = \">=3.10\"\n        # dependencies = [ \"pyecho-cli\" ]\n        # ///\n        from pyecho import main\n        main()\n    \"#})?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    other-hook...............................................................Passed\n    - hook id: other-hook\n    - duration: [TIME]\n\n      hello from other-hook\n    local....................................................................Passed\n    - hook id: local\n    - duration: [TIME]\n\n      hello world\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that GIT environment variables do not leak into uv pip install subprocess.\n/// When prek runs in a git worktree, git sets `GIT_DIR` which should not propagate to\n/// pip install where it breaks packages using `setuptools_scm` for file discovery.\n///\n/// Regression test for <https://github.com/j178/prek/issues/1354>\n#[test]\nfn git_env_vars_not_leaked_to_pip_install() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // setup.py that fails if GIT_DIR leaks into pip install\n    context\n        .work_dir()\n        .child(\"setup.py\")\n        .write_str(indoc::indoc! {r#\"\n        import os, sys\n        from setuptools import setup\n        if os.environ.get(\"GIT_DIR\"):\n            sys.exit(\"ERROR: GIT_DIR should not leak into pip install\")\n        setup(name=\"test\", version=\"0.1.0\", extras_require={\"test\": []})\n    \"#})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-no-git-dir\n                name: check-no-git-dir\n                language: python\n                entry: python -c \"print('ok')\"\n                additional_dependencies: [\".[test]\"]\n                always_run: true\n    \"#});\n\n    context.git_add(\".\");\n\n    // Simulate worktree environment by setting GIT_DIR (like git does in worktrees)\n    cmd_snapshot!(context.filters(), context.run()\n        .env(\"GIT_DIR\", context.work_dir().join(\".git\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-no-git-dir.........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that health check passes when Python toolchain path involves symlinks.\n/// The stored toolchain path and the queried path should be canonicalized before comparison.\n///\n/// Regression test for symlink-related \"Python executable mismatch\" errors.\n#[test]\n#[cfg(unix)]\nfn health_check_with_symlinked_toolchain() -> anyhow::Result<()> {\n    use prek_consts::prepend_paths;\n    use std::os::unix::fs::symlink;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    // Find a Python executable, create a symlinked directory to its parent,\n    // and prepend that to PATH so that prek picks up the symlinked path.\n    let python_executable = which::which(\"python3\")?;\n    let symlinked_bin = context.work_dir().child(\"symlinked-bin\");\n    symlink(python_executable.parent().unwrap(), &symlinked_bin)?;\n    let new_path = prepend_paths(&[&*symlinked_bin])?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: python\n                entry: python -c 'print(\"hello\")'\n                always_run: true\n                pass_filenames: false\n    \"#});\n    context.git_add(\".\");\n\n    // First run installs the hook\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PATH, new_path), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local....................................................................Passed\n\n    ----- stderr -----\n    \");\n\n    let hooks_dir = context.home_dir().child(\"hooks\");\n    let hook_envs = hooks_dir\n        .read_dir()?\n        .flatten()\n        .filter(|d| d.file_name().to_string_lossy().starts_with(\"python-\"))\n        .collect::<Vec<_>>();\n    assert_eq!(\n        hook_envs.len(),\n        1,\n        \"Expected one installed hook env, found: {hook_envs:?}\",\n    );\n\n    // Second run triggers health check with a symlinked toolchain path\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local....................................................................Passed\n\n    ----- stderr -----\n    \");\n\n    let hook_envs = hooks_dir\n        .read_dir()?\n        .flatten()\n        .filter(|d| d.file_name().to_string_lossy().starts_with(\"python-\"))\n        .collect::<Vec<_>>();\n    assert_eq!(\n        hook_envs.len(),\n        1,\n        \"Expected one installed hook env, found: {hook_envs:?}\",\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/ruby.rs",
    "content": "use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\n/// Test basic Ruby hook with system Ruby\n#[test]\nfn system_ruby() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Discover the actual system Ruby path\n    let ruby_path = which::which(\"ruby\")\n        .expect(\"Ruby not found in PATH\")\n        .to_string_lossy()\n        .to_string();\n\n    context.write_pre_commit_config(&format!(\n        indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ruby-version\n                name: ruby-version\n                language: ruby\n                entry: ruby --version\n                language_version: system\n                pass_filenames: false\n                always_run: true\n              - id: ruby-version-unspecified\n                name: ruby-version-unspecified\n                language: ruby\n                entry: ruby --version\n                pass_filenames: false\n                always_run: true\n              - id: ruby-version-path\n                name: ruby-version-path\n                language: ruby\n                language_version: {}\n                entry: ruby --version\n                pass_filenames: false\n                always_run: true\n    \"},\n        ruby_path\n    ));\n    context.git_add(\".\");\n\n    let filters = [(\n        r\"ruby (\\d+\\.\\d+)\\.\\d+(?:p\\d+)? \\(\\d{4}-\\d{2}-\\d{2} revision [0-9a-f]{0,10}\\).*?\\[.+\\]\",\n        \"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ruby-version.............................................................Passed\n    - hook id: ruby-version\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-version-unspecified.................................................Passed\n    - hook id: ruby-version-unspecified\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-version-path........................................................Passed\n    - hook id: ruby-version-path\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that `language_version: default` works\n#[test]\nfn language_version_default() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ruby-default\n                name: ruby-default\n                language: ruby\n                entry: ruby --version\n                language_version: default\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = [(\n        r\"ruby (\\d+\\.\\d+)\\.\\d+(?:p\\d+)? \\(\\d{4}-\\d{2}-\\d{2} revision [0-9a-f]{0,10}\\).*?\\[.+\\]\",\n        \"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ruby-default.............................................................Passed\n    - hook id: ruby-default\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n\n    ----- stderr -----\n    \");\n}\n\n/// Test basic Ruby hook with a specified (and available) version of Ruby\n#[test]\nfn specific_ruby_available() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ruby-version-prefixed\n                name: ruby-version-prefixed\n                language: ruby\n                entry: ruby --version\n                language_version: ruby3.4\n                pass_filenames: false\n                always_run: true\n              - id: ruby-version\n                name: ruby-version\n                language: ruby\n                entry: ruby --version\n                language_version: '3.4'\n                pass_filenames: false\n                always_run: true\n              - id: ruby-version-range-min\n                name: ruby-version-range-min\n                language: ruby\n                entry: ruby --version\n                language_version: '>=3.2'\n                pass_filenames: false\n                always_run: true\n              - id: ruby-version-range-max\n                name: ruby-version-range-max\n                language: ruby\n                entry: ruby --version\n                language_version: '<4.0'\n                pass_filenames: false\n                always_run: true\n              - id: ruby-version-constrained-range\n                name: ruby-version-constrained-range\n                language: ruby\n                entry: ruby --version\n                language_version: '>=3.2, <4'\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = [(\n        r\"ruby (\\d+\\.\\d+)\\.\\d+(?:p\\d+)? \\(\\d{4}-\\d{2}-\\d{2} revision [0-9a-f]{0,10}\\).*?\\[.+\\]\",\n        \"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ruby-version-prefixed....................................................Passed\n    - hook id: ruby-version-prefixed\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-version.............................................................Passed\n    - hook id: ruby-version\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-version-range-min...................................................Passed\n    - hook id: ruby-version-range-min\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-version-range-max...................................................Passed\n    - hook id: ruby-version-range-max\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-version-constrained-range...........................................Passed\n    - hook id: ruby-version-constrained-range\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n\n    ----- stderr -----\n    \");\n}\n\n/// Test basic Ruby hook with a specified (and unavailable) version of Ruby\n#[test]\nfn specific_ruby_unavailable() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ruby-version\n                name: ruby-version\n                language: ruby\n                entry: ruby --version\n                language_version: 3.1.3\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = [(\n        r\"ruby (\\d+\\.\\d+)\\.\\d+(?:p\\d+)? \\(\\d{4}-\\d{2}-\\d{2} revision [0-9a-f]{0,10}\\).*?\\[.+\\]\",\n        \"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    #[cfg(target_os = \"windows\")]\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `ruby-version`\n      caused by: Failed to install Ruby\n      caused by: No suitable Ruby found for request: 3.1.3\n    Automatic installation is not supported on this platform.\n    Please install Ruby manually.\n    \");\n\n    #[cfg(not(target_os = \"windows\"))]\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `ruby-version`\n      caused by: Failed to install Ruby\n      caused by: No suitable Ruby found for request: 3.1.3\n    No rv-ruby release found matching: 3.1.3\n    Please install Ruby manually.\n    \");\n}\n\n/// Test Ruby hook with `additional_dependencies` and `require` statement\n#[test]\nfn additional_gem_dependencies() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a Ruby script that uses a gem from additional_dependencies\n    // Use 'rspec' - a gem that's NOT bundled with Ruby\n    context\n        .work_dir()\n        .child(\"test_script.rb\")\n        .write_str(indoc::indoc! {r\"\n            require 'rspec'\n            puts RSpec::Version::STRING\n        \"})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: test-gem-require\n                name: test-gem-require\n                language: ruby\n                entry: ruby test_script.rb\n                language_version: system\n                additional_dependencies: [\"rspec\"]\n                pass_filenames: false\n                always_run: true\n              - id: test-gem-require-versioned\n                name: test-gem-require-versioned\n                language: ruby\n                entry: ruby test_script.rb\n                language_version: system\n                additional_dependencies: [\"rspec:3.12.0\"]\n                pass_filenames: false\n                always_run: true\n              - id: test-gem-require-missing\n                name: test-gem-require-missing\n                language: ruby\n                entry: ruby test_script.rb\n                language_version: system\n                pass_filenames: false\n                always_run: true\n    \"#});\n    context.git_add(\".\");\n\n    let filters = [\n        // Normalize unpinned rspec version (only for test-gem-require, not test-gem-require-versioned)\n        (\n            r\"(- hook id: test-gem-require\\n- duration: .*?\\n\\n)  \\d+\\.\\d+\\.\\d+\",\n            \"$1  X.Y.Z\",\n        ),\n        // Normalize Ruby internal paths\n        (r\"<internal:[^>]+>:\\d+:in\", \"<internal:[RUBY_LIB]>:[X]:in\"),\n    ]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    test-gem-require.........................................................Passed\n    - hook id: test-gem-require\n    - duration: [TIME]\n\n      X.Y.Z\n    test-gem-require-versioned...............................................Passed\n    - hook id: test-gem-require-versioned\n    - duration: [TIME]\n\n      3.12.0\n    test-gem-require-missing.................................................Failed\n    - hook id: test-gem-require-missing\n    - duration: [TIME]\n    - exit code: 1\n\n      <internal:[RUBY_LIB]>:[X]:in 'Kernel#require': cannot load such file -- rspec (LoadError)\n      \tfrom <internal:[RUBY_LIB]>:[X]:in 'Kernel#require'\n      \tfrom test_script.rb:1:in '<main>'\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test Ruby hook with gemspec\n#[test]\nfn gemspec_workflow() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a simple gemspec\n    context\n        .work_dir()\n        .child(\"test_gem.gemspec\")\n        .write_str(indoc::indoc! {r#\"\n            Gem::Specification.new do |spec|\n              spec.name          = \"test_gem\"\n              spec.version       = \"0.1.0\"\n              spec.authors       = [\"Test\"]\n              spec.email         = [\"test@example.com\"]\n              spec.summary       = \"Test gem\"\n              spec.files         = [\"lib/test_gem.rb\"]\n              spec.require_paths = [\"lib\"]\n            end\n        \"#})?;\n\n    // Create lib directory and file\n    context.work_dir().child(\"lib\").create_dir_all()?;\n    context\n        .work_dir()\n        .child(\"lib/test_gem.rb\")\n        .write_str(indoc::indoc! {r#\"\n            module TestGem\n              def self.hello\n                \"Hello from TestGem\"\n              end\n            end\n        \"#})?;\n\n    // Create test script\n    context\n        .work_dir()\n        .child(\"test_script.rb\")\n        .write_str(indoc::indoc! {r\"\n            require 'test_gem'\n            puts TestGem.hello\n        \"})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: test-gemspec\n                name: test-gemspec\n                language: ruby\n                entry: ruby -I lib test_script.rb\n                language_version: system\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    test-gemspec.............................................................Passed\n    - hook id: test-gemspec\n    - duration: [TIME]\n\n      Hello from TestGem\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test environment isolation between Ruby hooks\n#[test]\nfn environment_isolation() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: hook1\n                name: hook1\n                language: ruby\n                entry: ruby -e \"puts 'hook1=' + ENV['GEM_HOME']\"\n                language_version: system\n                pass_filenames: false\n                always_run: true\n                verbose: true\n              - id: hook2\n                name: hook2\n                language: ruby\n                entry: ruby -e \"puts 'hook2=' + ENV['GEM_HOME']\"\n                language_version: system\n                pass_filenames: false\n                always_run: true\n                verbose: true\n              - id: hook3\n                name: hook3\n                language: ruby\n                entry: ruby -e \"puts 'hook3=' + ENV['GEM_HOME']\"\n                language_version: system\n                additional_dependencies: [\"rspec\"]\n                pass_filenames: false\n                always_run: true\n                verbose: true\n              - id: hook4\n                name: hook4\n                language: ruby\n                entry: ruby -e \"puts 'hook4=' + ENV['GEM_HOME']\"\n                language_version: system\n                additional_dependencies: [\"webrick\"]\n                pass_filenames: false\n                always_run: true\n                verbose: true\n              - id: hook5\n                name: hook5\n                language: ruby\n                entry: ruby -e \"puts 'hook5=' + ENV['GEM_HOME']\"\n                language_version: system\n                additional_dependencies: [\"rspec\"]\n                pass_filenames: false\n                always_run: true\n                verbose: true\n    \"#});\n    context.git_add(\".\");\n\n    let output = context.run().output()?;\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let stderr = String::from_utf8_lossy(&output.stderr);\n\n    assert!(\n        output.status.success(),\n        \"Command failed\\nEXIT CODE: {:?}\\nSTDOUT:\\n{}\\nSTDERR:\\n{}\",\n        output.status.code(),\n        stdout,\n        stderr\n    );\n\n    // Extract GEM_HOME paths from each hook's output\n    let extract_gem_home = |hook_id: &str| -> String {\n        let prefix = format!(\"{hook_id}=\");\n        stdout\n            .lines()\n            .find_map(|line| line.trim().strip_prefix(&prefix))\n            .unwrap_or_else(|| panic!(\"Failed to extract GEM_HOME for {hook_id}\"))\n            .to_string()\n    };\n\n    let hook1_gem_home = extract_gem_home(\"hook1\");\n    let hook2_gem_home = extract_gem_home(\"hook2\");\n    let hook3_gem_home = extract_gem_home(\"hook3\");\n    let hook4_gem_home = extract_gem_home(\"hook4\");\n    let hook5_gem_home = extract_gem_home(\"hook5\");\n\n    // Verify isolation: hook1 == hook2 (same dependencies (none))\n    assert_eq!(\n        hook1_gem_home, hook2_gem_home,\n        \"hook1 and hook2 should share the same environment (both have no additional_dependencies)\"\n    );\n\n    // Verify isolation: hook3 == hook5 (same dependencies (rspec))\n    assert_eq!(\n        hook3_gem_home, hook5_gem_home,\n        \"hook3 and hook5 should share the same environment (both have the same additional_dependencies)\"\n    );\n\n    // Verify isolation: hook1 != hook3 (different dependencies)\n    assert_ne!(\n        hook1_gem_home, hook3_gem_home,\n        \"hook1 and hook3 should have different environments (hook3 has rspec)\"\n    );\n\n    // Verify isolation: hook1 != hook4 (different dependencies)\n    assert_ne!(\n        hook1_gem_home, hook4_gem_home,\n        \"hook1 and hook4 should have different environments (hook4 has webrick)\"\n    );\n\n    // Verify isolation: hook3 != hook4 (different dependencies)\n    assert_ne!(\n        hook3_gem_home, hook4_gem_home,\n        \"hook3 and hook4 should have different environments (different gems)\"\n    );\n\n    // Run the command again to check that the environments are reused\n    let output = context.run().output()?;\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let stderr = String::from_utf8_lossy(&output.stderr);\n\n    assert!(\n        output.status.success(),\n        \"Command failed\\nEXIT CODE: {:?}\\nSTDOUT:\\n{}\\nSTDERR:\\n{}\",\n        output.status.code(),\n        stdout,\n        stderr\n    );\n\n    let hook1_gem_home_v2 = extract_gem_home(\"hook1\");\n    let hook2_gem_home_v2 = extract_gem_home(\"hook2\");\n    let hook3_gem_home_v2 = extract_gem_home(\"hook3\");\n    let hook4_gem_home_v2 = extract_gem_home(\"hook4\");\n    let hook5_gem_home_v2 = extract_gem_home(\"hook5\");\n\n    assert_eq!(\n        hook1_gem_home, hook1_gem_home_v2,\n        \"hook1 should reuse the same environment on a second run\"\n    );\n\n    assert_eq!(\n        hook2_gem_home, hook2_gem_home_v2,\n        \"hook2 should reuse the same environment on a second run\"\n    );\n\n    assert_eq!(\n        hook3_gem_home, hook3_gem_home_v2,\n        \"hook3 should reuse the same environment on a second run\"\n    );\n\n    assert_eq!(\n        hook4_gem_home, hook4_gem_home_v2,\n        \"hook4 should reuse the same environment on a second run\"\n    );\n\n    assert_eq!(\n        hook5_gem_home, hook5_gem_home_v2,\n        \"hook5 should reuse the same environment on a second run\"\n    );\n\n    Ok(())\n}\n\n/// Test local Ruby hook repository with gemspec build and install\n#[test]\nfn local_hook_with_gemspec() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a local hook repository with a gemspec\n    let hook_repo = context.work_dir().child(\"my-hook-repo\");\n    hook_repo.create_dir_all()?;\n\n    // Create the gemspec\n    hook_repo\n        .child(\"my_hook.gemspec\")\n        .write_str(indoc::indoc! {r#\"\n            Gem::Specification.new do |spec|\n              spec.name          = \"my_hook\"\n              spec.version       = \"0.1.0\"\n              spec.authors       = [\"Test\"]\n              spec.email         = [\"test@example.com\"]\n              spec.summary       = \"Test hook gem\"\n              spec.files         = [\"bin/my-hook\"]\n              spec.executables   = [\"my-hook\"]\n              spec.bindir        = \"bin\"\n            end\n        \"#})?;\n\n    // Create executable\n    hook_repo.child(\"bin\").create_dir_all()?;\n    hook_repo.child(\"bin/my-hook\").write_str(indoc::indoc! {r#\"\n        #!/usr/bin/env ruby\n        puts \"Hook executed from gem!\"\n    \"#})?;\n\n    // Create .pre-commit-hooks.yaml manifest\n    hook_repo\n        .child(\".pre-commit-hooks.yaml\")\n        .write_str(indoc::indoc! {r\"\n            - id: my-hook\n              name: My Hook\n              entry: my-hook\n              language: ruby\n              pass_filenames: false\n        \"})?;\n\n    // Initialize git repo in the hook directory (separate from main project)\n    let output = git_cmd(&hook_repo).args([\"init\"]).output()?;\n    assert!(output.status.success(), \"git init failed: {output:?}\");\n\n    // Configure git user for this repo\n    git_cmd(&hook_repo)\n        .args([\"config\", \"user.name\", \"Test User\"])\n        .output()?;\n\n    git_cmd(&hook_repo)\n        .args([\"config\", \"user.email\", \"test@example.com\"])\n        .output()?;\n\n    let output = git_cmd(&hook_repo).args([\"add\", \".\"]).output()?;\n    assert!(output.status.success(), \"git add failed: {output:?}\");\n\n    let output = git_cmd(&hook_repo)\n        .args([\"commit\", \"-m\", \"Initial commit\"])\n        .output()?;\n    assert!(output.status.success(), \"git commit failed: {output:?}\");\n\n    // Get the commit SHA\n    let rev_output = git_cmd(&hook_repo).args([\"rev-parse\", \"HEAD\"]).output()?;\n    assert!(rev_output.status.success(), \"git rev-parse failed\");\n    let rev = String::from_utf8_lossy(&rev_output.stdout)\n        .trim()\n        .to_string();\n\n    // Configure prek to use this local repo\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n            repos:\n              - repo: {}\n                rev: {}\n                hooks:\n                  - id: my-hook\n                    name: my-hook\n                    entry: my-hook\n                    language: ruby\n                    pass_filenames: false\n                    always_run: true\n        \",\n        hook_repo.to_path_buf().display(),\n        rev\n    });\n    context.git_add(\".pre-commit-config.yaml\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    my-hook..................................................................Passed\n    - hook id: my-hook\n    - duration: [TIME]\n\n      Hook executed from gem!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test Ruby hook with native gem (C extension)\n#[test]\nfn native_gem_dependency() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a Ruby script that uses msgpack (small native gem that compiles quickly)\n    context\n        .work_dir()\n        .child(\"check_msgpack.rb\")\n        .write_str(indoc::indoc! {r#\"\n            #!/usr/bin/env ruby\n            require 'msgpack'\n\n            # Test that the native extension works\n            data = { \"hello\" => \"world\", \"number\" => 42 }\n            packed = MessagePack.pack(data)\n            unpacked = MessagePack.unpack(packed)\n\n            puts \"MessagePack native extension working!\"\n            puts \"Packed size: #{packed.bytesize} bytes\"\n        \"#})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: test-native-gem\n                name: test-native-gem\n                language: ruby\n                entry: ruby check_msgpack.rb\n                additional_dependencies: ['msgpack']\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    test-native-gem..........................................................Passed\n    - hook id: test-native-gem\n    - duration: [TIME]\n\n      MessagePack native extension working!\n      Packed size: 21 bytes\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that multiple gemspecs in a hook repo are built and installed together,\n/// with all dependencies resolved in a single pass.\n#[test]\nfn multiple_gemspecs() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let hook_repo = context.work_dir().child(\"multi-gem-repo\");\n    hook_repo.create_dir_all()?;\n\n    // First gemspec: depends on `rainbow`\n    hook_repo\n        .child(\"gem_one.gemspec\")\n        .write_str(indoc::indoc! {r#\"\n            Gem::Specification.new do |spec|\n              spec.name          = \"gem_one\"\n              spec.version       = \"0.1.0\"\n              spec.authors       = [\"Test\"]\n              spec.summary       = \"First gem\"\n              spec.files         = [\"bin/gem-one\"]\n              spec.executables   = [\"gem-one\"]\n              spec.bindir        = \"bin\"\n              spec.add_dependency \"rainbow\", \"~> 3.0\"\n            end\n        \"#})?;\n\n    // Second gemspec: also depends on `rainbow` (overlapping) plus `unicode-display_width`.\n    // This tests that --explain de-duplicates shared dependencies across gemspecs.\n    hook_repo.child(\"lib\").create_dir_all()?;\n    hook_repo\n        .child(\"lib/gem_two.rb\")\n        .write_str(\"module GemTwo; end\\n\")?;\n    hook_repo\n        .child(\"gem_two.gemspec\")\n        .write_str(indoc::indoc! {r#\"\n            Gem::Specification.new do |spec|\n              spec.name          = \"gem_two\"\n              spec.version       = \"0.1.0\"\n              spec.authors       = [\"Test\"]\n              spec.summary       = \"Second gem\"\n              spec.files         = [\"lib/gem_two.rb\"]\n              spec.add_dependency \"rainbow\", \"~> 3.0\"\n              spec.add_dependency \"unicode-display_width\", \"~> 3.0\"\n            end\n        \"#})?;\n\n    // Executable that requires both gems' dependencies\n    hook_repo.child(\"bin\").create_dir_all()?;\n    hook_repo.child(\"bin/gem-one\").write_str(indoc::indoc! {r#\"\n        #!/usr/bin/env ruby\n        require 'rainbow'\n        require 'unicode/display_width'\n        puts \"rainbow=#{Gem.loaded_specs['rainbow'].version}\"\n        puts \"udw=#{Gem.loaded_specs['unicode-display_width'].version}\"\n    \"#})?;\n\n    hook_repo\n        .child(\".pre-commit-hooks.yaml\")\n        .write_str(indoc::indoc! {r\"\n            - id: multi-gem\n              name: Multi Gem\n              entry: gem-one\n              language: ruby\n              pass_filenames: false\n        \"})?;\n\n    let output = git_cmd(&hook_repo).args([\"init\"]).output()?;\n    assert!(output.status.success(), \"git init failed: {output:?}\");\n    git_cmd(&hook_repo)\n        .args([\"config\", \"user.name\", \"Test User\"])\n        .output()?;\n    git_cmd(&hook_repo)\n        .args([\"config\", \"user.email\", \"test@example.com\"])\n        .output()?;\n    let output = git_cmd(&hook_repo).args([\"add\", \".\"]).output()?;\n    assert!(output.status.success(), \"git add failed: {output:?}\");\n    let output = git_cmd(&hook_repo)\n        .args([\"commit\", \"-m\", \"Initial commit\"])\n        .output()?;\n    assert!(output.status.success(), \"git commit failed: {output:?}\");\n\n    let rev_output = git_cmd(&hook_repo).args([\"rev-parse\", \"HEAD\"]).output()?;\n    let rev = String::from_utf8_lossy(&rev_output.stdout)\n        .trim()\n        .to_string();\n\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n            repos:\n              - repo: {}\n                rev: {}\n                hooks:\n                  - id: multi-gem\n                    always_run: true\n        \",\n        hook_repo.to_path_buf().display(),\n        rev\n    });\n    context.git_add(\".pre-commit-config.yaml\");\n\n    let filters = [\n        (r\"rainbow=\\d+\\.\\d+\\.\\d+\", \"rainbow=X.Y.Z\"),\n        (r\"udw=\\d+\\.\\d+\\.\\d+\", \"udw=X.Y.Z\"),\n    ]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Multi Gem................................................................Passed\n    - hook id: multi-gem\n    - duration: [TIME]\n\n      rainbow=X.Y.Z\n      udw=X.Y.Z\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that pre-built platform-specific gems skip compilation while source gems\n/// compile natively. Installs `sqlite3` (publishes precompiled platform gems) and\n/// `msgpack` (source-only, requires C compilation) side by side.\n#[test]\nfn prebuilt_vs_compiled_gems() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(\"check_gems.rb\")\n        .write_str(indoc::indoc! {r#\"\n            require 'sqlite3'\n            require 'msgpack'\n\n            db = SQLite3::Database.new(\":memory:\")\n            db.execute(\"CREATE TABLE t (v TEXT)\")\n            db.execute(\"INSERT INTO t VALUES (?)\", [MessagePack.pack(\"ok\")])\n            puts \"sqlite3=#{SQLite3::VERSION} msgpack=#{MessagePack::VERSION}\"\n        \"#})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: test-native-gems\n                name: test-native-gems\n                language: ruby\n                entry: ruby check_gems.rb\n                additional_dependencies: ['sqlite3', 'msgpack']\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = [\n        (r\"sqlite3=\\d+\\.\\d+\\.\\d+\", \"sqlite3=X.Y.Z\"),\n        (r\"msgpack=\\d+\\.\\d+\\.\\d+\", \"msgpack=X.Y.Z\"),\n    ]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    test-native-gems.........................................................Passed\n    - hook id: test-native-gems\n    - duration: [TIME]\n\n      sqlite3=X.Y.Z msgpack=X.Y.Z\n\n    ----- stderr -----\n    \");\n\n    // Verify installation methods by inspecting the gem directories.\n    // Pre-built gems include a platform suffix (e.g. sqlite3-X.Y.Z-x86_64-linux-gnu),\n    // while compiled-from-source gems do not (e.g. msgpack-X.Y.Z).\n    let hooks_dir = context.home_dir().join(\"hooks\");\n    let gems_dir = fs_err::read_dir(&hooks_dir)?\n        .filter_map(Result::ok)\n        .find(|e| e.file_name().to_string_lossy().starts_with(\"ruby-\"))\n        .map(|e| e.path().join(\"gems\").join(\"gems\"))\n        .expect(\"No ruby hook directory found\");\n\n    let gem_dirs: Vec<String> = fs_err::read_dir(&gems_dir)?\n        .filter_map(Result::ok)\n        .map(|e| e.file_name().to_string_lossy().to_string())\n        .collect();\n\n    // sqlite3 should have a platform suffix (precompiled)\n    let sqlite3_dir = gem_dirs\n        .iter()\n        .find(|d| d.starts_with(\"sqlite3-\"))\n        .expect(\"sqlite3 gem not found\");\n    assert!(\n        sqlite3_dir.contains(\"x86_64-linux\")\n            || sqlite3_dir.contains(\"aarch64-linux\")\n            || sqlite3_dir.contains(\"arm64-darwin\")\n            || sqlite3_dir.contains(\"x64-mingw\"),\n        \"sqlite3 should be a prebuilt platform gem, got: {sqlite3_dir}\"\n    );\n\n    // msgpack should NOT have a platform suffix (compiled from source)\n    let msgpack_dir = gem_dirs\n        .iter()\n        .find(|d| d.starts_with(\"msgpack-\"))\n        .expect(\"msgpack gem not found\");\n    assert!(\n        !msgpack_dir.contains(\"linux\")\n            && !msgpack_dir.contains(\"darwin\")\n            && !msgpack_dir.contains(\"mingw\"),\n        \"msgpack should be compiled from source (no platform suffix), got: {msgpack_dir}\"\n    );\n\n    Ok(())\n}\n\n/// Test Ruby hook that processes files\n#[test]\nfn process_files() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a Ruby script that validates file extensions\n    context\n        .work_dir()\n        .child(\"check_ruby.rb\")\n        .write_str(indoc::indoc! {r#\"\n            ARGV.sort.each do |file|\n              unless file.end_with?('.rb')\n                puts \"Error: #{file} is not a Ruby file\"\n                exit 1\n              end\n              puts \"OK: #{file}\"\n            end\n        \"#})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-ruby-files\n                name: check-ruby-files\n                language: ruby\n                entry: ruby check_ruby.rb\n                language_version: system\n                files: \\.rb$\n                verbose: true\n    \"});\n\n    // Create a Ruby file\n    context\n        .work_dir()\n        .child(\"test.rb\")\n        .write_str(\"puts 'hello'\")?;\n    // Create a text file\n    context.work_dir().child(\"test.txt\").write_str(\"hello\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-ruby-files.........................................................Passed\n    - hook id: check-ruby-files\n    - duration: [TIME]\n\n      OK: check_ruby.rb\n      OK: test.rb\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that Ruby is auto-downloaded from rv-ruby when a specific version\n/// is requested that isn't available on the system.\n/// Windows doesn't support auto-download, so this test is only for non-Windows platforms.\n/// The Windows-specific message is tested in `specific_ruby_unavailable`.\n#[test]\n#[cfg(not(target_os = \"windows\"))]\nfn auto_download() -> anyhow::Result<()> {\n    use assert_fs::assert::PathAssert;\n    use prek_consts::env_vars::EnvVars;\n\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI: local environments may have\n        // unexpected Ruby versions installed.\n        return Ok(());\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ruby-downloaded\n                name: ruby-downloaded\n                language: ruby\n                entry: ruby --version\n                language_version: '3.3'\n                pass_filenames: false\n                always_run: true\n              - id: ruby-system\n                name: ruby-system\n                language: ruby\n                entry: ruby --version\n                language_version: '3.4'\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let ruby_dir = context.home_dir().child(\"tools\").child(\"ruby\");\n    ruby_dir.assert(predicates::path::missing());\n\n    let filters = [(\n        r\"ruby (\\d+\\.\\d+)\\.\\d+(?:p\\d+)? \\(\\d{4}-\\d{2}-\\d{2} revision [0-9a-f]{0,10}\\).*?\\[.+\\]\",\n        \"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ruby-downloaded..........................................................Passed\n    - hook id: ruby-downloaded\n    - duration: [TIME]\n\n      ruby 3.3.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n    ruby-system..............................................................Passed\n    - hook id: ruby-system\n    - duration: [TIME]\n\n      ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n\n    ----- stderr -----\n    \");\n\n    // Verify that only Ruby 3.3 was downloaded (3.4 should use system Ruby)\n    let installed_versions = ruby_dir\n        .read_dir()?\n        .flatten()\n        .filter_map(|d| {\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one Ruby version to be downloaded, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.starts_with(\"3.3.\")),\n        \"Expected Ruby 3.3.x to be downloaded, but found: {installed_versions:?}\"\n    );\n\n    // Record the mtime of the downloaded Ruby directory so we can verify\n    // step 2 doesn't re-download it.\n    let ruby_version_dir = ruby_dir\n        .read_dir()?\n        .flatten()\n        .find(|d| d.file_name().to_string_lossy().starts_with(\"3.3.\"))\n        .expect(\"Expected a 3.3.x directory\");\n    let mtime_before = ruby_version_dir.metadata()?.modified()?;\n\n    // Step 2: Re-run with a looser version match that should reuse the\n    // already-downloaded 3.3.x without hitting the network again.\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ruby-reused\n                name: ruby-reused\n                language: ruby\n                entry: ruby --version\n                language_version: '>=3.3, <3.4'\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = [(\n        r\"ruby (\\d+\\.\\d+)\\.\\d+(?:p\\d+)? \\(\\d{4}-\\d{2}-\\d{2} revision [0-9a-f]{0,10}\\).*?\\[.+\\]\",\n        \"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\",\n    )]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ruby-reused..............................................................Passed\n    - hook id: ruby-reused\n    - duration: [TIME]\n\n      ruby 3.3.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]\n\n    ----- stderr -----\n    \");\n\n    // Verify the directory wasn't re-downloaded: mtime should be unchanged\n    let mtime_after = ruby_version_dir.metadata()?.modified()?;\n    assert_eq!(\n        mtime_before, mtime_after,\n        \"Ruby directory was modified during reuse, suggests it was re-downloaded\"\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/rust.rs",
    "content": "use anyhow::Result;\nuse assert_fs::assert::PathAssert;\nuse assert_fs::fixture::PathChild;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n/// Test `language_version` parsing and installation for Rust hooks.\n#[test]\nfn language_version() -> Result<()> {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may have other rust versions installed locally.\n        return Ok(());\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: rust-system\n                name: rust-system\n                language: rust\n                entry: rustc --version\n                language_version: system\n                pass_filenames: false\n                always_run: true\n              - id: rust-1.70 # should auto install 1.70.X\n                name: rust-1.70\n                language: rust\n                entry: rustc --version\n                language_version: '1.70'\n                always_run: true\n                pass_filenames: false\n              - id: rust-1.70 # run again to ensure reusing the installed version\n                name: rust-1.70\n                language: rust\n                entry: rustc --version\n                language_version: '1.70'\n                always_run: true\n                pass_filenames: false\n    \"});\n    context.git_add(\".\");\n\n    let rust_dir = context.home_dir().child(\"tools/rustup/toolchains\");\n    rust_dir.assert(predicates::path::missing());\n\n    let filters = [\n        (r\"rustc (1\\.70)\\.\\d{1,2} .+\", \"rustc $1.X\"), // Keep 1.70.X format\n        (r\"rustc 1\\.\\d{1,3}\\.\\d{1,2} .+\", \"rustc 1.X.X\"), // Others become 1.X.X\n    ]\n    .into_iter()\n    .chain(context.filters())\n    .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    rust-system..............................................................Passed\n    - hook id: rust-system\n    - duration: [TIME]\n\n      rustc 1.X.X\n    rust-1.70................................................................Passed\n    - hook id: rust-1.70\n    - duration: [TIME]\n\n      rustc 1.70.X\n    rust-1.70................................................................Passed\n    - hook id: rust-1.70\n    - duration: [TIME]\n\n      rustc 1.70.X\n\n    ----- stderr -----\n    \"#);\n\n    // Ensure that only Rust 1.70.X is installed.\n    let installed_versions = rust_dir\n        .read_dir()?\n        .flatten()\n        .filter_map(|d| {\n            let filename = d.file_name().to_string_lossy().to_string();\n            if filename.starts_with('.') {\n                None\n            } else {\n                Some(filename)\n            }\n        })\n        .collect::<Vec<_>>();\n\n    assert_eq!(\n        installed_versions.len(),\n        1,\n        \"Expected only one Rust version to be installed, but found: {installed_versions:?}\"\n    );\n    assert!(\n        installed_versions.iter().any(|v| v.starts_with(\"1.70\")),\n        \"Expected Rust 1.70.X to be installed, but found: {installed_versions:?}\"\n    );\n\n    Ok(())\n}\n\n/// Test `rustup` installer.\n#[test]\nfn rustup_installer() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: rustup-test\n                name: rustup-test\n                language: rust\n                entry: rustc --version\n   \"});\n    context.git_add(\".\");\n    let filters = [(r\"rustc 1\\.\\d{1,3}\\.\\d{1,2} .+\", \"rustc 1.X.X\")]\n        .into_iter()\n        .chain(context.filters())\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run().arg(\"-v\").env(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME, \"non-exist-rustup\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    rustup-test..............................................................Passed\n    - hook id: rustup-test\n    - duration: [TIME]\n\n      rustc 1.X.X\n\n    ----- stderr -----\n    \"#);\n}\n\n/// Test that `additional_dependencies` with cli: prefix are installed correctly.\n#[test]\nfn additional_dependencies_cli() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: rust-cli\n                name: rust-cli\n                language: rust\n                entry: prek-rust-echo Hello, Prek!\n                additional_dependencies: [\"cli:prek-rust-echo\"]\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    rust-cli.................................................................Passed\n    - hook id: rust-cli\n    - duration: [TIME]\n\n      Hello, Prek!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that remote Rust hooks are installed and run correctly.\n#[test]\nfn remote_hooks() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: https://github.com/prek-test-repos/rust-hooks\n            rev: v1.0.0\n            hooks:\n              - id: hello-world\n                verbose: true\n                pass_filenames: false\n                always_run: true\n                args: [\"Hello World\"]\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Hello World..............................................................Passed\n    - hook id: hello-world\n    - duration: [TIME]\n\n      Hello World\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that remote Rust hooks from non-workspace repos are installed and run correctly.\n#[test]\nfn remote_hook_non_workspace() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/rust-hooks-non-workspace\n            rev: v1.0.0\n            hooks:\n              - id: hello-world\n                verbose: true\n                pass_filenames: false\n                always_run: true\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    hello-world..............................................................Passed\n    - hook id: hello-world\n    - duration: [TIME]\n\n      Hello, Prek!\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that library dependencies (non-cli: prefix) work correctly on remote hooks.\n/// This verifies that the shared repo is not modified when adding dependencies.\n#[test]\nfn remote_hooks_with_lib_deps() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: https://github.com/prek-test-repos/rust-hooks\n            rev: v1.0.0\n            hooks:\n              - id: hello-world-lib-deps\n                additional_dependencies: [\"itoa:1\"]\n                verbose: true\n                pass_filenames: false\n                always_run: true\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Hello World Lib Deps.....................................................Passed\n    - hook id: hello-world-lib-deps\n    - duration: [TIME]\n\n      42\n\n    ----- stderr -----\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/script.rs",
    "content": "use anyhow::Result;\nuse assert_fs::fixture::{FileWriteStr, PathChild};\n\nuse crate::common::{TestContext, cmd_snapshot};\n\n#[cfg(unix)]\nmod unix {\n    use super::*;\n\n    use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\n    use prek_consts::PRE_COMMIT_CONFIG_YAML;\n    use std::os::unix::fs::PermissionsExt;\n\n    #[test]\n    fn script_run() {\n        let context = TestContext::new();\n        context.init_project();\n        context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/prek-test-repos/script-hooks\n            rev: v1.0.0\n            hooks:\n              - id: echo-env\n                env:\n                  VAR2: universe\n                verbose: true\n              - id: echo-env\n                env:\n                  VAR1: everyone\n                  VAR2: galaxy\n                verbose: true\n        \"});\n        context.git_add(\".\");\n\n        cmd_snapshot!(context.filters(), context.run(), @r\"\n        success: true\n        exit_code: 0\n        ----- stdout -----\n        echo-env.................................................................Passed\n        - hook id: echo-env\n        - duration: [TIME]\n\n          Hello world and universe!\n        echo-env.................................................................Passed\n        - hook id: echo-env\n        - duration: [TIME]\n\n          Hello everyone and galaxy!\n\n        ----- stderr -----\n        \");\n    }\n\n    #[test]\n    fn workspace_script_run() -> Result<()> {\n        let context = TestContext::new();\n        context.init_project();\n\n        let config = indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: script\n                name: script\n                language: script\n                entry: ./script.sh\n                env:\n                  MESSAGE: \"Hello, World\"\n                verbose: true\n        \"#};\n        context.write_pre_commit_config(config);\n        context\n            .work_dir()\n            .child(\"script.sh\")\n            .write_str(indoc::indoc! {r#\"\n            #!/usr/bin/env bash\n            echo \"$MESSAGE!\"\n        \"#})?;\n\n        let child = context.work_dir().child(\"child\");\n        child.create_dir_all()?;\n        child.child(PRE_COMMIT_CONFIG_YAML).write_str(config)?;\n        child.child(\"script.sh\").write_str(indoc::indoc! {r#\"\n            #!/usr/bin/env bash\n            echo \"$MESSAGE from child!\"\n        \"#})?;\n\n        fs_err::set_permissions(\n            context.work_dir().child(\"script.sh\"),\n            std::fs::Permissions::from_mode(0o755),\n        )?;\n        fs_err::set_permissions(\n            child.child(\"script.sh\"),\n            std::fs::Permissions::from_mode(0o755),\n        )?;\n        context.git_add(\".\");\n\n        cmd_snapshot!(context.filters(), context.run(), @r\"\n        success: true\n        exit_code: 0\n        ----- stdout -----\n        Running hooks for `child`:\n        script...................................................................Passed\n        - hook id: script\n        - duration: [TIME]\n\n          Hello, World from child!\n\n        Running hooks for `.`:\n        script...................................................................Passed\n        - hook id: script\n        - duration: [TIME]\n\n          Hello, World!\n\n        ----- stderr -----\n        \");\n\n        cmd_snapshot!(context.filters(), context.run().current_dir(&child), @r\"\n        success: true\n        exit_code: 0\n        ----- stdout -----\n        script...................................................................Passed\n        - hook id: script\n        - duration: [TIME]\n\n          Hello, World from child!\n\n        ----- stderr -----\n        \");\n\n        Ok(())\n    }\n\n    #[test]\n    fn local_repo_bash_shebang() -> Result<()> {\n        let context = TestContext::new();\n        context.init_project();\n        context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: echo\n                name: echo\n                language: script\n                entry: ./echo.sh\n                verbose: true\n        \"});\n\n        let script = context.work_dir().child(\"echo.sh\");\n        script.write_str(indoc::indoc! {r#\"\n            #!/usr/bin/env bash\n            echo \"Hello, World!\"\n        \"#})?;\n        fs_err::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?;\n\n        context.git_add(\".\");\n\n        cmd_snapshot!(context.filters(), context.run(), @r\"\n        success: true\n        exit_code: 0\n        ----- stdout -----\n        echo.....................................................................Passed\n        - hook id: echo\n        - duration: [TIME]\n\n          Hello, World!\n\n        ----- stderr -----\n        \");\n\n        Ok(())\n    }\n}\n\n/// Test that a script with a shebang line works correctly on Windows.\n/// The interpreter must exist in the PATH, the script is not needed to be executable.\n#[test]\nfn windows_script_run() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n          - id: echo\n            name: echo\n            language: script\n            entry: ./echo.sh\n            verbose: true\n    \"});\n\n    let script = context.work_dir().child(\"echo.sh\");\n    script.write_str(indoc::indoc! {r#\"\n        #!/usr/bin/env python3\n        print(\"Hello, World!\")\n    \"#})?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo.....................................................................Passed\n    - hook id: echo\n    - duration: [TIME]\n\n      Hello, World!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/swift.rs",
    "content": "use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\nuse prek_consts::PRE_COMMIT_HOOKS_YAML;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\n/// Test that a local Swift hook with a system command works.\n#[test]\nfn local_hook_system_command() {\n    if !EnvVars::is_set(EnvVars::CI) {\n        return;\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: echo-swift\n                name: echo-swift\n                language: swift\n                entry: echo \"Swift hook ran\"\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo-swift...............................................................Passed\n    - hook id: echo-swift\n    - duration: [TIME]\n\n      Swift hook ran\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that `language_version` is rejected for Swift.\n#[test]\nfn language_version_rejected() {\n    if !EnvVars::is_set(EnvVars::CI) {\n        return;\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: swift\n                entry: swift --version\n                language_version: '6.0'\n                always_run: true\n                pass_filenames: false\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to init hooks\n      caused by: Invalid hook `local`\n      caused by: Hook specified `language_version: 6.0` but the language `swift` does not support toolchain installation for now\n    \");\n}\n\n/// Test that health check works after install.\n#[test]\nfn health_check() {\n    if !EnvVars::is_set(EnvVars::CI) {\n        return;\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: swift-echo\n                name: swift-echo\n                language: swift\n                entry: echo \"Hello\"\n                always_run: true\n                verbose: true\n                pass_filenames: false\n    \"#});\n\n    context.git_add(\".\");\n\n    // First run - installs\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    swift-echo...............................................................Passed\n    - hook id: swift-echo\n    - duration: [TIME]\n\n      Hello\n\n    ----- stderr -----\n    \");\n\n    // Second run - health check\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    swift-echo...............................................................Passed\n    - hook id: swift-echo\n    - duration: [TIME]\n\n      Hello\n\n    ----- stderr -----\n    \");\n}\n\n/// Test that a Swift Package.swift is built and the executable is available.\n#[test]\nfn local_package_build() -> anyhow::Result<()> {\n    if !EnvVars::is_set(EnvVars::CI) {\n        return Ok(());\n    }\n\n    let swift_hook = TestContext::new();\n    swift_hook.init_project();\n\n    // Create a minimal Swift package\n    swift_hook\n        .work_dir()\n        .child(\"Package.swift\")\n        .write_str(indoc::indoc! {r#\"\n        // swift-tools-version:6.0\n        import PackageDescription\n\n        let package = Package(\n            name: \"prek-swift-test\",\n            targets: [\n                .executableTarget(name: \"prek-swift-test\", path: \"Sources\")\n            ]\n        )\n    \"#})?;\n    swift_hook.work_dir().child(\"Sources\").create_dir_all()?;\n    swift_hook\n        .work_dir()\n        .child(\"Sources/main.swift\")\n        .write_str(indoc::indoc! {r#\"\n        print(\"Hello from Swift package!\")\n    \"#})?;\n    swift_hook\n        .work_dir()\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r\"\n        - id: swift-package-test\n          name: swift-package-test\n          entry: prek-swift-test\n          language: swift\n    \"})?;\n    swift_hook.git_add(\".\");\n    swift_hook.git_commit(\"Initial commit\");\n    git_cmd(swift_hook.work_dir())\n        .args([\"tag\", \"v1.0\", \"-m\", \"v1.0\"])\n        .output()?;\n\n    let context = TestContext::new();\n    context.init_project();\n\n    let hook_url = swift_hook.work_dir().to_str().unwrap();\n    context.write_pre_commit_config(&indoc::formatdoc! {r\"\n        repos:\n          - repo: {hook_url}\n            rev: v1.0\n            hooks:\n              - id: swift-package-test\n                verbose: true\n                always_run: true\n                pass_filenames: false\n    \", hook_url = hook_url});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    swift-package-test.......................................................Passed\n    - hook id: swift-package-test\n    - duration: [TIME]\n\n      Hello from Swift package!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/unimplemented.rs",
    "content": "use crate::common::{TestContext, cmd_snapshot};\n\n#[test]\nfn unimplemented_language() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n            - id: unimplemented-language-hook\n              name: r-hook\n              language: r\n              entry: rscript --version\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    r-hook...............................................(unimplemented yet)Skipped\n\n    ----- stderr -----\n    warning: Some hooks were skipped because their languages are unimplemented.\n    We're working hard to support more languages. Check out current support status at https://prek.j178.dev/languages/.\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/languages/unsupported.rs",
    "content": "/// Test `language: unsupported` and `language: unsupported_script` works.\n#[cfg(unix)]\n#[test]\nfn unsupported_language() -> anyhow::Result<()> {\n    use crate::common::{TestContext, cmd_snapshot};\n    use assert_fs::fixture::{FileWriteStr, PathChild};\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: unsupported\n                name: unsupported\n                language: unsupported\n                entry: echo\n                verbose: true\n              - id: unsupported-script\n                name: unsupported-script\n                language: unsupported_script\n                entry: ./script.sh\n                verbose: true\n    \"});\n    context\n        .work_dir()\n        .child(\"script.sh\")\n        .write_str(indoc::indoc! {r#\"\n            #!/usr/bin/env bash\n            echo \"Hello, World!\"\n        \"#})?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    unsupported..............................................................Passed\n    - hook id: unsupported\n    - duration: [TIME]\n\n      script.sh .pre-commit-config.yaml\n    unsupported-script.......................................................Passed\n    - hook id: unsupported-script\n    - duration: [TIME]\n\n      Hello, World!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/list.rs",
    "content": "use crate::common::{TestContext, cmd_snapshot};\nuse indoc::indoc;\n\nmod common;\n\n#[test]\nfn list_basic() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n              - id: check-json\n                name: Check JSON\n                entry: check-json\n                language: system\n                types: [json]\n                description: Validate JSON files\n    \"});\n\n    cmd_snapshot!(context.filters(), context.list(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n    .:check-json\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_verbose() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n              - id: check-json\n                name: Check JSON\n                entry: check-json\n                language: system\n                types: [json]\n                description: Validate JSON files\n                fail_fast: true\n                verbose: true\n    \"});\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--verbose\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n      ID: check-yaml\n      Name: Check YAML\n      Language: system\n      Stages: all\n\n    .:check-json\n      ID: check-json\n      Name: Check JSON\n      Description: Validate JSON files\n      Language: system\n      Stages: all\n\n\n    ----- stderr -----\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: custom-formatter\n                name: Custom Code Formatter\n                entry: ./format.sh\n                language: script\n                description: Custom formatting tool with specific requirements\n                files: \\.(py|rs|js)$\n                exclude: vendor/\n                types: [text]\n                types_or: [python, rust, javascript]\n                exclude_types: [binary]\n                args: [--check, --diff]\n                always_run: true\n                fail_fast: true\n                pass_filenames: false\n                require_serial: true\n                verbose: true\n                stages: [pre-commit, pre-push]\n                alias: fmt\n    \"});\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--verbose\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:custom-formatter\n      ID: custom-formatter\n      Alias: fmt\n      Name: Custom Code Formatter\n      Description: Custom formatting tool with specific requirements\n      Language: script\n      Stages: pre-commit, pre-push\n\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_with_hook_ids_filter() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n              - id: check-json\n                name: Check JSON\n                entry: check-json\n                language: system\n                types: [json]\n              - id: check-toml\n                name: Check TOML\n                entry: check-toml\n                language: system\n                types: [toml]\n    \"});\n\n    // Test filtering by specific hook ID\n    cmd_snapshot!(context.filters(), context.list().arg(\"check-yaml\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n\n    ----- stderr -----\n    \");\n\n    // Test filtering by multiple hook IDs\n    cmd_snapshot!(context.filters(), context.list().arg(\"check-yaml\").arg(\"check-json\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n    .:check-json\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_with_language_filter() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n              - id: format-python\n                name: Format Python\n                entry: black\n                language: python\n                types: [python]\n              - id: lint-rust\n                name: Lint Rust\n                entry: clippy\n                language: rust\n                types: [rust]\n    \"});\n\n    // Test filtering by language\n    cmd_snapshot!(context.filters(), context.list().arg(\"--language\").arg(\"system\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--language\").arg(\"python\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:format-python\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_with_stage_filter() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n              - id: check-json\n                name: Check JSON\n                entry: check-json\n                language: system\n                types: [json]\n                stages: [pre-push]\n              - id: check-toml\n                name: Check TOML\n                entry: check-toml\n                language: system\n                types: [toml]\n                stages: [pre-commit, pre-push]\n    \"});\n\n    // Test filtering by stage\n    cmd_snapshot!(context.filters(), context.list().arg(\"--hook-stage\").arg(\"pre-commit\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n    .:check-toml\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--hook-stage\").arg(\"pre-push\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n    .:check-json\n    .:check-toml\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_with_aliases() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n                alias: yaml-check\n              - id: check-json\n                name: Check JSON\n                entry: check-json\n                language: system\n                types: [json]\n    \"});\n\n    // Test that aliases are recognized\n    cmd_snapshot!(context.filters(), context.list().arg(\"yaml-check\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n\n    ----- stderr -----\n    \");\n\n    // Test verbose shows alias information\n    cmd_snapshot!(context.filters(), context.list().arg(\"--verbose\").arg(\"check-yaml\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:check-yaml\n      ID: check-yaml\n      Alias: yaml-check\n      Name: Check YAML\n      Language: system\n      Stages: all\n\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_empty_config() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(\"repos: []\");\n\n    cmd_snapshot!(context.filters(), context.list(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--verbose\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn list_no_config_file() {\n    let context = TestContext::new();\n    context.init_project();\n\n    // No config file exists\n    cmd_snapshot!(context.filters(), context.list(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.\n\n    hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.\n    \");\n}\n\n#[test]\nfn list_json_output() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-yaml\n                name: Check YAML\n                entry: check-yaml\n                language: system\n                types: [yaml]\n                alias: yaml-check\n              - id: check-json\n                name: Check JSON\n                entry: check-json\n                language: system\n                types: [json]\n                description: Validate JSON files\n    \"});\n\n    // Test JSON output for all hooks\n    cmd_snapshot!(context.filters(), context.list().arg(\"--output-format=json\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [\n      {\n        \"id\": \"check-yaml\",\n        \"full_id\": \".:check-yaml\",\n        \"name\": \"Check YAML\",\n        \"alias\": \"yaml-check\",\n        \"language\": \"system\",\n        \"description\": null,\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      },\n      {\n        \"id\": \"check-json\",\n        \"full_id\": \".:check-json\",\n        \"name\": \"Check JSON\",\n        \"alias\": \"\",\n        \"language\": \"system\",\n        \"description\": \"Validate JSON files\",\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      }\n    ]\n\n    ----- stderr -----\n    \"#);\n\n    // Test filtered JSON output\n    cmd_snapshot!(context.filters(), context.list().arg(\"check-json\").arg(\"--output-format=json\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [\n      {\n        \"id\": \"check-json\",\n        \"full_id\": \".:check-json\",\n        \"name\": \"Check JSON\",\n        \"alias\": \"\",\n        \"language\": \"system\",\n        \"description\": \"Validate JSON files\",\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      }\n    ]\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn workspace_list() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.list(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    nested/project4:show-cwd\n    project3/project5:show-cwd\n    project2:show-cwd\n    project3:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    let mut filters = context.filters();\n    filters.push((r\"\\\\/\", \"/\")); // Normalize Windows path separators in JSON output\n    cmd_snapshot!(filters, context.list().arg(\"--output-format=json\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [\n      {\n        \"id\": \"show-cwd\",\n        \"full_id\": \"nested/project4:show-cwd\",\n        \"name\": \"Show CWD\",\n        \"alias\": \"\",\n        \"language\": \"python\",\n        \"description\": null,\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      },\n      {\n        \"id\": \"show-cwd\",\n        \"full_id\": \"project3/project5:show-cwd\",\n        \"name\": \"Show CWD\",\n        \"alias\": \"\",\n        \"language\": \"python\",\n        \"description\": null,\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      },\n      {\n        \"id\": \"show-cwd\",\n        \"full_id\": \"project2:show-cwd\",\n        \"name\": \"Show CWD\",\n        \"alias\": \"\",\n        \"language\": \"python\",\n        \"description\": null,\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      },\n      {\n        \"id\": \"show-cwd\",\n        \"full_id\": \"project3:show-cwd\",\n        \"name\": \"Show CWD\",\n        \"alias\": \"\",\n        \"language\": \"python\",\n        \"description\": null,\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      },\n      {\n        \"id\": \"show-cwd\",\n        \"full_id\": \".:show-cwd\",\n        \"name\": \"Show CWD\",\n        \"alias\": \"\",\n        \"language\": \"python\",\n        \"description\": null,\n        \"stages\": [\n          \"manual\",\n          \"commit-msg\",\n          \"post-checkout\",\n          \"post-commit\",\n          \"post-merge\",\n          \"post-rewrite\",\n          \"pre-commit\",\n          \"pre-merge-commit\",\n          \"pre-push\",\n          \"pre-rebase\",\n          \"prepare-commit-msg\"\n        ]\n      }\n    ]\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.list().current_dir(cwd.join(\"project3\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    project5:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().current_dir(cwd.join(\"project3\")).arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    project5:show-cwd\n      ID: show-cwd\n      Name: Show CWD\n      Language: python\n      Stages: all\n\n    .:show-cwd\n      ID: show-cwd\n      Name: Show CWD\n      Language: python\n      Stages: all\n\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn list_with_selectors() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    project2:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--skip\").arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    nested/project4:show-cwd\n    project3/project5:show-cwd\n    project3:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--skip\").arg(\"nested/\").arg(\"--skip\").arg(\"project3/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    project2:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    nested/project4:show-cwd\n    project3/project5:show-cwd\n    project2:show-cwd\n    project3:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"project2:show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    project2:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\".:show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--skip\").arg(\"show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--skip\").arg(\"project2:show-cwd\").arg(\"--skip\").arg(\"nested:show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    nested/project4:show-cwd\n    project3/project5:show-cwd\n    project3:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    warning: selector `--skip=nested:show-cwd` did not match any hooks\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--skip\").arg(\"non-exist\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    nested/project4:show-cwd\n    project3/project5:show-cwd\n    project2:show-cwd\n    project3:show-cwd\n    .:show-cwd\n\n    ----- stderr -----\n    warning: selector `--skip=non-exist` did not match any hooks\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().arg(\"--skip\").arg(\"../\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Invalid selector: `../`\n      caused by: Invalid project path: `../`\n      caused by: path is outside the workspace root\n    \");\n\n    cmd_snapshot!(context.filters(), context.list().current_dir(context.work_dir().join(\"project2\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    .:show-cwd\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/list_builtins.rs",
    "content": "use crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\n#[test]\nfn list_builtins_basic() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"util\").arg(\"list-builtins\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-added-large-files\n    check-case-conflict\n    check-executables-have-shebangs\n    check-json\n    check-json5\n    check-merge-conflict\n    check-symlinks\n    check-toml\n    check-xml\n    check-yaml\n    detect-private-key\n    end-of-file-fixer\n    fix-byte-order-marker\n    mixed-line-ending\n    no-commit-to-branch\n    trailing-whitespace\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_builtins_verbose() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"util\").arg(\"list-builtins\").arg(\"--verbose\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-added-large-files\n      prevents giant files from being committed.\n\n    check-case-conflict\n      checks for files that would conflict in case-insensitive filesystems\n\n    check-executables-have-shebangs\n      ensures that (non-binary) executables have a shebang.\n\n    check-json\n      checks json files for parseable syntax.\n\n    check-json5\n      checks json5 files for parseable syntax.\n\n    check-merge-conflict\n      checks for files that contain merge conflict strings.\n\n    check-symlinks\n      checks for symlinks which do not point to anything.\n\n    check-toml\n      checks toml files for parseable syntax.\n\n    check-xml\n      checks xml files for parseable syntax.\n\n    check-yaml\n      checks yaml files for parseable syntax.\n\n    detect-private-key\n      detects the presence of private keys.\n\n    end-of-file-fixer\n      ensures that a file is either empty, or ends with one newline.\n\n    fix-byte-order-marker\n      removes utf-8 byte order marker.\n\n    mixed-line-ending\n      replaces or checks mixed line ending.\n\n    no-commit-to-branch\n\n    trailing-whitespace\n      trims trailing whitespace.\n\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn list_builtins_json() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.command().arg(\"util\").arg(\"list-builtins\").arg(\"--output-format=json\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [\n      {\n        \"id\": \"check-added-large-files\",\n        \"name\": \"check for added large files\",\n        \"description\": \"prevents giant files from being committed.\"\n      },\n      {\n        \"id\": \"check-case-conflict\",\n        \"name\": \"check for case conflicts\",\n        \"description\": \"checks for files that would conflict in case-insensitive filesystems\"\n      },\n      {\n        \"id\": \"check-executables-have-shebangs\",\n        \"name\": \"check that executables have shebangs\",\n        \"description\": \"ensures that (non-binary) executables have a shebang.\"\n      },\n      {\n        \"id\": \"check-json\",\n        \"name\": \"check json\",\n        \"description\": \"checks json files for parseable syntax.\"\n      },\n      {\n        \"id\": \"check-json5\",\n        \"name\": \"check json5\",\n        \"description\": \"checks json5 files for parseable syntax.\"\n      },\n      {\n        \"id\": \"check-merge-conflict\",\n        \"name\": \"check for merge conflicts\",\n        \"description\": \"checks for files that contain merge conflict strings.\"\n      },\n      {\n        \"id\": \"check-symlinks\",\n        \"name\": \"check for broken symlinks\",\n        \"description\": \"checks for symlinks which do not point to anything.\"\n      },\n      {\n        \"id\": \"check-toml\",\n        \"name\": \"check toml\",\n        \"description\": \"checks toml files for parseable syntax.\"\n      },\n      {\n        \"id\": \"check-xml\",\n        \"name\": \"check xml\",\n        \"description\": \"checks xml files for parseable syntax.\"\n      },\n      {\n        \"id\": \"check-yaml\",\n        \"name\": \"check yaml\",\n        \"description\": \"checks yaml files for parseable syntax.\"\n      },\n      {\n        \"id\": \"detect-private-key\",\n        \"name\": \"detect private key\",\n        \"description\": \"detects the presence of private keys.\"\n      },\n      {\n        \"id\": \"end-of-file-fixer\",\n        \"name\": \"fix end of files\",\n        \"description\": \"ensures that a file is either empty, or ends with one newline.\"\n      },\n      {\n        \"id\": \"fix-byte-order-marker\",\n        \"name\": \"fix utf-8 byte order marker\",\n        \"description\": \"removes utf-8 byte order marker.\"\n      },\n      {\n        \"id\": \"mixed-line-ending\",\n        \"name\": \"mixed line ending\",\n        \"description\": \"replaces or checks mixed line ending.\"\n      },\n      {\n        \"id\": \"no-commit-to-branch\",\n        \"name\": \"don't commit to branch\",\n        \"description\": null\n      },\n      {\n        \"id\": \"trailing-whitespace\",\n        \"name\": \"trim trailing whitespace\",\n        \"description\": \"trims trailing whitespace.\"\n      }\n    ]\n\n    ----- stderr -----\n    \"#);\n}\n"
  },
  {
    "path": "crates/prek/tests/meta_hooks.rs",
    "content": "mod common;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nuse assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\n\n#[test]\nfn meta_hooks() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"file.txt\").write_str(\"Hello, world!\\n\")?;\n    cwd.child(\"valid.json\").write_str(\"{}\")?;\n    cwd.child(\"invalid.json\").write_str(\"{}\")?;\n    cwd.child(\"main.py\").write_str(r#\"print \"abc\"  \"#)?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: meta\n            hooks:\n              - id: check-hooks-apply\n              - id: check-useless-excludes\n              - id: identity\n          - repo: local\n            hooks:\n              - id: match-no-files\n                name: match no files\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                files: ^nonexistent$\n              - id: useless-exclude\n                name: useless exclude\n                language: system\n                entry: python3 -c 'import sys; sys.exit(0)'\n                exclude: $nonexistent^\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Check hooks apply........................................................Failed\n    - hook id: check-hooks-apply\n    - exit code: 1\n\n      match-no-files does not apply to this repository\n    Check useless excludes...................................................Failed\n    - hook id: check-useless-excludes\n    - exit code: 1\n\n      The exclude pattern `regex: $nonexistent^` for `useless-exclude` does not match any files\n    identity.................................................................Passed\n    - hook id: identity\n    - duration: [TIME]\n\n      file.txt\n      .pre-commit-config.yaml\n      valid.json\n      invalid.json\n      main.py\n    match no files.......................................(no files to check)Skipped\n    useless exclude..........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn meta_hooks_unknown_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: meta\n            hooks:\n              - id: this-hook-does-not-exist\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: error: line 4 column 9: unknown meta hook id `this-hook-does-not-exist`\n     --> <input>:4:9\n      |\n    2 |   - repo: meta\n    3 |     hooks:\n    4 |       - id: this-hook-does-not-exist\n      |         ^ unknown meta hook id `this-hook-does-not-exist`\n    \");\n}\n\n#[test]\nfn check_useless_excludes_remote() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // When checking useless excludes, remote hooks are not actually cloned,\n    // so hook options defined from HookManifest are not used.\n    // If applied, \"types_or: [python, pyi]\" from black-pre-commit-mirror\n    // will filter out html files first, so the excludes would not be useless, and the test would fail.\n    let pre_commit_config = indoc::formatdoc! {r\"\n    repos:\n      - repo: https://github.com/psf/black-pre-commit-mirror\n        rev: 25.1.0\n        hooks:\n          - id: black\n            exclude: '^html/'\n      - repo: local\n        hooks:\n          - id: echo\n            name: echo\n            entry: echo 'echoing'\n            language: system\n            exclude: '^useless/$'\n      - repo: meta\n        hooks:\n            - id: check-useless-excludes\n    \"};\n    context.work_dir().child(\"html\").create_dir_all()?;\n    context\n        .work_dir()\n        .child(\"html\")\n        .child(\"file1.html\")\n        .write_str(\"<!DOCTYPE html>\")?;\n\n    context.write_pre_commit_config(&pre_commit_config);\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run().arg(\"check-useless-excludes\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Check useless excludes...................................................Failed\n    - hook id: check-useless-excludes\n    - exit code: 1\n\n      The exclude pattern `regex: ^useless/$` for `echo` does not match any files\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn meta_hooks_workspace() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let app = context.work_dir().child(\"app\");\n    app.create_dir_all()?;\n    app.child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc::indoc! {r\"\n        repos:\n          - repo: meta\n            hooks:\n              - id: check-hooks-apply\n              - id: check-useless-excludes\n              - id: identity\n          - repo: local\n            hooks:\n              - id: match-no-files\n                name: match no files\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                files: ^nonexistent$\n              - id: useless-exclude\n                name: useless exclude\n                language: system\n                entry: python3 -c 'import sys; sys.exit(0)'\n                exclude: $nonexistent^\n    \"})?;\n\n    app.child(\"file.txt\").write_str(\"Hello, world!\\n\")?;\n    app.child(\"valid.json\").write_str(\"{}\")?;\n    app.child(\"invalid.json\").write_str(\"{x}\")?;\n    app.child(\"main.py\").write_str(r#\"print \"abc\"  \"#)?;\n\n    context.write_pre_commit_config(\"repos: []\");\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Running hooks for `app`:\n    Check hooks apply........................................................Failed\n    - hook id: check-hooks-apply\n    - exit code: 1\n\n      match-no-files does not apply to this repository\n    Check useless excludes...................................................Failed\n    - hook id: check-useless-excludes\n    - exit code: 1\n\n      The exclude pattern `regex: $nonexistent^` for `useless-exclude` does not match any files\n    identity.................................................................Passed\n    - hook id: identity\n    - duration: [TIME]\n\n      file.txt\n      .pre-commit-config.yaml\n      valid.json\n      invalid.json\n      main.py\n    match no files.......................................(no files to check)Skipped\n    useless exclude..........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn check_useless_excludes_workspace_paths_are_project_relative() -> anyhow::Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Workspace layout:\n    // - Root project has no hooks.\n    // - Nested project `app/` runs `check-useless-excludes`.\n    //\n    // Regression: in workspace mode, `files`/`exclude` matching must use paths *relative to the\n    // nested project root* (so anchored patterns like `^...$` work as expected).\n    let app = context.work_dir().child(\"app\");\n    app.create_dir_all()?;\n    app.child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc::indoc! {r\"\n        exclude: '^global_excluded$'\n        repos:\n          - repo: meta\n            hooks:\n              - id: check-useless-excludes\n          - repo: local\n            hooks:\n              - id: ok\n                name: ok\n                language: system\n                entry: python3 -c 'import sys; sys.exit(0)'\n                exclude: '^hook_excluded$'\n        \"})?;\n\n    // These files exist specifically so the anchored patterns above are NOT useless.\n    // If the meta hook mistakenly matches against `app/<name>` instead of `<name>`, it will fail.\n    app.child(\"global_excluded\").write_str(\"ignored\\n\")?;\n    app.child(\"hook_excluded\").write_str(\"ignored\\n\")?;\n\n    context.write_pre_commit_config(\"repos: []\");\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"check-useless-excludes\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `app`:\n    Check useless excludes...................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/run.rs",
    "content": "use std::path::Path;\n\nuse anyhow::Result;\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::prelude::*;\nuse insta::assert_snapshot;\nuse predicates::prelude::predicate;\nuse prek_consts::env_vars::EnvVars;\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML};\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\nmod common;\n\n#[test]\nfn run_basic() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n              - id: end-of-file-fixer\n              - id: check-json\n    \"});\n\n    // Create a repository with some files.\n    cwd.child(\"file.txt\").write_str(\"Hello, world!\\n\")?;\n    cwd.child(\"valid.json\").write_str(\"{}\")?;\n    cwd.child(\"invalid.json\").write_str(\"{}\")?;\n    cwd.child(\"main.py\").write_str(r#\"print \"abc\"  \"#)?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trim trailing whitespace.................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing main.py\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing valid.json\n      Fixing invalid.json\n      Fixing main.py\n    check json...............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"trailing-whitespace\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    trim trailing whitespace.................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn run_glob_patterns_with_multiple_hooks() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: echo-py\n                name: echo-py\n                entry: python3 -c \"import sys; print('PY:' + ' '.join(sys.argv[2:]))\" _\n                language: system\n                files:\n                  glob: src/**/*.py\n                verbose: true\n              - id: echo-md\n                name: echo-md\n                entry: python3 -c \"import sys; print('MD:' + ' '.join(sys.argv[2:]))\" _\n                language: system\n                files:\n                  glob: \"**/*.md\"\n                verbose: true\n    \"#});\n\n    let src_dir = cwd.child(\"src\");\n    src_dir.create_dir_all()?;\n    src_dir.child(\"main.py\").write_str(\"print('hi')\")?;\n    cwd.child(\"docs\").create_dir_all()?;\n    cwd.child(\"docs/readme.md\").write_str(\"# Docs\")?;\n    cwd.child(\"notes.txt\").write_str(\"note\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo-py..................................................................Passed\n    - hook id: echo-py\n    - duration: [TIME]\n\n      PY:src/main.py\n    echo-md..................................................................Passed\n    - hook id: echo-md\n    - duration: [TIME]\n\n      MD:docs/readme.md\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn run_in_non_git_repo() {\n    let context = TestContext::new();\n\n    let mut filters = context.filters();\n    filters.push((r\"exit code: \", \"exit status: \"));\n\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Command `get git root` exited with an error:\n\n    [status]\n    exit status: 128\n\n    [stderr]\n    fatal: not a git repository (or any of the parent directories): .git\n    \");\n}\n\n#[test]\nfn invalid_config() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(\"invalid: config\");\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: error: line 1 column 1: missing field `repos`\n     --> <input>:1:1\n      |\n    1 | invalid: config\n      | ^ missing field `repos`\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        files: 12\n        repos: []\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      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\n     --> <input>:1:1\n      |\n    1 | files: 12\n      | ^ invalid type: integer `12`, expected a regex string or a mapping with `glob` set to a string or list of strings\n    2 | repos: []\n      |\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        files:\n          glog: \"*.rs\"\n        repos: []\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: error: line 2 column 3: unknown field `glog`, expected one of glob\n     --> <input>:2:3\n      |\n    1 | files:\n    2 |   glog: \\\"*.rs\\\"\n      |   ^ unknown field `glog`, expected one of glob\n    3 | repos: []\n      |\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: dotnet\n                additional_dependencies: [\"dotnet@6\"]\n                entry: echo Hello, world!\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to init hooks\n      caused by: Invalid hook `trailing-whitespace`\n      caused by: Hook specified `additional_dependencies: dotnet@6` but the language `dotnet` does not support installing dependencies for now\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: fail\n                language_version: '6'\n                entry: echo Hello, world!\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to init hooks\n      caused by: Invalid hook `trailing-whitespace`\n      caused by: Hook specified `language_version: 6` but the language `fail` does not support toolchain installation for now\n    \");\n}\n\n/// Use same repo multiple times, with same or different revisions.\n#[test]\nfn same_repo() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v4.6.0\n            hooks:\n              - id: trailing-whitespace\n    \"});\n\n    cwd.child(\"file.txt\").write_str(\"Hello, world!\\n\")?;\n    cwd.child(\"valid.json\").write_str(\"{}\")?;\n    cwd.child(\"invalid.json\").write_str(\"{}\")?;\n    cwd.child(\"main.py\").write_str(r#\"print \"abc\"  \"#)?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trim trailing whitespace.................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing main.py\n    trim trailing whitespace.................................................Passed\n    trim trailing whitespace.................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn local() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: system\n                entry: echo Hello, world!\n                always_run: true\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local....................................................................Passed\n\n    ----- stderr -----\n    \"#);\n}\n\n/// Test multiple hook IDs scenarios.\n#[test]\nfn multiple_hook_ids() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: hook1\n                name: First Hook\n                language: system\n                entry: echo hook1\n              - id: hook2\n                name: Second Hook\n                language: system\n                entry: echo hook2\n              - id: shared-name\n                name: Shared Hook A\n                language: system\n                entry: echo shared-a\n              - id: shared-name-2\n                name: Shared Hook B\n                language: system\n                entry: echo shared-b\n                alias: shared-name\n    \"});\n\n    context.git_add(\".\");\n\n    // Multiple repeated hook-id (should deduplicate)\n    cmd_snapshot!(context.filters(), context.run().arg(\"hook1\").arg(\"hook1\").arg(\"hook1\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    First Hook...............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    // Hook-id that matches multiple hooks (by alias)\n    cmd_snapshot!(context.filters(), context.run().arg(\"shared-name\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Shared Hook A............................................................Passed\n    Shared Hook B............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    // Hook-id matches nothing\n    cmd_snapshot!(context.filters(), context.run().arg(\"nonexistent-hook\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    warning: selector `nonexistent-hook` did not match any hooks\n    error: No hooks found after filtering with the given selectors\n    \");\n\n    // Multiple hook_ids match nothing\n    cmd_snapshot!(context.filters(), context.run().arg(\"nonexistent-hook\").arg(\"nonexistent-hook\").arg(\"nonexistent-hook-2\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    warning: the following selectors did not match any hooks or projects:\n      - `nonexistent-hook`\n      - `nonexistent-hook-2`\n    error: No hooks found after filtering with the given selectors\n    \");\n\n    // Hook-id matches one hook\n    cmd_snapshot!(context.filters(), context.run().arg(\"hook2\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Second Hook..............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    // Multiple hook-ids with mixed results (some exist, some don't)\n    cmd_snapshot!(context.filters(), context.run().arg(\"hook1\").arg(\"nonexistent\").arg(\"hook2\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    First Hook...............................................................Passed\n    Second Hook..............................................................Passed\n\n    ----- stderr -----\n    warning: selector `nonexistent` did not match any hooks\n    \");\n\n    // Multiple valid hook-ids\n    cmd_snapshot!(context.filters(), context.run().arg(\"hook1\").arg(\"hook2\").arg(\"nonexistent-hook\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    First Hook...............................................................Passed\n    Second Hook..............................................................Passed\n\n    ----- stderr -----\n    warning: selector `nonexistent-hook` did not match any hooks\n    \");\n\n    // Multiple hook-ids with some duplicates and aliases\n    cmd_snapshot!(context.filters(), context.run().arg(\"hook1\").arg(\"shared-name\").arg(\"hook1\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    First Hook...............................................................Passed\n    Shared Hook A............................................................Passed\n    Shared Hook B............................................................Passed\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn priorities_respected() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: late\n                name: Late Hook\n                language: system\n                entry: python3 -c \"print('late')\"\n                always_run: true\n                priority: 10\n              - id: early\n                name: Early Hook\n                language: system\n                entry: python3 -c \"print('early')\"\n                always_run: true\n                priority: 0\n              - id: middle\n                name: Middle Hook\n                language: system\n                entry: python3 -c \"print('middle')\"\n                always_run: true\n                priority: 5\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Early Hook...............................................................Passed\n    Middle Hook..............................................................Passed\n    Late Hook................................................................Passed\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn priority_fail_fast_stops_later_groups() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: fail-fast\n                name: Failing Hook\n                language: system\n                entry: python3 -c \"import sys; sys.exit(1)\"\n                always_run: true\n                priority: 5\n                fail_fast: true\n              - id: sibling\n                name: Same Priority Sibling\n                language: system\n                entry: python3 -c \"import time; time.sleep(0.2)\"\n                always_run: true\n                priority: 5\n              - id: later\n                name: Later Hook\n                language: system\n                entry: python3 -c \"print('later ran')\"\n                always_run: true\n                priority: 10\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Failing Hook.............................................................Failed\n    - hook id: fail-fast\n    - exit code: 1\n    Same Priority Sibling....................................................Passed\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn priority_group_modified_files_is_group_failure_and_output_is_indented() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"file.txt\").write_str(\"hello\\n\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: modify\n                name: Modifies File\n                language: system\n                entry: python3 -c \"from pathlib import Path; p = Path('file.txt'); p.write_text(p.read_text() + 'x')\"\n                always_run: true\n                verbose: true\n                priority: 0\n              - id: loud\n                name: Prints Output\n                language: system\n                entry: python3 -c \"print('hello from loud')\"\n                always_run: true\n                verbose: true\n                priority: 0\n              - id: quiet\n                name: No Output\n                language: system\n                entry: python3 -c \"import time; time.sleep(0.1)\"\n                always_run: true\n                priority: 0\n              - id: later\n                name: Later Hook\n                language: system\n                entry: python3 -c \"print('later ran')\"\n                always_run: true\n                verbose: true\n                priority: 10\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Files were modified by following hooks...................................Failed\n      ┌ Modifies File........................................................Passed\n      │ - hook id: modify\n      │ - duration: [TIME]\n      │ Prints Output........................................................Passed\n      │ - hook id: loud\n      │ - duration: [TIME]\n      │\n      │ hello from loud\n      └ No Output............................................................Passed\n    Later Hook...............................................................Passed\n    - hook id: later\n    - duration: [TIME]\n\n      later ran\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// `.pre-commit-config.yaml` is not staged.\n#[test]\nfn config_not_staged() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.work_dir().child(PRE_COMMIT_CONFIG_YAML).touch()?;\n    context.git_add(\".\");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -V\n    \"});\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"invalid-hook-id\"), @r#\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: prek configuration file is not staged, run `git add .pre-commit-config.yaml` to stage it\n    \"#);\n\n    Ok(())\n}\n\n/// `.pre-commit-config.yaml` outside the repository should not be checked.\n#[test]\nfn config_outside_repo() -> Result<()> {\n    let context = TestContext::new();\n\n    // Initialize a git repository in ./work.\n    let root = context.work_dir().child(\"work\");\n    root.create_dir_all()?;\n    git_cmd(&root).arg(\"init\").assert().success();\n\n    // Create a configuration file in . (outside the repository).\n    context\n        .work_dir()\n        .child(\"c.yaml\")\n        .write_str(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'print(\"Hello world\")'\n    \"#})?;\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(&root).arg(\"-c\").arg(\"../c.yaml\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    trailing-whitespace..................................(no files to check)Skipped\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n/// Test the output format for a hook with a CJK name.\n#[test]\nfn cjk_hook_name() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: 去除行尾空格\n                language: system\n                entry: python3 -V\n              - id: end-of-file-fixer\n                name: fix end of files\n                language: system\n                entry: python3 -V\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    去除行尾空格.............................................................Passed\n    fix end of files.........................................................Passed\n\n    ----- stderr -----\n    \"#);\n}\n\n/// Skips hooks based on the `SKIP` environment variable.\n#[test]\nfn skips() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c \"exit(1)\"\n              - id: end-of-file-fixer\n                name: fix end of files\n                language: system\n                entry: python3 -c \"exit(1)\"\n              - id: check-json\n                name: check json\n                language: system\n                entry: python3 -c \"exit(1)\"\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"SKIP\", \"end-of-file-fixer\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    check json...............................................................Failed\n    - hook id: check-json\n    - exit code: 1\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"SKIP\", \"trailing-whitespace,end-of-file-fixer\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    check json...............................................................Failed\n    - hook id: check-json\n    - exit code: 1\n\n    ----- stderr -----\n    \");\n}\n\n/// Run hooks with matched `stage`.\n#[test]\nfn stage() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: manual-stage\n                name: manual-stage\n                language: system\n                entry: echo manual-stage\n                stages: [ manual ]\n              # Defaults to all stages.\n              - id: default-stage\n                name: default-stage\n                language: system\n                entry: echo default-stage\n              - id: post-commit-stage\n                name: post-commit-stage\n                language: system\n                entry: echo post-commit-stage\n                stages: [ post-commit ]\n    \"});\n    context.git_add(\".\");\n\n    // By default, run hooks with `pre-commit` stage.\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    default-stage............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    // Run hooks with `manual` stage.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--hook-stage\").arg(\"manual\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    manual-stage.............................................................Passed\n    default-stage............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    // Run hooks with `post-commit` stage.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--hook-stage\").arg(\"post-commit\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    default-stage........................................(no files to check)Skipped\n    post-commit-stage....................................(no files to check)Skipped\n\n    ----- stderr -----\n    \"#);\n}\n\n#[test]\nfn fallback_to_manual_stage() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: manual-only\n                name: manual-only\n                language: system\n                entry: echo manual-only\n                stages: [ manual ]\n              - id: another-manual\n                name: another-manual\n                language: system\n                entry: echo another-manual\n                stages: [ manual ]\n              - id: default-stage\n                name: default-stage\n                language: system\n                entry: echo default-stage\n              - id: pre-push\n                name: pre-push\n                language: system\n                entry: echo pre-push\n                stages: [ pre-push ]\n    \"});\n    context.git_add(\".\");\n\n    // With pre-commit hooks present, default `prek run` stays on pre-commit.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    default-stage............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Explicit `--hook-stage pre-commit` keeps execution scoped to that stage.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--hook-stage\").arg(\"pre-commit\").arg(\"default-stage\").arg(\"manual-only\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    default-stage............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Selecting manual + pre-commit hooks still runs only the pre-commit ones.\n    cmd_snapshot!(context.filters(), context.run().arg(\"manual-only\").arg(\"default-stage\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    default-stage............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Selecting only manual hooks should still succeed via fallback.\n    cmd_snapshot!(context.filters(), context.run().arg(\"manual-only\").arg(\"another-manual\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    manual-only..............................................................Passed\n    another-manual...........................................................Passed\n\n    ----- stderr -----\n    \");\n\n    // Mixing `pre-push` and manual selectors still runs the manual hook via fallback.\n    cmd_snapshot!(context.filters(), context.run().arg(\"pre-push\").arg(\"manual-only\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    manual-only..............................................................Passed\n\n    ----- stderr -----\n    \");\n}\n\n/// Test global `files`, `exclude`, and hook level `files`, `exclude`.\n#[test]\nfn files_and_exclude() -> Result<()> {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"file.txt\").write_str(\"Hello, world!  \\n\")?;\n    cwd.child(\"valid.json\").write_str(\"{}\\n  \")?;\n    cwd.child(\"invalid.json\").write_str(\"{}\")?;\n    cwd.child(\"main.py\").write_str(r#\"print \"abc\"  \"#)?;\n\n    // Global files and exclude.\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        files: file.txt\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                types: [text]\n              - id: end-of-file-fixer\n                name: fix end of files\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                types: [text]\n              - id: check-json\n                name: check json\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                types: [json]\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      ['file.txt']\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n\n      ['file.txt']\n    check json...........................................(no files to check)Skipped\n\n    ----- stderr -----\n    \");\n\n    // Override hook level files and exclude.\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        files: file.txt\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                files: valid.json\n              - id: end-of-file-fixer\n                name: fix end of files\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                exclude: (valid.json|main.py)\n              - id: check-json\n                name: check json\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing whitespace..................................(no files to check)Skipped\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n\n      ['file.txt']\n    check json...............................................................Failed\n    - hook id: check-json\n    - exit code: 1\n\n      ['file.txt']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test selecting files by type, `types`, `types_or`, and `exclude_types`.\n#[test]\nfn file_types() -> Result<()> {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    let cwd = context.work_dir();\n    cwd.child(\"file.txt\").write_str(\"Hello, world!  \")?;\n    cwd.child(\"json.json\").write_str(\"{}\\n  \")?;\n    cwd.child(\"main.py\").write_str(r#\"print \"abc\"  \"#)?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                types: [\"json\"]\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                types_or: [\"json\", \"python\"]\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                exclude_types: [\"json\"]\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'\n                types: [\"json\" ]\n                exclude_types: [\"json\"]\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      ['json.json']\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      ['main.py', 'json.json']\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      ['file.txt', '.pre-commit-config.yaml', 'main.py']\n    trailing-whitespace..................................(no files to check)Skipped\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Abort the run if a hook fails.\n#[test]\nfn fail_fast() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'print(\"Fixing files\"); exit(1)'\n                always_run: true\n                fail_fast: false\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'print(\"Fixing files\"); exit(1)'\n                always_run: true\n                fail_fast: true\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -V\n                always_run: true\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -V\n                always_run: true\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      Fixing files\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      Fixing files\n\n    ----- stderr -----\n    \");\n}\n\n/// Test --fail-fast CLI flag stops execution after first failure.\n#[test]\nfn fail_fast_cli_flag() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: failing-hook\n                name: failing-hook\n                language: system\n                entry: python3 -c 'print(\"Failed\"); exit(1)'\n                always_run: true\n              - id: passing-hook\n                name: passing-hook\n                language: system\n                entry: python3 -c 'print(\"Passed\")'\n                always_run: true\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    failing-hook.............................................................Failed\n    - hook id: failing-hook\n    - exit code: 1\n\n      Failed\n    passing-hook.............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--fail-fast\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    failing-hook.............................................................Failed\n    - hook id: failing-hook\n    - exit code: 1\n\n      Failed\n\n    ----- stderr -----\n    \");\n}\n\n/// Run from a subdirectory. File arguments should be fixed to be relative to the root.\n#[test]\nfn subdirectory() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    let child = cwd.child(\"foo/bar/baz\");\n    child.create_dir_all()?;\n    child.child(\"file.txt\").write_str(\"Hello, world!\\n\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sys.argv[1]); exit(1)'\n                always_run: true\n    \"});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(&child).arg(\"--files\").arg(\"file.txt\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      foo/bar/baz/file.txt\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--cd\").arg(&*child).arg(\"--files\").arg(\"file.txt\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n      foo/bar/baz/file.txt\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test hook `log_file` option.\n#[test]\nfn log_file() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'print(\"Fixing files\"); exit(1)'\n                always_run: true\n                log_file: log.txt\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trailing-whitespace......................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n\n    ----- stderr -----\n    \"#);\n\n    let log = context.read(\"log.txt\");\n    assert_eq!(log, \"Fixing files\");\n}\n\n/// Pass pre-commit environment variables to the hook.\n#[test]\nfn pass_env_vars() {\n    let context = TestContext::new();\n\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: env-vars\n                name: Pass environment\n                language: system\n                entry: python3 -c \"import os, sys; print(os.getenv('PRE_COMMIT')); sys.exit(1)\"\n                always_run: true\n    \"#});\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Pass environment.........................................................Failed\n    - hook id: env-vars\n    - exit code: 1\n\n      1\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn staged_files_only() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'print(open(\"file.txt\", \"rt\").read())'\n                verbose: true\n                types: [text]\n   \"#});\n\n    context\n        .work_dir()\n        .child(\"file.txt\")\n        .write_str(\"Hello, world!\")?;\n    context.git_add(\".\");\n\n    // Non-staged files should be stashed and restored.\n    context\n        .work_dir()\n        .child(\"file.txt\")\n        .write_str(\"Hello world again!\")?;\n\n    let filters: Vec<_> = context\n        .filters()\n        .into_iter()\n        .chain([(r\"/\\d+-\\d+.patch\", \"/[TIME]-[PID].patch\")])\n        .collect();\n\n    cmd_snapshot!(filters, context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    trailing-whitespace......................................................Passed\n    - hook id: trailing-whitespace\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    Unstaged changes detected, stashing unstaged changes to `[HOME]/patches/[TIME]-[PID].patch`\n    Restored working tree changes from `[HOME]/patches/[TIME]-[PID].patch`\n    \");\n\n    let content = context.read(\"file.txt\");\n    assert_snapshot!(content, @\"Hello world again!\");\n\n    Ok(())\n}\n\n#[cfg(unix)]\n#[test]\nfn restore_on_interrupt() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    // The hook will sleep for 3 seconds.\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import time; open(\"out.txt\", \"wt\").write(open(\"file.txt\", \"rt\").read()); time.sleep(10)'\n                verbose: true\n                types: [text]\n   \"#});\n\n    context\n        .work_dir()\n        .child(\"file.txt\")\n        .write_str(\"Hello, world!\")?;\n    context.git_add(\".\");\n\n    // Non-staged files should be stashed and restored.\n    context\n        .work_dir()\n        .child(\"file.txt\")\n        .write_str(\"Hello world again!\")?;\n\n    let mut child = context.run().spawn()?;\n    let child_id = child.id();\n\n    // Send an interrupt signal to the process.\n    let handle = std::thread::spawn(move || {\n        std::thread::sleep(std::time::Duration::from_secs(1));\n        #[allow(clippy::cast_possible_wrap)]\n        unsafe {\n            libc::kill(child_id as i32, libc::SIGINT)\n        };\n    });\n\n    handle.join().unwrap();\n    child.wait()?;\n\n    let content = context.read(\"out.txt\");\n    assert_snapshot!(content, @\"Hello, world!\");\n\n    let content = context.read(\"file.txt\");\n    assert_snapshot!(content, @\"Hello world again!\");\n\n    Ok(())\n}\n\n/// When in merge conflict, runs on files that have conflicts fixed.\n#[test]\nfn merge_conflicts() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a merge conflict.\n    let cwd = context.work_dir();\n    cwd.child(\"file.txt\").write_str(\"Hello, world!\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    git_cmd(cwd)\n        .arg(\"checkout\")\n        .arg(\"-b\")\n        .arg(\"feature\")\n        .assert()\n        .success();\n    cwd.child(\"file.txt\").write_str(\"Hello, world again!\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Feature commit\");\n\n    git_cmd(cwd)\n        .arg(\"checkout\")\n        .arg(\"master\")\n        .assert()\n        .success();\n    cwd.child(\"file.txt\")\n        .write_str(\"Hello, world from master!\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Master commit\");\n\n    git_cmd(cwd).arg(\"merge\").arg(\"feature\").assert().code(1);\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: trailing-whitespace\n                name: trailing-whitespace\n                language: system\n                entry: python3 -c 'import sys; print(sorted(sys.argv[1:]))'\n                verbose: true\n    \"});\n\n    // Abort on merge conflicts.\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: You have unmerged paths. Resolve them before running prek\n    \"#);\n\n    // Fix the conflict and run again.\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    trailing-whitespace......................................................Passed\n    - hook id: trailing-whitespace\n    - duration: [TIME]\n\n      ['.pre-commit-config.yaml', 'file.txt']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Local python hook with no additional dependencies.\n#[test]\nfn local_python_hook() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-python-hook\n                name: local-python-hook\n                language: python\n                entry: python3 -c 'import sys; print(\"Hello, world!\"); sys.exit(1)'\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    local-python-hook........................................................Failed\n    - hook id: local-python-hook\n    - exit code: 1\n\n      Hello, world!\n\n    ----- stderr -----\n    \");\n}\n\n/// Invalid `entry`\n#[test]\nfn invalid_entry() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: entry\n                name: entry\n                language: python\n                entry: '\"'\n    \"#});\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to run hook `entry`\n      caused by: Invalid hook `entry`\n      caused by: Failed to parse entry `\"` as commands\n    \"#);\n}\n\n/// Initialize a repo that does not exist.\n#[test]\nfn init_nonexistent_repo() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://notexistentatallnevergonnahappen.com/nonexistent/repo\n            rev: v1.0.0\n            hooks:\n              - id: nonexistent\n                name: nonexistent\n        \"});\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(r\"exit code: \", \"exit status: \"),\n            // Normalize Git error message to handle environment-specific variations\n            (\n                r\"fatal: unable to access 'https://notexistentatallnevergonnahappen\\.com/nonexistent/repo/':.*\",\n                r\"fatal: unable to access 'https://notexistentatallnevergonnahappen.com/nonexistent/repo/': [error]\"\n            ),\n        ])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to init hooks\n      caused by: Failed to clone repo `https://notexistentatallnevergonnahappen.com/nonexistent/repo`\n      caused by: Command `git full clone` exited with an error:\n\n    [status]\n    exit status: 128\n\n    [stderr]\n    fatal: unable to access 'https://notexistentatallnevergonnahappen.com/nonexistent/repo/': [error]\n    \");\n}\n\n/// Test hooks that specifies `types: [directory]`.\n#[test]\nfn types_directory() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: directory\n                name: directory\n                language: system\n                entry: echo\n                types: [directory]\n        \"});\n    context.work_dir().child(\"dir\").create_dir_all()?;\n    context\n        .work_dir()\n        .child(\"dir/file.txt\")\n        .write_str(\"Hello, world!\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory............................................(no files to check)Skipped\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--files\").arg(\"dir\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory............................................(no files to check)Skipped\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--files\").arg(\"non-exist-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory............................................(no files to check)Skipped\n\n    ----- stderr -----\n    warning: This file does not exist and will be ignored: `non-exist-files`\n    \");\n    Ok(())\n}\n\n#[test]\nfn run_last_commit() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n              - id: end-of-file-fixer\n    \"});\n\n    // Create initial files and make first commit\n    cwd.child(\"file1.txt\").write_str(\"Hello, world!\\n\")?;\n    cwd.child(\"file2.txt\")\n        .write_str(\"Initial content with trailing spaces   \\n\")?; // This has issues but won't be in last commit\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Modify files and make second commit with trailing whitespace\n    cwd.child(\"file1.txt\").write_str(\"Hello, world!   \\n\")?; // trailing whitespace\n    cwd.child(\"file3.txt\").write_str(\"New file\")?; // missing newline\n    // Note: file2.txt is NOT modified in this commit, so it should be filtered out by --last-commit\n    context.git_add(\".\");\n    context.git_commit(\"Second commit with issues\");\n\n    // Run with --last-commit should only check files from the last commit\n    // This should only process file1.txt and file3.txt, NOT file2.txt\n    cmd_snapshot!(context.filters(), context.run().arg(\"--last-commit\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trim trailing whitespace.................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing file1.txt\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing file3.txt\n\n    ----- stderr -----\n    \");\n\n    // Now reset the files to their problematic state for comparison\n    cwd.child(\"file1.txt\").write_str(\"Hello, world!   \\n\")?; // trailing whitespace\n    cwd.child(\"file3.txt\").write_str(\"New file\")?; // missing newline\n\n    // Run with --all-files should check ALL files including file2.txt\n    // This demonstrates that file2.txt was indeed filtered out in the previous test\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    trim trailing whitespace.................................................Failed\n    - hook id: trailing-whitespace\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing file1.txt\n      Fixing file2.txt\n    fix end of files.........................................................Failed\n    - hook id: end-of-file-fixer\n    - exit code: 1\n    - files were modified by this hook\n\n      Fixing file3.txt\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test `prek run --files` with multiple files.\n#[test]\nfn run_multiple_files() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: multiple-files\n                name: multiple-files\n                language: system\n                entry: echo\n                verbose: true\n                types: [text]\n    \"});\n    let cwd = context.work_dir();\n    cwd.child(\"file1.txt\").write_str(\"Hello, world!\")?;\n    cwd.child(\"file2.txt\").write_str(\"Hello, world!\")?;\n    context.git_add(\".\");\n    // `--files` with multiple files\n    cmd_snapshot!(context.filters(), context.run().arg(\"--files\").arg(\"file1.txt\").arg(\"file2.txt\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    multiple-files...........................................................Passed\n    - hook id: multiple-files\n    - duration: [TIME]\n\n      file2.txt file1.txt\n\n    ----- stderr -----\n    \");\n    Ok(())\n}\n\n/// Test `prek run --files` with no files.\n#[test]\nfn run_no_files() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: no-files\n                name: no-files\n                language: system\n                entry: echo\n                verbose: true\n    \"});\n    context.git_add(\".\");\n    // `--files` with no files\n    cmd_snapshot!(context.filters(), context.run().arg(\"--files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    no-files.................................................................Passed\n    - hook id: no-files\n    - duration: [TIME]\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n}\n\n/// Test `prek run --directory` flags.\n#[test]\nfn run_directory() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: directory\n                name: directory\n                language: system\n                entry: echo\n                verbose: true\n    \"});\n\n    let cwd = context.work_dir();\n    cwd.child(\"dir1\").create_dir_all()?;\n    cwd.child(\"dir1/file.txt\").write_str(\"Hello, world!\")?;\n    cwd.child(\"dir2\").create_dir_all()?;\n    cwd.child(\"dir2/file.txt\").write_str(\"Hello, world!\")?;\n    context.git_add(\".\");\n\n    // one `--directory`\n    cmd_snapshot!(context.filters(), context.run().arg(\"--directory\").arg(\"dir1\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir1/file.txt\n\n    ----- stderr -----\n    \");\n\n    // repeated `--directory`\n    cmd_snapshot!(context.filters(), context.run().arg(\"--directory\").arg(\"dir1\").arg(\"--directory\").arg(\"dir1\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir1/file.txt\n\n    ----- stderr -----\n    \");\n\n    // multiple `--directory`\n    cmd_snapshot!(context.filters(), context.run().arg(\"--directory\").arg(\"dir1\").arg(\"--directory\").arg(\"dir2\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir2/file.txt dir1/file.txt\n\n    ----- stderr -----\n    \");\n\n    // non-existing directory\n    cmd_snapshot!(context.filters(), context.run().arg(\"--directory\").arg(\"non-existing-dir\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory............................................(no files to check)Skipped\n\n    ----- stderr -----\n    \");\n\n    // `--directory` with `--files`\n    cmd_snapshot!(context.filters(), context.run().arg(\"--directory\").arg(\"dir1\").arg(\"--files\").arg(\"dir1/file.txt\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir1/file.txt\n\n    ----- stderr -----\n    \");\n    cmd_snapshot!(context.filters(), context.run().arg(\"--directory\").arg(\"dir1\").arg(\"--files\").arg(\"dir2/file.txt\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir2/file.txt dir1/file.txt\n\n    ----- stderr -----\n    \");\n\n    // run `--directory` inside a subdirectory\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join(\"dir1\")).arg(\"--directory\").arg(\".\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir1/file.txt\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--cd\").arg(\"dir1\").arg(\"--directory\").arg(\".\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    directory................................................................Passed\n    - hook id: directory\n    - duration: [TIME]\n\n      dir1/file.txt\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test `minimum_prek_version` option.\n#[test]\nfn minimum_prek_version() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        minimum_prek_version: 10.0.0\n        repos:\n          - repo: local\n            hooks:\n              - id: directory\n                name: directory\n                language: system\n                entry: echo\n                verbose: true\n    \"});\n    context.git_add(\".\");\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\n            r\"current version `\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z]+(?:\\.[0-9A-Za-z]+)*)?`\",\n            \"current version `[CURRENT_VERSION]`\",\n        )])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      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\n     --> <input>:1:23\n      |\n    1 | minimum_prek_version: 10.0.0\n      |                       ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek\n    2 | repos:\n    3 |   - repo: local\n      |\n    \");\n}\n\n/// Run hooks that would echo color.\n#[test]\n#[cfg(not(windows))]\nfn color() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n      repos:\n        - repo: local\n          hooks:\n            - id: color\n              name: color\n              language: python\n              entry: python ./color.py\n              verbose: true\n              pass_filenames: false\n  \"});\n\n    let script = indoc::indoc! {r\"\n      import sys\n      if sys.stdout.isatty():\n          print('\\033[1;32mHello, world!\\033[0m')\n      else:\n          print('Hello, world!')\n      sys.stdout.flush()\n  \"};\n    context.work_dir().child(\"color.py\").write_str(script)?;\n\n    context.git_add(\".\");\n\n    // Run default. In integration tests, we don't have a TTY.\n    // So this prints without color.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    color....................................................................Passed\n    - hook id: color\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    \");\n\n    // Force color output\n    cmd_snapshot!(context.filters(), context.run().arg(\"--color=always\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    color....................................................................\u001b[42mPassed\u001b[49m\n    \u001b[2m- hook id: color\u001b[0m\n    \u001b[2m- duration: [TIME]\u001b[0m\n\n      \u001b[1;32mHello, world!\u001b[0m\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test running hook whose `entry` is script with shebang on Windows.\n#[test]\nfn shebang_script() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a script with shebang.\n    let script = indoc::indoc! {r\"\n        #!/usr/bin/env python\n        import sys\n        print('Hello, world!')\n        sys.exit(0)\n    \"};\n    context.work_dir().child(\"script.py\").write_str(script)?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n      repos:\n        - repo: local\n          hooks:\n            - id: shebang-script\n              name: shebang-script\n              language: python\n              entry: script.py\n              verbose: true\n              pass_filenames: false\n              always_run: true\n    \"});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    shebang-script...........................................................Passed\n    - hook id: shebang-script\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test `git commit -a` works without `.git/index.lock exists` error.\n#[test]\nfn git_commit_a() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: echo\n                name: echo\n                language: system\n                entry: echo\n                verbose: true\n    \"});\n\n    // Create a file and commit it.\n    let cwd = context.work_dir();\n    let file = cwd.child(\"file.txt\");\n    file.write_str(\"Hello, world!\\n\")?;\n\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Edit the file\n    file.write_str(\"Hello, world again!\\n\")?;\n\n    let mut commit = git_cmd(cwd);\n    commit\n        .arg(\"commit\")\n        .arg(\"-a\")\n        .arg(\"-m\")\n        .arg(\"Update file\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir());\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([\n            (r\"\\[master \\w{7}\\]\", r\"[master COMMIT]\"),\n            (\"7c8398204bbc95c33a6d2543f86a27621647cf78\", \"[HASH]\"),\n        ])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, commit, @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    [master COMMIT] Update file\n     1 file changed, 1 insertion(+), 1 deletion(-)\n\n    ----- stderr -----\n    echo.....................................................................Passed\n    - hook id: echo\n    - duration: [TIME]\n\n      file.txt\n    \");\n\n    Ok(())\n}\n\n#[cfg(unix)]\n#[test]\nfn git_commit_a_currently_fails_when_hook_writes_to_temp_git_index() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Repro for #1786 documenting the current behavior.\n    // `git commit -a` exports `GIT_INDEX_FILE=.git/index.lock` to the pre-commit hook\n    // process. If the hook inherits that env var and then runs a git command that writes\n    // to an index in a different repository, Git will write those entries into the parent\n    // repo's temporary index instead.\n    //\n    // The important detail is that the temp repo stages `file.txt`, matching a tracked\n    // path in the parent repo. That makes prek's post-hook `git diff` read the corrupted\n    // parent index entry and fail with `fatal: unable to read <hash>`.\n    context\n        .work_dir()\n        .child(\"hook.sh\")\n        .write_str(indoc::indoc! {r#\"\n        set -eu\n        tmpdir=\"$(mktemp -d)\"\n        trap 'rm -rf \"$tmpdir\"' EXIT\n        cd \"$tmpdir\"\n        git init >/dev/null 2>&1\n        printf 'hook version\\n' > file.txt\n        git add file.txt\n    \"#})?;\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: write-temp-index\n                name: write-temp-index\n                language: system\n                entry: sh hook.sh\n                pass_filenames: false\n                always_run: true\n                verbose: true\n    \"});\n\n    let cwd = context.work_dir();\n    let file = cwd.child(\"file.txt\");\n    file.write_str(\"Hello, world!\\n\")?;\n\n    cmd_snapshot!(context.filters(), context.install(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek installed at `.git/hooks/pre-commit`\n\n    ----- stderr -----\n    \"#);\n\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // `git commit` does not set `GIT_INDEX_FILE`; `git commit -a` does.\n    // The repro only triggers on the `-a` path.\n    file.write_str(\"Hello again!\\n\")?;\n\n    let mut commit = git_cmd(cwd);\n    commit\n        .arg(\"commit\")\n        .arg(\"-a\")\n        .arg(\"-m\")\n        .arg(\"Update file\")\n        .env(EnvVars::PREK_HOME, &**context.home_dir());\n\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([\n            (r\"\\[master \\w{7}\\]\", r\"[master COMMIT]\"),\n            (\n                r\"fatal: unable to read [0-9a-f]{40}\",\n                \"fatal: unable to read [HASH]\",\n            ),\n        ])\n        .collect::<Vec<_>>();\n\n    cmd_snapshot!(filters, commit, @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Command `git diff` exited with an error:\n\n    [status]\n    exit status: 128\n\n    [stderr]\n    fatal: unable to read [HASH]\n    \"\n    );\n\n    Ok(())\n}\n\nfn write_pre_commit_config(path: &Path, hooks: &[(&str, &str)]) -> Result<()> {\n    let mut yaml = String::from(indoc::indoc! {\"\n        repos:\n          - repo: local\n            hooks:\n    \"});\n    for (id, name) in hooks {\n        let hook = textwrap::indent(\n            &indoc::formatdoc! {\"\n        - id: {}\n          name: {}\n          entry: echo\n          language: system\n        \", id, name\n            },\n            \"      \",\n        );\n        yaml.push_str(&hook);\n    }\n\n    std::fs::create_dir_all(path)?;\n    std::fs::write(path.join(PRE_COMMIT_CONFIG_YAML), yaml)?;\n\n    Ok(())\n}\n\n#[cfg(unix)]\n#[test]\nfn selectors_completion() -> Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    // Root project with one hook\n    write_pre_commit_config(cwd, &[(\"root-hook\", \"Root Hook\")])?;\n\n    // Nested project at app/ with one hook\n    let app = cwd.join(\"app\");\n    write_pre_commit_config(&app, &[(\"app-hook\", \"App Hook\")])?;\n\n    // Deeper nested project at app/lib/ with one hook\n    let app_lib = app.join(\"lib\");\n    write_pre_commit_config(&app_lib, &[(\"lib-hook\", \"Lib Hook\")])?;\n\n    // Unrelated non-project dir should not appear in subdir suggestions\n    cwd.child(\"scratch\").create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    install\tInstall prek Git shims under the `.git/hooks/` directory\n    prepare-hooks\tPrepare environments for all hooks used in the config file\n    run\tRun hooks\n    list\tList hooks configured in the current workspace\n    uninstall\tUninstall prek Git shims\n    validate-config\tValidate configuration files (prek.toml or .pre-commit-config.yaml)\n    validate-manifest\tValidate `.pre-commit-hooks.yaml` files\n    sample-config\tProduce a sample configuration file (prek.toml or .pre-commit-config.yaml)\n    auto-update\tAuto-update the `rev` field of repositories in the config file to the latest version\n    cache\tManage the prek cache\n    try-repo\tTry the pre-commit hooks in the current repo\n    util\tUtility commands\n    self\t`prek` self management\n    app/\n    app:\n    app-hook\tApp Hook\n    lib-hook\tLib Hook\n    root-hook\tRoot Hook\n    --skip\tSkip the specified hooks or projects\n    --all-files\tRun on all files in the repo\n    --files\tSpecific filenames to run hooks on\n    --directory\tRun hooks on all files in the specified directories\n    --from-ref\tThe original ref in a `<from_ref>...<to_ref>` diff expression. Files changed in this diff will be run through the hooks\n    --to-ref\tThe destination ref in a `from_ref...to_ref` diff expression. Defaults to `HEAD` if `from_ref` is specified\n    --last-commit\tRun hooks against the last commit. Equivalent to `--from-ref HEAD~1 --to-ref HEAD`\n    --stage\tThe stage during which the hook is fired\n    --show-diff-on-failure\tWhen hooks fail, run `git diff` directly afterward\n    --fail-fast\tStop running hooks after the first failure\n    --dry-run\tDo not run the hooks, but print the hooks that would have been run\n    --config\tPath to alternate config file\n    --cd\tChange to directory before running\n    --color\tWhether to use color in output\n    --refresh\tRefresh all cached data\n    --help\tDisplay the concise help for this command\n    --no-progress\tHide all progress outputs\n    --quiet\tUse quiet output\n    --verbose\tUse verbose output\n    --log-file\tWrite trace logs to the specified file. If not specified, trace logs will be written to `$PREK_HOME/prek.log`\n    --version\tDisplay the prek version\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"ap\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app/\n    app:\n    app-hook\tApp Hook\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"app:\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app:app-hook\tApp Hook\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"app:app\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app:app-hook\tApp Hook\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"app/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app/lib/\n    app/lib:\n\n    ----- stderr -----\n    \");\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"app/li\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app/lib/\n    app/lib:\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"app/lib:\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app/lib:lib-hook\tLib Hook\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().env(\"COMPLETE\", \"fish\").arg(\"--\").arg(\"prek\").arg(\"app/lib/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    app/lib/\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test reusing hook environments only when dependencies are exactly same. (ignore order)\n#[test]\nfn reuse_env() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let pkg_dir = context.work_dir().child(\"local_pkg\");\n    pkg_dir.create_dir_all()?;\n    pkg_dir.child(\"setup.py\").write_str(indoc::indoc! {r#\"\n        from setuptools import setup\n\n        setup(\n            name=\"local-pkg\",\n            version=\"0.1.0\",\n            py_modules=[\"local_pkg\"],\n        )\n    \"#})?;\n    pkg_dir\n        .child(\"local_pkg.py\")\n        .write_str(\"def hello():\\n     print('hello')\\n\")?;\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n          - id: reuse-env\n            name: reuse-env\n            language: python\n            entry: python -c \"import local_pkg; local_pkg.hello()\"\n            pass_filenames: false\n            additional_dependencies: [\"./local_pkg\"]\n            verbose: true\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    reuse-env................................................................Passed\n    - hook id: reuse-env\n    - duration: [TIME]\n\n      hello\n\n    ----- stderr -----\n    \");\n\n    // Remove dependencies, so the environment should not be reused.\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n    repos:\n      - repo: local\n        hooks:\n          - id: reuse-env\n            name: reuse-env\n            language: python\n            entry: python -c \"print('ok')\"\n            pass_filenames: false\n            verbose: true\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    reuse-env................................................................Passed\n    - hook id: reuse-env\n    - duration: [TIME]\n\n      ok\n\n    ----- stderr -----\n    \");\n\n    // There should be two hook environments.\n    assert_eq!(context.home_dir().child(\"hooks\").read_dir()?.count(), 2);\n\n    Ok(())\n}\n\n#[test]\nfn dry_run() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: fail\n                name: fail\n                entry: fail\n                language: fail\n    \"});\n    context.git_add(\".\");\n\n    // Run with `--dry-run`\n    cmd_snapshot!(context.filters(), context.run().arg(\"--dry-run\").arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    fail....................................................................Dry Run\n    - hook id: fail\n    - duration: [TIME]\n\n      `fail` would be run on 1 files:\n      - .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n}\n\n/// Supports reading `pre-commit-config.yml` as well.\n#[test]\nfn alternate_config_file() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YML)\n        .write_str(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-python-hook\n                name: local-python-hook\n                language: python\n                entry: python3 -c 'import sys; print(\"Hello, world!\")'\n    \"#})?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local-python-hook........................................................Passed\n    - hook id: local-python-hook\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    \");\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local-python-hook\n                name: local-python-hook\n                language: python\n                entry: python3 -c 'import sys; print(\"Hello, world!\")'\n    \"#})?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--refresh\").arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local-python-hook........................................................Passed\n    - hook id: local-python-hook\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    warning: Multiple configuration files found (`.pre-commit-config.yaml`, `.pre-commit-config.yml`); using `[TEMP_DIR]/.pre-commit-config.yaml`\n    \");\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(indoc::indoc! {r#\"\n        [[repos]]\n        repo = \"local\"\n        hooks = [\n          {\n            id = \"local-python-hook\",\n            name = \"local-python-hook\",\n            language = \"python\",\n            entry = \"python3 -c 'import sys; print(\\\"Hello, world!\\\")'\"\n          }\n        ]\n    \"#})?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--refresh\").arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local-python-hook........................................................Passed\n    - hook id: local-python-hook\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    warning: Multiple configuration files found (`prek.toml`, `.pre-commit-config.yaml`, `.pre-commit-config.yml`); using `[TEMP_DIR]/prek.toml`\n    \");\n\n    Ok(())\n}\n\n/// Supports `prek.toml` as configuration file.\n#[test]\nfn prek_toml() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .write_str(indoc::indoc! {r#\"\n        [[repos]]\n        repo = \"local\"\n        hooks = [\n          {\n            id = \"local-python-hook\",\n            name = \"local-python-hook\",\n            language = \"python\",\n            entry = \"python3 -c 'import sys; print(\\\"Hello, world!\\\")'\"\n          }\n        ]\n    \"#})?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"-v\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    local-python-hook........................................................Passed\n    - hook id: local-python-hook\n    - duration: [TIME]\n\n      Hello, world!\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn show_diff_on_failure() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: modify\n                name: modify\n                language: python\n                entry: python -c \"import sys; open('file.txt', 'a').write('Added line\\n')\"\n                pass_filenames: false\n    \"#};\n    context.write_pre_commit_config(config);\n    context\n        .work_dir()\n        .child(\"file.txt\")\n        .write_str(\"Original line\\n\")?;\n    context.git_add(\".\");\n\n    let mut filters = context.filters();\n    filters.push((r\"index \\w{7}\\.\\.\\w{7} \\d{6}\", \"index [OLD]..[NEW] 100644\"));\n\n    // When failed in CI environment\n    cmd_snapshot!(filters.clone(), context.run().env(EnvVars::CI, \"1\").arg(\"--show-diff-on-failure\").arg(\"-v\"), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    modify...................................................................Failed\n    - hook id: modify\n    - duration: [TIME]\n    - files were modified by this hook\n\n    hint: Some hooks made changes to the files.\n    If you are seeing this message in CI, reproduce locally with: `prek run --all-files`\n    To run prek as part of Git workflow, use `prek install` to set up Git shims.\n\n    All changes made by hooks:\n    diff --git a/file.txt b/file.txt\n    index [OLD]..[NEW] 100644\n    --- a/file.txt\n    +++ b/file.txt\n    @@ -1 +1,2 @@\n     Original line\n    +Added line\n\n    ----- stderr -----\n    \");\n\n    context\n        .work_dir()\n        .child(\"file.txt\")\n        .write_str(\"Original line\\n\")?;\n    context.git_add(\".\");\n    // When failed in non-CI environment\n    cmd_snapshot!(filters.clone(), context.run().env_remove(EnvVars::CI).arg(\"--show-diff-on-failure\").arg(\"-v\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    modify...................................................................Failed\n    - hook id: modify\n    - duration: [TIME]\n    - files were modified by this hook\n    All changes made by hooks:\n    diff --git a/file.txt b/file.txt\n    index [OLD]..[NEW] 100644\n    --- a/file.txt\n    +++ b/file.txt\n    @@ -1 +1,2 @@\n     Original line\n    +Added line\n\n    ----- stderr -----\n    \");\n\n    // Run in the `app` subproject.\n    let app = context.work_dir().child(\"app\");\n    app.create_dir_all()?;\n    app.child(\"file.txt\").write_str(\"Original line\\n\")?;\n    app.child(PRE_COMMIT_CONFIG_YAML).write_str(config)?;\n\n    git_cmd(&app).arg(\"add\").arg(\".\").assert().success();\n\n    cmd_snapshot!(filters.clone(), context.run().env_remove(EnvVars::CI).current_dir(&app).arg(\"--show-diff-on-failure\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    modify...................................................................Failed\n    - hook id: modify\n    - files were modified by this hook\n    All changes made by hooks:\n    diff --git a/app/file.txt b/app/file.txt\n    index [OLD]..[NEW] 100644\n    --- a/app/file.txt\n    +++ b/app/file.txt\n    @@ -1 +1,2 @@\n     Original line\n    +Added line\n\n    ----- stderr -----\n    \");\n\n    context.git_add(\".\");\n\n    // Run in the root\n    // Since we add a new subproject, use `--refresh` to find that.\n    cmd_snapshot!(filters.clone(), context.run().env_remove(EnvVars::CI).arg(\"--show-diff-on-failure\").arg(\"--refresh\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Running hooks for `app`:\n    modify...................................................................Failed\n    - hook id: modify\n    - files were modified by this hook\n\n    Running hooks for `.`:\n    modify...................................................................Failed\n    - hook id: modify\n    - files were modified by this hook\n    All changes made by hooks:\n    diff --git a/app/file.txt b/app/file.txt\n    index [OLD]..[NEW] 100644\n    --- a/app/file.txt\n    +++ b/app/file.txt\n    @@ -1,2 +1,3 @@\n     Original line\n     Added line\n    +Added line\n    diff --git a/file.txt b/file.txt\n    index [OLD]..[NEW] 100644\n    --- a/file.txt\n    +++ b/file.txt\n    @@ -1,2 +1,3 @@\n     Original line\n     Added line\n    +Added line\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn run_quiet() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: success\n                name: success\n                entry: echo\n                language: system\n              - id: fail\n                name: fail\n                entry: fail\n                language: fail\n    \"});\n    context.git_add(\".\");\n\n    // Run with `--quiet`, only print failed hooks.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--quiet\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      fail\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n\n    // Run with `-qq`, do not print anything.\n    cmd_snapshot!(context.filters(), context.run().arg(\"-qq\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    \");\n}\n\n/// Test `PREK_QUIET` environment variable.\n#[test]\nfn run_quiet_env() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: success\n                name: success\n                entry: echo\n                language: system\n              - id: fail\n                name: fail\n                entry: fail\n                language: fail\n    \"});\n    context.git_add(\".\");\n\n    // Run with `PREK_QUIET=1`, only print failed hooks.\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_QUIET, \"1\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      fail\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n\n    // Run with `PREK_QUIET=2`, does not print anything (silent mode).\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_QUIET, \"2\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    \");\n}\n\n/// Test `prek run --log-file <file>` flag.\n#[test]\nfn run_log_file() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: fail\n                name: fail\n                entry: fail\n                language: fail\n    \"});\n    context.git_add(\".\");\n\n    // Run with `--no-log-file`, no `prek.log` is created.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--no-log-file\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      fail\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n    context\n        .home_dir()\n        .child(\"prek.log\")\n        .assert(predicate::path::missing());\n\n    // Write log to `log`.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--log-file\").arg(\"log\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    fail.....................................................................Failed\n    - hook id: fail\n    - exit code: 1\n\n      fail\n\n      .pre-commit-config.yaml\n\n    ----- stderr -----\n    \");\n    context\n        .work_dir()\n        .child(\"log\")\n        .assert(predicate::path::exists());\n}\n\n/// Test `language_version: system` works and disables downloading.\n#[test]\nfn system_language_version() {\n    if !EnvVars::is_set(EnvVars::CI) {\n        // Skip when not running in CI, as we may not have toolchains installed locally.\n        return;\n    }\n\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: system-node\n                name: system-node\n                language: node\n                language_version: system\n                entry: node -v\n                pass_filenames: false\n              - id: system-go\n                name: system-go\n                language: golang\n                language_version: system\n                entry: go version\n                pass_filenames: false\n              - id: system-bun\n                name: system-bun\n                language: bun\n                language_version: system\n                entry: bun -e 'console.log(`Bun ${Bun.version}`)'\n                pass_filenames: false\n   \"});\n    context.git_add(\".\");\n\n    // Binaries can't be found, `system` must fail.\n    cmd_snapshot!(\n        context.filters(),\n        context.run()\n        .arg(\"system-node\")\n        .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, \"go-never-exist\")\n        .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, \"node-never-exist\")\n        .env(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME, \"bun-never-exist\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `system-node`\n      caused by: Failed to install node\n      caused by: No suitable system Node version found and downloads are disabled\n    \");\n\n    cmd_snapshot!(\n        context.filters(),\n        context.run()\n        .arg(\"system-go\")\n        .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, \"go-never-exist\")\n        .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, \"node-never-exist\")\n        .env(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME, \"bun-never-exist\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `system-go`\n      caused by: Failed to install go\n      caused by: No suitable system Go version found and downloads are disabled\n    \");\n\n    cmd_snapshot!(\n        context.filters(),\n        context.run()\n        .arg(\"system-bun\")\n        .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, \"go-never-exist\")\n        .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, \"node-never-exist\")\n        .env(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME, \"bun-never-exist\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to install hook `system-bun`\n      caused by: Failed to install bun\n      caused by: No suitable system Bun version found and downloads are disabled\n    \");\n}\n\n/// Tests that empty `entry` field.\n#[test]\nfn empty_entry() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: local\n                name: local\n                language: python\n                entry: ''\n                pass_filenames: false\n   \"});\n    context.git_add(\".\");\n\n    // Go and Node can't be found, `system` must fail.\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to run hook `local`\n      caused by: Invalid hook `local`\n      caused by: Failed to parse entry: entry is empty\n    \");\n}\n\n/// Test that hooks are run with stdin closed.\n#[test]\nfn run_with_stdin_closed() {\n    let context = TestContext::new();\n    context.init_project();\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: check-stdin\n                name: check-stdin\n                language: python\n                entry: python -c 'import sys; sys.stdin.read(); print(\"STDIN closed\"); sys.stdout.flush()'\n                pass_filenames: false\n                verbose: true\n    \"#});\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-stdin..............................................................Passed\n    - hook id: check-stdin\n    - duration: [TIME]\n\n      STDIN closed\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--color\").arg(\"always\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    check-stdin..............................................................\u001b[42mPassed\u001b[49m\n    \u001b[2m- hook id: check-stdin\u001b[0m\n    \u001b[2m- duration: [TIME]\u001b[0m\n\n      STDIN closed\n\n    ----- stderr -----\n    \");\n}\n\n/// Test `prek --version` outputs version info.\n#[test]\nfn version_info() {\n    // skip if not built in the git repository\n    if option_env!(\"PREK_COMMIT_HASH\").is_none() {\n        return;\n    }\n    let context = TestContext::new();\n    let filters = context\n        .filters()\n        .into_iter()\n        .chain([(\n            r\"prek \\d+\\.\\d+\\.\\d+(-[0-9A-Za-z]+(\\.[0-9A-Za-z]+)*)?(\\+\\d+)? \\(\\w{9} [\\d\\-T:\\.]+\\)\",\n            \"prek [CURRENT_VERSION] ([COMMIT] [DATE])\",\n        )])\n        .collect::<Vec<_>>();\n    cmd_snapshot!(filters, context.command().arg(\"--version\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    prek [CURRENT_VERSION] ([COMMIT] [DATE])\n\n    ----- stderr -----\n    \");\n}\n\n#[test]\nfn expands_tilde_in_prek_home() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: ok\n                name: ok\n                entry: echo ok\n                language: system\n    \"});\n    context.git_add(\".\");\n\n    let fake_home = context.work_dir().child(\"fake-home\");\n    fake_home.create_dir_all()?;\n\n    cmd_snapshot!(context.filters(), context\n        .run()\n        .env(\"HOME\", fake_home.path())\n        .env(\"USERPROFILE\", fake_home.path()) // For Windows\n        .env(EnvVars::PREK_HOME, \"~/prek-store\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    ok.......................................................................Passed\n\n    ----- stderr -----\n    \");\n\n    let store = fake_home.child(\"prek-store\");\n    store.child(\"README\").assert(predicate::path::exists());\n    store.child(\"repos\").assert(predicate::path::is_dir());\n    store.child(\"hooks\").assert(predicate::path::is_dir());\n    store.child(\"scratch\").assert(predicate::path::is_dir());\n\n    // Ensure we didn't create a literal `./~` directory under the project.\n    context\n        .work_dir()\n        .child(\"~\")\n        .assert(predicate::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn run_with_tree_object_as_ref() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: echo-files\n                name: echo files\n                entry: echo\n                language: system\n                pass_filenames: true\n    \"});\n\n    // Create initial commit\n    cwd.child(\"file1.txt\").write_str(\"hello\")?;\n    context.git_add(\".\");\n    context.git_commit(\"Initial commit\");\n\n    // Create some changes and stage them\n    cwd.child(\"file2.txt\").write_str(\"world\")?;\n    context.git_add(\"file2.txt\");\n\n    // Get the tree object from the staged changes\n    let tree_output = git_cmd(cwd)\n        .arg(\"write-tree\")\n        .output()\n        .expect(\"Failed to run git write-tree\");\n    let tree_sha = String::from_utf8_lossy(&tree_output.stdout)\n        .trim()\n        .to_string();\n\n    // Run prek with tree object as to-ref (should work with .. syntax)\n    cmd_snapshot!(context.filters(), context.run()\n        .arg(\"--from-ref\").arg(\"HEAD\")\n        .arg(\"--to-ref\").arg(&tree_sha), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo files...............................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// `pass_filenames: n` limits each invocation to at most n files.\n/// With n=1, each matched file gets its own invocation.\n#[test]\nfn pass_filenames_1_limits_batch_size() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    // Use a script that errors if it receives more than one filename argument.\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: one-at-a-time\n                name: one at a time\n                entry: python -c \"import sys; args = sys.argv[1:]; sys.exit(0 if len(args) <= 1 else 1)\"\n                language: system\n                pass_filenames: 1\n                require_serial: true\n                verbose: true\n    \"#});\n\n    cwd.child(\"a.txt\").write_str(\"a\")?;\n    cwd.child(\"b.txt\").write_str(\"b\")?;\n    cwd.child(\"c.txt\").write_str(\"c\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    one at a time............................................................Passed\n    - hook id: one-at-a-time\n    - duration: [TIME]\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// `pass_filenames: n` limits each invocation to at most n files.\n/// With n=2 and more than 2 matching files, multiple batches are spawned.\n#[test]\nfn pass_filenames_2_limits_batch_size() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n    // Use a script that errors if it receives more than two filename arguments.\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: two-at-a-time\n                name: two at a time\n                entry: python -c \"import sys; args = sys.argv[1:]; sys.exit(0 if len(args) <= 2 else 1)\"\n                language: system\n                pass_filenames: 2\n                require_serial: true\n                verbose: true\n    \"#});\n\n    cwd.child(\"a.txt\").write_str(\"a\")?;\n    cwd.child(\"b.txt\").write_str(\"b\")?;\n    cwd.child(\"c.txt\").write_str(\"c\")?;\n    cwd.child(\"d.txt\").write_str(\"d\")?;\n    cwd.child(\"e.txt\").write_str(\"e\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    two at a time............................................................Passed\n    - hook id: two-at-a-time\n    - duration: [TIME]\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/sample_config.rs",
    "content": "use prek_consts::PRE_COMMIT_CONFIG_YAML;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\n#[test]\nfn sample_config() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.sample_config(), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    # See https://pre-commit.com for more information\n    # See https://pre-commit.com/hooks.html for more hooks\n    repos:\n      - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n        rev: v6.0.0\n        hooks:\n          - id: trailing-whitespace\n          - id: end-of-file-fixer\n          - id: check-yaml\n          - id: check-added-large-files\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"-f\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Written to `.pre-commit-config.yaml`\n\n    ----- stderr -----\n    \"#);\n\n    insta::assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r##\"\n    # See https://pre-commit.com for more information\n    # See https://pre-commit.com/hooks.html for more hooks\n    repos:\n      - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n        rev: v6.0.0\n        hooks:\n          - id: trailing-whitespace\n          - id: end-of-file-fixer\n          - id: check-yaml\n          - id: check-added-large-files\n    \"##);\n\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"-f\").arg(\"sample.yaml\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Written to `sample.yaml`\n\n    ----- stderr -----\n    \"#);\n\n    insta::assert_snapshot!(context.read(\"sample.yaml\"), @r##\"\n    # See https://pre-commit.com for more information\n    # See https://pre-commit.com/hooks.html for more hooks\n    repos:\n      - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n        rev: v6.0.0\n        hooks:\n          - id: trailing-whitespace\n          - id: end-of-file-fixer\n          - id: check-yaml\n          - id: check-added-large-files\n    \"##);\n\n    let child = context.work_dir().join(\"child\");\n    std::fs::create_dir(&child)?;\n\n    cmd_snapshot!(context.filters(), context.sample_config().current_dir(&*child).arg(\"-f\").arg(\"sample.yaml\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Written to `sample.yaml`\n\n    ----- stderr -----\n    \"#);\n    insta::assert_snapshot!(context.read(\"child/sample.yaml\"), @r##\"\n    # See https://pre-commit.com for more information\n    # See https://pre-commit.com/hooks.html for more hooks\n    repos:\n      - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n        rev: v6.0.0\n        hooks:\n          - id: trailing-whitespace\n          - id: end-of-file-fixer\n          - id: check-yaml\n          - id: check-added-large-files\n    \"##);\n\n    Ok(())\n}\n\n#[test]\nfn sample_config_toml() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"-f\").arg(\"prek.toml\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Written to `prek.toml`\n\n    ----- stderr -----\n    \"#);\n\n    insta::assert_snapshot!(context.read(\"prek.toml\"), @r#\"\n    # Configuration file for `prek`, a git hook framework written in Rust.\n    # See https://prek.j178.dev for more information.\n    #:schema https://www.schemastore.org/prek.json\n\n    [[repos]]\n    repo = \"builtin\"\n    hooks = [\n        { id = \"trailing-whitespace\" },\n        { id = \"end-of-file-fixer\" },\n        { id = \"check-added-large-files\" },\n    ]\n    \"#);\n}\n\n#[test]\nfn sample_config_format() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"--format\").arg(\"toml\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    # Configuration file for `prek`, a git hook framework written in Rust.\n    # See https://prek.j178.dev for more information.\n    #:schema https://www.schemastore.org/prek.json\n\n    [[repos]]\n    repo = \"builtin\"\n    hooks = [\n        { id = \"trailing-whitespace\" },\n        { id = \"end-of-file-fixer\" },\n        { id = \"check-added-large-files\" },\n    ]\n\n    ----- stderr -----\n    \"#);\n\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"--format\").arg(\"yaml\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    # See https://pre-commit.com for more information\n    # See https://pre-commit.com/hooks.html for more hooks\n    repos:\n      - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n        rev: v6.0.0\n        hooks:\n          - id: trailing-whitespace\n          - id: end-of-file-fixer\n          - id: check-yaml\n          - id: check-added-large-files\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"--format\").arg(\"json\"), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: invalid value 'json' for '--format <FORMAT>'\n      [possible values: yaml, toml]\n\n    For more information, try '--help'.\n    \");\n}\n\n#[test]\nfn respect_format() {\n    let context = TestContext::new();\n\n    // Write YAML format even with `.toml` extension.\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"--format\").arg(\"yaml\").arg(\"-f\").arg(\"prek.toml\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Written to `prek.toml`\n\n    ----- stderr -----\n    \");\n\n    insta::assert_snapshot!(context.read(\"prek.toml\"), @\"\n    # See https://pre-commit.com for more information\n    # See https://pre-commit.com/hooks.html for more hooks\n    repos:\n      - repo: 'https://github.com/pre-commit/pre-commit-hooks'\n        rev: v6.0.0\n        hooks:\n          - id: trailing-whitespace\n          - id: end-of-file-fixer\n          - id: check-yaml\n          - id: check-added-large-files\n    \");\n}\n\n#[test]\nfn respect_format_if_filename_missing() {\n    let context = TestContext::new();\n\n    // Create `prek.toml` when TOML format is specified but filename is not given.\n    cmd_snapshot!(context.filters(), context.sample_config().arg(\"--format\").arg(\"toml\").arg(\"-f\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Written to `prek.toml`\n\n    ----- stderr -----\n    \");\n\n    insta::assert_snapshot!(context.read(\"prek.toml\"), @r#\"\n    # Configuration file for `prek`, a git hook framework written in Rust.\n    # See https://prek.j178.dev for more information.\n    #:schema https://www.schemastore.org/prek.json\n\n    [[repos]]\n    repo = \"builtin\"\n    hooks = [\n        { id = \"trailing-whitespace\" },\n        { id = \"end-of-file-fixer\" },\n        { id = \"check-added-large-files\" },\n    ]\n    \"#);\n}\n"
  },
  {
    "path": "crates/prek/tests/skipped_hooks.rs",
    "content": "//! Integration tests for hook skip behavior.\n//!\n//! These tests verify that prek correctly identifies and reports skipped hooks\n//! in various scenarios: file pattern mismatches, dry-run mode, and mixed\n//! execution across priority groups.\n//!\n//! Includes regression tests for #1335: when all hooks in a group are skipped,\n//! prek should not call `git diff` to check for file modifications.\n\nuse anyhow::Result;\nuse assert_fs::prelude::*;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\n/// All hooks skip when no staged files match their file patterns.\n#[test]\nfn all_hooks_skipped_no_matching_files() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: python-check\n                name: python-check\n                language: system\n                entry: echo \"checking python\"\n                files: \\.py$\n              - id: rust-check\n                name: rust-check\n                language: system\n                entry: echo \"checking rust\"\n                files: \\.rs$\n              - id: go-check\n                name: go-check\n                language: system\n                entry: echo \"checking go\"\n                files: \\.go$\n    \"#});\n\n    cwd.child(\"readme.txt\").write_str(\"Hello\")?;\n    cwd.child(\"data.json\").write_str(\"{}\")?;\n    cwd.child(\"config.yaml\").write_str(\"key: value\")?;\n\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    python-check.........................................(no files to check)Skipped\n    rust-check...........................................(no files to check)Skipped\n    go-check.............................................(no files to check)Skipped\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n/// `--dry-run` skips hooks without executing them.\n#[test]\nfn dry_run_skips_all_hooks() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: formatter\n                name: formatter\n                language: system\n                entry: python3 -c \"import sys; open(sys.argv[1], 'a').write('modified')\"\n                files: \\.txt$\n              - id: linter\n                name: linter\n                language: system\n                entry: echo \"linting\"\n                files: \\.txt$\n    \"#});\n\n    cwd.child(\"file.txt\").write_str(\"content\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--dry-run\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    formatter...............................................................Dry Run\n    linter..................................................................Dry Run\n\n    ----- stderr -----\n    \"#);\n\n    assert_eq!(context.read(\"file.txt\"), \"content\");\n\n    Ok(())\n}\n\n/// Hooks that match staged files run; others are skipped.\n#[test]\nfn mixed_skipped_and_executed_hooks() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: txt-check\n                name: txt-check\n                language: system\n                entry: echo \"checking txt\"\n                files: \\.txt$\n              - id: py-check\n                name: py-check\n                language: system\n                entry: echo \"checking py\"\n                files: \\.py$\n              - id: rs-check\n                name: rs-check\n                language: system\n                entry: echo \"checking rs\"\n                files: \\.rs$\n    \"#});\n\n    cwd.child(\"readme.txt\").write_str(\"Hello\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run(), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    txt-check................................................................Passed\n    py-check.............................................(no files to check)Skipped\n    rs-check.............................................(no files to check)Skipped\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n/// Skipped hooks across multiple priority groups\n///\n/// Hooks with different `priority` values form separate priority groups. Each\n/// group is processed sequentially. This test verifies:\n/// 1. Skip behavior works correctly across group boundaries\n/// 2. `git diff` is only called once (initial baseline), not per-group\n///\n/// Note: This test uses manual output capture instead of `cmd_snapshot!` because\n/// we need to count `get_diff` occurrences in trace-level stderr. Trace output\n/// contains non-deterministic timestamps and timing data unsuitable for snapshots.\n#[test]\nfn all_hooks_skipped_multiple_priority_groups() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let cwd = context.work_dir();\n\n    context.write_pre_commit_config(indoc::indoc! {r#\"\n        repos:\n          - repo: local\n            hooks:\n              - id: priority-10\n                name: priority-10\n                language: system\n                entry: echo \"priority 10\"\n                files: \\.py$\n                priority: 10\n              - id: priority-20\n                name: priority-20\n                language: system\n                entry: echo \"priority 20\"\n                files: \\.rs$\n                priority: 20\n              - id: priority-30\n                name: priority-30\n                language: system\n                entry: echo \"priority 30\"\n                files: \\.go$\n                priority: 30\n    \"#});\n\n    cwd.child(\"data.json\").write_str(\"{}\")?;\n    context.git_add(\".\");\n\n    // Run with trace logging to verify #1335 fix\n    let output = context.run().env(\"RUST_LOG\", \"prek::git=trace\").output()?;\n\n    assert!(output.status.success(), \"prek should succeed\");\n\n    // Verify all hooks skipped\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    assert!(stdout.contains(\"priority-10\") && stdout.contains(\"Skipped\"));\n    assert!(stdout.contains(\"priority-20\") && stdout.contains(\"Skipped\"));\n    assert!(stdout.contains(\"priority-30\") && stdout.contains(\"Skipped\"));\n\n    // Regression test for #1335: only 1 get_diff call (initial baseline)\n    // Without fix: 4 calls (1 initial + 3 per priority group)\n    let stderr = String::from_utf8_lossy(&output.stderr);\n    let get_diff_calls = stderr.matches(\"get_diff\").count();\n    assert_eq!(\n        get_diff_calls, 1,\n        \"Expected 1 get_diff call (initial baseline) when all hooks skip, found {get_diff_calls}.\\n\\\n         Trace output:\\n{stderr}\"\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/try_repo.rs",
    "content": "mod common;\nuse anyhow::Result;\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::prelude::*;\nuse std::path::PathBuf;\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\nuse assert_fs::fixture::ChildPath;\nuse prek_consts::PRE_COMMIT_HOOKS_YAML;\n\nfn create_hook_repo(context: &TestContext, repo_name: &str) -> Result<PathBuf> {\n    let repo_dir = context.home_dir().child(format!(\"test-repos/{repo_name}\"));\n    repo_dir.create_dir_all()?;\n\n    git_cmd(&repo_dir).arg(\"init\").assert().success();\n\n    // Configure the author specifically for this hook repository\n    git_cmd(&repo_dir)\n        .arg(\"config\")\n        .arg(\"user.name\")\n        .arg(\"Prek Test\")\n        .assert()\n        .success();\n    git_cmd(&repo_dir)\n        .arg(\"config\")\n        .arg(\"user.email\")\n        .arg(\"test@prek.dev\")\n        .assert()\n        .success();\n    // Disable autocrlf for test consistency\n    git_cmd(&repo_dir)\n        .arg(\"config\")\n        .arg(\"core.autocrlf\")\n        .arg(\"false\")\n        .assert()\n        .success();\n\n    repo_dir\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r#\"\n        - id: test-hook\n          name: Test Hook\n          entry: echo\n          language: system\n          files: \"\\\\.txt$\"\n        - id: another-hook\n          name: Another Hook\n          entry: python3 -c \"print('hello')\"\n          language: python\n    \"#})?;\n\n    // Add a dummy setup.py to make it an installable Python package\n    repo_dir\n        .child(\"setup.py\")\n        .write_str(\"from setuptools import setup; setup(name='dummy-pkg', version='0.0.1')\")?;\n\n    git_cmd(&repo_dir).arg(\"add\").arg(\".\").assert().success();\n\n    git_cmd(&repo_dir)\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Initial commit\")\n        .assert()\n        .success();\n\n    Ok(repo_dir.to_path_buf())\n}\n\n// Helper for a repo with a hook that is designed to fail\nfn create_failing_hook_repo(context: &TestContext, repo_name: &str) -> Result<PathBuf> {\n    let repo_dir = context.home_dir().child(format!(\"test-repos/{repo_name}\"));\n    repo_dir.create_dir_all()?;\n\n    git_cmd(&repo_dir).arg(\"init\").assert().success();\n    git_cmd(&repo_dir)\n        .arg(\"config\")\n        .arg(\"user.name\")\n        .arg(\"Prek Test\")\n        .assert()\n        .success();\n    git_cmd(&repo_dir)\n        .arg(\"config\")\n        .arg(\"user.email\")\n        .arg(\"test@prek.dev\")\n        .assert()\n        .success();\n    // Disable autocrlf for test consistency\n    git_cmd(&repo_dir)\n        .arg(\"config\")\n        .arg(\"core.autocrlf\")\n        .arg(\"false\")\n        .assert()\n        .success();\n\n    repo_dir\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r#\"\n        - id: failing-hook\n          name: Always Fail\n          entry: \"false\"\n          language: system\n        \"#})?;\n\n    git_cmd(&repo_dir).arg(\"add\").arg(\".\").assert().success();\n\n    git_cmd(&repo_dir)\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"Initial commit\")\n        .assert()\n        .success();\n\n    Ok(repo_dir.to_path_buf())\n}\n\n#[test]\nfn try_repo_basic() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.work_dir().child(\"test.txt\").write_str(\"test\")?;\n    context.git_add(\".\");\n\n    let repo_path = create_hook_repo(&context, \"try-repo-basic\")?;\n\n    let mut filters = context.filters();\n    filters.extend([(r\"[a-f0-9]{40}\", \"[COMMIT_SHA]\"), (\"'\", \"\\\"\")]);\n\n    cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg(\"--skip\").arg(\"another-hook\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Using generated `prek.toml`:\n    [[repos]]\n    repo = \"[HOME]/test-repos/try-repo-basic\"\n    rev = \"[COMMIT_SHA]\"\n    hooks = [\n      { id = \"test-hook\" },\n    ]\n\n    Test Hook................................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn try_repo_failing_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.work_dir().child(\"test.txt\").write_str(\"test\")?;\n    context.git_add(\".\");\n\n    let repo_path = create_failing_hook_repo(&context, \"try-repo-failing\")?;\n\n    let mut filters = context.filters();\n    filters.extend([(r\"[a-f0-9]{40}\", \"[COMMIT_SHA]\"), (\"'\", \"\\\"\")]);\n\n    cmd_snapshot!(filters, context.try_repo().arg(&repo_path), @r#\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n    Using generated `prek.toml`:\n    [[repos]]\n    repo = \"[HOME]/test-repos/try-repo-failing\"\n    rev = \"[COMMIT_SHA]\"\n    hooks = [\n      { id = \"failing-hook\" },\n    ]\n\n    Always Fail..............................................................Failed\n    - hook id: failing-hook\n    - exit code: 1\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn try_repo_specific_hook() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_hook_repo(&context, \"try-repo-specific-hook\")?;\n\n    context.work_dir().child(\"test.txt\").write_str(\"test\")?;\n    context.git_add(\".\");\n\n    let mut filters = context.filters();\n    filters.extend([(r\"[a-f0-9]{40}\", \"[COMMIT_SHA]\"), (\"'\", \"\\\"\")]);\n\n    cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg(\"another-hook\"), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Using generated `prek.toml`:\n    [[repos]]\n    repo = \"[HOME]/test-repos/try-repo-specific-hook\"\n    rev = \"[COMMIT_SHA]\"\n    hooks = [\n      { id = \"another-hook\" },\n    ]\n\n    Another Hook.............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn try_repo_specific_rev() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.work_dir().child(\"test.txt\").write_str(\"test\")?;\n    context.git_add(\".\");\n\n    let repo_path = create_hook_repo(&context, \"try-repo-specific-rev\")?;\n\n    let initial_rev = git_cmd(&repo_path)\n        .arg(\"rev-parse\")\n        .arg(\"HEAD\")\n        .output()?\n        .stdout;\n    let initial_rev = String::from_utf8_lossy(&initial_rev).trim().to_string();\n\n    // Make a new commit\n    ChildPath::new(&repo_path)\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r\"\n        - id: new-hook\n          name: New Hook\n          entry: echo new\n          language: system\n        \"})?;\n    git_cmd(&repo_path).arg(\"add\").arg(\".\").assert().success();\n    git_cmd(&repo_path)\n        .arg(\"commit\")\n        .arg(\"-m\")\n        .arg(\"second\")\n        .assert()\n        .success();\n\n    let mut filters = context.filters();\n    filters.extend([\n        (r\"[a-f0-9]{40}\", \"[COMMIT_SHA]\"),\n        (&initial_rev, \"[COMMIT_SHA]\"),\n        (\"'\", \"\\\"\"),\n    ]);\n\n    cmd_snapshot!(filters, context.try_repo().arg(&repo_path)\n        .arg(\"--ref\")\n        .arg(&initial_rev), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Using generated `prek.toml`:\n    [[repos]]\n    repo = \"[HOME]/test-repos/try-repo-specific-rev\"\n    rev = \"[COMMIT_SHA]\"\n    hooks = [\n      { id = \"test-hook\" },\n      { id = \"another-hook\" },\n    ]\n\n    Test Hook................................................................Passed\n    Another Hook.............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn try_repo_uncommitted_changes() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let repo_path = create_hook_repo(&context, \"try-repo-uncommitted\")?;\n\n    // Make uncommitted changes\n    ChildPath::new(&repo_path)\n        .child(PRE_COMMIT_HOOKS_YAML)\n        .write_str(indoc::indoc! {r\"\n        - id: uncommitted-hook\n          name: Uncommitted Hook\n          entry: echo uncommitted\n          language: system\n        \"})?;\n    ChildPath::new(&repo_path)\n        .child(\"new-file.txt\")\n        .write_str(\"new\")?;\n    git_cmd(&repo_path)\n        .arg(\"add\")\n        .arg(\"new-file.txt\")\n        .assert()\n        .success();\n\n    context.work_dir().child(\"test.txt\").write_str(\"test\")?;\n    context.git_add(\".\");\n\n    let mut filters = context.filters();\n    filters.extend([\n        (r\"try-repo-[^/\\\\]+\", \"[REPO]\"),\n        (r\"[a-f0-9]{40}\", \"[COMMIT_SHA]\"),\n        (\"'\", \"\\\"\"),\n    ]);\n\n    cmd_snapshot!(filters, context.try_repo().arg(&repo_path), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Using generated `prek.toml`:\n    [[repos]]\n    repo = \"[HOME]/scratch/[REPO]/shadow-repo\"\n    rev = \"[COMMIT_SHA]\"\n    hooks = [\n      { id = \"uncommitted-hook\" },\n    ]\n\n    Uncommitted Hook.........................................................Passed\n\n    ----- stderr -----\n    warning: Creating temporary repo with uncommitted changes...\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn try_repo_relative_path() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.work_dir().child(\"test.txt\").write_str(\"test\")?;\n    context.git_add(\".\");\n\n    let _repo_path = create_hook_repo(&context, \"try-repo-relative\")?;\n    let relative_path = \"../home/test-repos/try-repo-relative\".to_string();\n\n    let mut filters = context.filters();\n    filters.extend([(r\"[a-f0-9]{40}\", \"[COMMIT_SHA]\")]);\n\n    cmd_snapshot!(filters, context.try_repo().arg(&relative_path), @r#\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Using generated `prek.toml`:\n    [[repos]]\n    repo = \"../home/test-repos/try-repo-relative\"\n    rev = \"[COMMIT_SHA]\"\n    hooks = [\n      { id = \"test-hook\" },\n      { id = \"another-hook\" },\n    ]\n\n    Test Hook................................................................Passed\n    Another Hook.............................................................Passed\n\n    ----- stderr -----\n    \"#);\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/validate.rs",
    "content": "use assert_fs::fixture::{FileWriteStr, PathChild};\nuse prek_consts::PRE_COMMIT_CONFIG_YAML;\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\n#[test]\nfn validate_config() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    // No files to validate.\n    cmd_snapshot!(context.filters(), context.validate_config(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    warning: No configs to check\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v5.0.0\n            hooks:\n              - id: trailing-whitespace\n              - id: end-of-file-fixer\n              - id: check-json\n    \"});\n    // Validate one file.\n    cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    success: All configs are valid\n    \");\n\n    context\n        .work_dir()\n        .child(\"config-1.yaml\")\n        .write_str(indoc::indoc! {r\"\n            repos:\n              - repo: https://github.com/pre-commit/pre-commit-hooks\n        \"})?;\n\n    // Validate multiple files.\n    cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML).arg(\"config-1.yaml\"), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `config-1.yaml`\n      caused by: error: line 2 column 5: missing field `rev`\n     --> <input>:2:5\n      |\n    1 | repos:\n    2 |   - repo: https://github.com/pre-commit/pre-commit-hooks\n      |     ^ missing field `rev`\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn invalid_config_error() {\n    let context = TestContext::new();\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            hooks:\n              - id: trailing-whitespace\n              - id: end-of-file-fixer\n              - id: check-json\n            rev: 1.0\n    \"});\n\n    cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    success: All configs are valid\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: https://github.com/pre-commit/pre-commit-hooks\n            rev: v6.0.0\n            hooks:\n              - id: trailing-whitespace\n              - id: end-of-file-fixer\n          - repo: local\n            hooks:\n              - name: check-json\n    \"});\n\n    cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `.pre-commit-config.yaml`\n      caused by: error: line 9 column 9: missing field `id`\n     --> <input>:9:9\n      |\n    7 |   - repo: local\n    8 |     hooks:\n    9 |       - name: check-json\n      |         ^ missing field `id`\n    \");\n}\n\n#[test]\nfn validate_manifest() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    // No files to validate.\n    cmd_snapshot!(context.filters(), context.validate_manifest(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    warning: No manifests to check\n    \");\n\n    context\n        .work_dir()\n        .child(\".pre-commit-hooks.yaml\")\n        .write_str(indoc::indoc! {r\"\n            -   id: check-added-large-files\n                name: check for added large files\n                description: prevents giant files from being committed.\n                entry: check-added-large-files\n                language: python\n                stages: [pre-commit, pre-push, manual]\n                minimum_pre_commit_version: 3.2.0\n        \"})?;\n    // Validate one file.\n    cmd_snapshot!(context.filters(), context.validate_manifest().arg(\".pre-commit-hooks.yaml\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    success: All manifests are valid\n    \");\n\n    context\n        .work_dir()\n        .child(\"hooks-1.yaml\")\n        .write_str(indoc::indoc! {r\"\n            -   id: check-added-large-files\n                name: check for added large files\n                description: prevents giant files from being committed.\n                language: python\n                stages: [pre-commit, pre-push, manual]\n                minimum_pre_commit_version: 3.2.0\n        \"})?;\n\n    // Validate multiple files.\n    cmd_snapshot!(context.filters(), context.validate_manifest().arg(\".pre-commit-hooks.yaml\").arg(\"hooks-1.yaml\"), @\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `hooks-1.yaml`\n      caused by: error: line 6 column 5: missing field `entry`\n     --> <input>:6:5\n      |\n    4 |     language: python\n    5 |     stages: [pre-commit, pre-push, manual]\n    6 |     minimum_pre_commit_version: 3.2.0\n      |     ^ missing field `entry`\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn unexpected_keys_warning() {\n    let context = TestContext::new();\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            unexpected_repo_key: some_value\n            hooks:\n              - id: test-hook\n                name: Test Hook\n                entry: echo test\n                language: system\n        unexpected_top_level_key: some_value\n        another_unknown: test\n        minimum_pre_commit_version: 1.0.0\n    \"});\n\n    cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    warning: Ignored unexpected keys in `.pre-commit-config.yaml`: `another_unknown`, `unexpected_top_level_key`, `repos[0].unexpected_repo_key`\n    success: All configs are valid\n    \");\n\n    context.write_pre_commit_config(indoc::indoc! {r\"\n        repos:\n          - repo: local\n            unexpected_repo_key: some_value\n            hooks:\n              - id: test-hook\n                name: Test Hook\n                entry: echo test\n                language: system\n                unexpected_hook_key_1: some_value\n                unexpected_hook_key_2: some_value\n                unexpected_hook_key_3: some_value\n                unexpected_hook_key_4: some_value\n        unexpected_top_level_key: some_value\n        another_unknown: test\n        minimum_pre_commit_version: 1.0.0\n    \"});\n\n    cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n\n    ----- stderr -----\n    warning: Ignored unexpected keys in `.pre-commit-config.yaml`:\n      - `another_unknown`\n      - `unexpected_top_level_key`\n      - `repos[0].unexpected_repo_key`\n      - `repos[0].hooks[0].unexpected_hook_key_1`\n      - `repos[0].hooks[0].unexpected_hook_key_2`\n      - `repos[0].hooks[0].unexpected_hook_key_3`\n      - `repos[0].hooks[0].unexpected_hook_key_4`\n    success: All configs are valid\n    \");\n}\n"
  },
  {
    "path": "crates/prek/tests/workspace.rs",
    "content": "mod common;\n\nuse anyhow::Result;\nuse assert_cmd::assert::OutputAssertExt;\nuse assert_fs::fixture::{FileWriteStr, PathChild};\nuse indoc::indoc;\nuse prek_consts::env_vars::EnvVars;\n\nuse crate::common::{TestContext, cmd_snapshot, git_cmd};\n\n#[test]\nfn basic_discovery() -> Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    // Run from the root directory\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `nested/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/nested/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3/project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Run from a subdirectory\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join(\"project2\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join(\"project2\")).arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join(\"project3\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--cd\").arg(cwd.join(\"project3\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Ignore `project5` in `project3`\n    context\n        .work_dir()\n        .child(\"project3/.prekignore\")\n        .write_str(\"project5/\\n\")?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--refresh\").arg(\"--cd\").arg(cwd.join(\"project3\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['.prekignore', '.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Ignoring everything under project3, but when runs from project3, it’s still getting picked up.\n    context\n        .work_dir()\n        .child(\"project3/.prekignore\")\n        .write_str(\"*\\n\")?;\n    context.git_add(\".\");\n    cmd_snapshot!(context.filters(), context.run().arg(\"--refresh\").arg(\"--cd\").arg(cwd.join(\"project3\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['.prekignore', '.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn config_not_staged() -> Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd-modified\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n    // Setup again to modify files after git add\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n\n    // Run from the root directory\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: The following configuration files are not staged, `git add` them first:\n      .pre-commit-config.yaml\n      nested/project4/.pre-commit-config.yaml\n      project2/.pre-commit-config.yaml\n      project3/.pre-commit-config.yaml\n      project3/project5/.pre-commit-config.yaml\n    \");\n\n    // Run from a subdirectory\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join(\"project3\")), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: The following configuration files are not staged, `git add` them first:\n      .pre-commit-config.yaml\n      project5/.pre-commit-config.yaml\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join(\"project2\")), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: prek configuration file is not staged, run `git add .pre-commit-config.yaml` to stage it\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn run_with_selectors() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(\n        &[\n            \"project2\",\n            \"project3\",\n            \"nested/project4\",\n            \"project3/project5\",\n        ],\n        config,\n    )?;\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `nested/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/nested/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3/project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"nested/\").arg(\"--skip\").arg(\"project3/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `nested/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/nested/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3/project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"project2:show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\".:show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"show-cwd\"), @r\"\n    success: false\n    exit_code: 1\n    ----- stdout -----\n\n    ----- stderr -----\n    error: No hooks found after filtering with the given selectors\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"project2:show-cwd\").arg(\"--skip\").arg(\"nested:show-cwd\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `nested/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/nested/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3/project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    warning: selector `--skip=nested:show-cwd` did not match any hooks\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"non-exist\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `nested/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/nested/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3/project5`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project5\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']\n      [TEMP_DIR]/\n      ['project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    warning: selector `--skip=non-exist` did not match any hooks\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"../\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Invalid selector: `../`\n      caused by: Invalid project path: `../`\n      caused by: path is outside the workspace root\n    \");\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(context.work_dir().join(\"project2\")), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn skips() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(&[\"project2\", \"project3\", \"project3/project4\"], config)?;\n    context.git_add(\".\");\n\n    // Test CLI skip\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project3/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Test PREK_SKIP environment variable\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_SKIP, \"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project3/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Test SKIP environment variable\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::SKIP, \"project2/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project3/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Test precedence: CLI --skip overrides PREK_SKIP\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"project2/\").env(EnvVars::PREK_SKIP, \"project3/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project3/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Test precedence: PREK_SKIP overrides SKIP\n    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_SKIP, \"project2/\").env(EnvVars::SKIP, \"project3/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project3/project4`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3/project4\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `project3`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project3\n      ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // Test multiple selectors in environment variable\n    cmd_snapshot!(context.filters(), context.run().env(\"PREK_SKIP\", \"project2/,project3/,non-exist-hook\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    warning: selector `PREK_SKIP=non-exist-hook` did not match any hooks\n    \");\n\n    // Add an invalid config\n    context\n        .work_dir()\n        .child(\"project3/.pre-commit-config.yaml\")\n        .write_str(\"invalid_yaml: [\")?;\n    context.git_add(\".\");\n\n    // Should error out because of the invalid config\n    cmd_snapshot!(context.filters(), context.run(), @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `project3/.pre-commit-config.yaml`\n      caused by: error: line 1 column 15: unclosed bracket '['\n     --> <input>:1:15\n      |\n    1 | invalid_yaml: [\n      |               ^ unclosed bracket '['\n    \");\n\n    // Should skip the invalid config\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\"project3/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn workspace_no_projects() {\n    let context = TestContext::new();\n    context.init_project();\n\n    context.write_pre_commit_config(\"repos: []\");\n    context.git_add(\".\");\n\n    cmd_snapshot!(context.filters(), context.run().arg(\"--skip\").arg(\".\"), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.\n\n    hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.\n    \");\n}\n\n#[test]\nfn gitignore_respected() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sorted(sys.argv[1:]))'\n          verbose: true\n    \"};\n\n    // Create a project structure with directories that should be ignored\n    context.setup_workspace(\n        &[\n            \"src\",\n            \"node_modules/ignored\", // Should be ignored by .gitignore\n            \"target/ignored\",       // Should be ignored by .gitignore\n        ],\n        config,\n    )?;\n\n    // Create .gitignore that ignores node_modules and target\n    context\n        .work_dir()\n        .child(\".gitignore\")\n        .write_str(\"node_modules/\\ntarget/\\n\")?;\n\n    context.git_add(\".\");\n\n    // Run from the root - should not discover projects in node_modules or target\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `src`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/src\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['.gitignore', '.pre-commit-config.yaml', 'src/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn nested_project_exclude_is_relative() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Regression test for nested workspaces:\n    // `exclude` must be evaluated against paths *relative to each project root*.\n    //\n    // Concretely:\n    // - In the nested project, the file is seen as `excluded_by_project` and should be excluded by `^excluded_by_project$`.\n    // - In the root project, the same file is seen as `nested/excluded_by_project` and should NOT be excluded.\n    let config = indoc! {r#\"\n    exclude: \\.pre-commit-config\\.yaml$|^excluded_by_project$\n    repos:\n      - repo: local\n        hooks:\n        - id: show-files\n          name: Show Files\n          language: python\n          entry: python -c 'import sys; print(\"Processing {} files\".format(len(sys.argv[1:]))); [print(\"  - {}\".format(f)) for f in sys.argv[1:]]'\n          pass_filenames: true\n          verbose: true\n    \"#};\n\n    // Workspace with a nested project.\n    context.setup_workspace(&[\"nested\"], config)?;\n\n    // A root-level file which should be excluded by the root project (path is `excluded_by_project`).\n    // This keeps the snapshot focused on the nested files, while proving the regex is not\n    // accidentally matching `nested/excluded_by_project`.\n    context\n        .work_dir()\n        .child(\"excluded_by_project\")\n        .write_str(\"\")?;\n\n    // Files inside the nested project: one that should be included and one excluded.\n    context.work_dir().child(\"nested/include\").write_str(\"\")?;\n    context\n        .work_dir()\n        .child(\"nested/excluded_by_project\")\n        .write_str(\"\")?;\n\n    context.git_add(\".\");\n\n    // When running from the root with --all-files, the nested project's exclude\n    // pattern should see paths relative to `nested/`, so `noinclude` is excluded\n    // there but still visible from the root project.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `nested`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 1 files\n        - include\n\n    Running hooks for `.`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 2 files\n        - nested/include\n        - nested/excluded_by_project\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Tests that `--files` arguments references files in other projects, should be filtered out properly.\n#[test]\nfn reference_files_across_projects() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: echo\n          name: echo\n          language: system\n          entry: echo\n          verbose: true\n    \"};\n\n    // Create a project structure with directories that should be ignored\n    context.setup_workspace(&[\"frontend\", \"backend\"], config)?;\n\n    let cwd = context.work_dir();\n    cwd.child(\"backend/app.py\")\n        .write_str(\"print('Hello from backend')\")?;\n    context.git_add(\".\");\n    // Run with --files referencing a file in another project\n    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.child(\"frontend\")).arg(\"--files\").arg(\"../backend/app.py\").arg(\"../backend/non-exist.py\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    echo.................................................(no files to check)Skipped\n\n    ----- stderr -----\n    warning: This file does not exist and will be ignored: `../backend/non-exist.py`\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn submodule_discovery() -> Result<()> {\n    let context = TestContext::new();\n    let cwd = context.work_dir();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(&[\"project2\"], config)?;\n\n    // Create a submodule\n    let submodule_path = cwd.child(\"submodule\");\n    let submodule_context = TestContext::new_at(submodule_path.to_path_buf());\n\n    submodule_context.init_project();\n    submodule_context.write_pre_commit_config(config);\n    submodule_context.git_add(\".\");\n    submodule_context.git_commit(\"Initial commit\");\n\n    // Add submodule to the main project\n    git_cmd(cwd)\n        .args([\"submodule\", \"add\", \"./submodule\"])\n        .assert()\n        .success();\n    context.git_add(\".\");\n\n    // 1. Test that workspace discovery does not recurse into git submodules\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['.pre-commit-config.yaml', '.gitmodules', 'project2/.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // 2. Test that current directory is in the submodule with a .pre-commit-config\n    cmd_snapshot!(context.filters(), context.run().current_dir(&submodule_path).arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/submodule\n      ['.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    // 3. Test that current directory is in the submodule without .pre-commit-config\n    // Remove the config file in the submodule\n    std::fs::remove_file(submodule_path.join(\".pre-commit-config.yaml\"))?;\n    submodule_context.git_add(\".\");\n    submodule_context.git_commit(\"Remove config\");\n\n    cmd_snapshot!(context.filters(), context.run().current_dir(&submodule_path), @r\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.\n\n    hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn cookiecutter_template_directories_are_skipped() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    let config = indoc! {r\"\n    repos:\n      - repo: local\n        hooks:\n        - id: show-cwd\n          name: Show CWD\n          language: python\n          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'\n          verbose: true\n    \"};\n\n    context.setup_workspace(&[\"project2\", \"{{cookiecutter.project_slug}}\"], config)?;\n\n    // Stage only the configs that should participate in discovery.\n    context.git_add(\".pre-commit-config.yaml\");\n    context.git_add(\"project2/.pre-commit-config.yaml\");\n\n    // The cookiecutter directory would otherwise be discovered as a project.\n    cmd_snapshot!(context.filters(), context.run().arg(\"--refresh\").arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `project2`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/project2\n      ['.pre-commit-config.yaml']\n\n    Running hooks for `.`:\n    Show CWD.................................................................Passed\n    - hook id: show-cwd\n    - duration: [TIME]\n\n      [TEMP_DIR]/\n      ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml']\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n#[test]\nfn orphan_projects() -> Result<()> {\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a hook that shows which files it processes\n    let config = indoc! {r#\"\n    exclude: \\.pre-commit-config\\.yaml$\n    repos:\n      - repo: local\n        hooks:\n        - id: show-files\n          name: Show Files\n          language: python\n          entry: python -c 'import sys; print(\"Processing {} files\".format(len(sys.argv[1:]))); [print(\"  - {}\".format(f)) for f in sys.argv[1:]]'\n          pass_filenames: true\n          verbose: true\n    \"#};\n\n    // Setup workspace with nested projects\n    context\n        .work_dir()\n        .child(\"src/backend/.pre-commit-config.yaml\")\n        .write_str(config)?;\n    context\n        .work_dir()\n        .child(\"src/.pre-commit-config.yaml\")\n        .write_str(config)?;\n    context\n        .work_dir()\n        .child(\".pre-commit-config.yaml\")\n        .write_str(config)?;\n\n    // Create test files\n    context\n        .work_dir()\n        .child(\"src/backend/test.py\")\n        .write_str(\"\")?;\n    context.work_dir().child(\"src/test.py\").write_str(\"\")?;\n    context.work_dir().child(\"test.py\").write_str(\"\")?;\n    context.git_add(\".\");\n\n    // Without `orphan`: files in subprojects are processed multiple times\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `src/backend`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 1 files\n        - test.py\n\n    Running hooks for `src`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 2 files\n        - test.py\n        - backend/test.py\n\n    Running hooks for `.`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 3 files\n        - src/test.py\n        - src/backend/test.py\n        - test.py\n\n    ----- stderr -----\n    \");\n\n    // Enable `orphan`\n    context\n        .work_dir()\n        .child(\"src/backend/.pre-commit-config.yaml\")\n        .write_str(indoc! {r#\"\n        orphan: true\n        exclude: \\.pre-commit-config\\.yaml$\n        repos:\n          - repo: local\n            hooks:\n            - id: show-files\n              name: Show Files\n              language: python\n              entry: python -c 'import sys; print(\"Processing {} files\".format(len(sys.argv[1:]))); [print(\"  - {}\".format(f)) for f in sys.argv[1:]]'\n              pass_filenames: true\n              verbose: true\n    \"#})?;\n\n    // `files` match nothing, but files are still \"consumed\"\n    context\n        .work_dir()\n        .child(\"src/.pre-commit-config.yaml\")\n        .write_str(indoc! {r#\"\n        orphan: true\n        files: ^$\n        exclude: \\.pre-commit-config\\.yaml$\n        repos:\n          - repo: local\n            hooks:\n            - id: show-files\n              name: Show Files\n              language: python\n              entry: python -c 'import sys; print(\"Processing {} files\".format(len(sys.argv[1:]))); [print(\"  - {}\".format(f)) for f in sys.argv[1:]]'\n              pass_filenames: true\n              verbose: true\n    \"#})?;\n\n    context\n        .work_dir()\n        .child(\".pre-commit-config.yaml\")\n        .write_str(indoc! {r#\"\n        orphan: false\n        exclude: \\.pre-commit-config\\.yaml$\n        repos:\n          - repo: local\n            hooks:\n            - id: show-files\n              name: Show Files\n              language: python\n              entry: python -c 'import sys; print(\"Processing {} files\".format(len(sys.argv[1:]))); [print(\"  - {}\".format(f)) for f in sys.argv[1:]]'\n              pass_filenames: true\n              verbose: true\n    \"#})?;\n\n    // In orphan project, files are \"consumed\" and not processed again in parent projects\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `src/backend`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 1 files\n        - test.py\n\n    Running hooks for `src`:\n    Show Files...........................................(no files to check)Skipped\n\n    Running hooks for `.`:\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 1 files\n        - test.py\n\n    ----- stderr -----\n    \");\n\n    // If hooks in orphan projects are not selected, files should be \"consumed\" as well\n    cmd_snapshot!(context.filters(), context.run().arg(\"--all-files\").arg(\"--skip\").arg(\"src/\"), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Show Files...............................................................Passed\n    - hook id: show-files\n    - duration: [TIME]\n\n      Processing 1 files\n        - test.py\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n\n/// Test that relative repo paths in subproject configs resolve from the config\n/// file's directory, not from the process's current working directory.\n///\n/// Regression test for <https://github.com/j178/prek/issues/1065>\n#[test]\nfn relative_repo_path_resolution() -> Result<()> {\n    use assert_fs::fixture::PathCreateDir;\n    use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_HOOKS_YAML};\n\n    let context = TestContext::new();\n    context.init_project();\n\n    // Create a local hook repository at the root level\n    let hook_repo = context.work_dir().child(\"hook-repo\");\n    hook_repo.create_dir_all()?;\n\n    git_cmd(&hook_repo).args([\"init\"]).assert().success();\n\n    git_cmd(&hook_repo)\n        .args([\"config\", \"user.name\", \"Test\"])\n        .assert()\n        .success();\n\n    git_cmd(&hook_repo)\n        .args([\"config\", \"user.email\", \"test@test.com\"])\n        .assert()\n        .success();\n\n    git_cmd(&hook_repo)\n        .args([\"config\", \"core.autocrlf\", \"false\"])\n        .assert()\n        .success();\n\n    hook_repo.child(PRE_COMMIT_HOOKS_YAML).write_str(indoc! {r\"\n        - id: test-hook\n          name: Test Hook\n          entry: echo test\n          language: system\n          always_run: true\n    \"})?;\n\n    git_cmd(&hook_repo).args([\"add\", \".\"]).assert().success();\n\n    git_cmd(&hook_repo)\n        .args([\"commit\", \"--no-si\", \"-m\", \"Initial commit\"])\n        .assert()\n        .success();\n\n    // Get the commit SHA\n    let output = git_cmd(&hook_repo).args([\"rev-parse\", \"HEAD\"]).output()?;\n    let commit_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();\n\n    // Create a subproject that references the hook repo with a relative path\n    let subproject = context.work_dir().child(\"subproject\");\n    subproject.create_dir_all()?;\n\n    // From subproject/, ../hook-repo should resolve to the hook-repo at root\n    subproject\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(&indoc::formatdoc! {r\"\n        repos:\n          - repo: ../hook-repo\n            rev: {commit_sha}\n            hooks:\n              - id: test-hook\n                always_run: true\n    \"})?;\n\n    subproject.child(\"test.txt\").write_str(\"test content\")?;\n\n    // Root config so workspace discovery works\n    context.write_pre_commit_config(indoc! {r\"\n        repos:\n          - repo: local\n            hooks:\n              - id: noop\n                name: Noop\n                entry: echo noop\n                language: system\n                always_run: true\n    \"});\n\n    context.git_add(\".\");\n\n    // Run from the root directory - the relative path ../hook-repo should resolve\n    // from subproject/.pre-commit-config.yaml's location, not from CWD\n    cmd_snapshot!(context.filters(), context.run(), @r\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Running hooks for `subproject`:\n    Test Hook................................................................Passed\n\n    Running hooks for `.`:\n    Noop.....................................................................Passed\n\n    ----- stderr -----\n    \");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/prek/tests/yaml_to_toml.rs",
    "content": "use assert_fs::assert::PathAssert;\nuse assert_fs::fixture::{FileWriteStr, PathChild};\nuse prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML};\n\nuse crate::common::{TestContext, cmd_snapshot};\n\nmod common;\n\nconst YAML_CONFIG: &str = r#\"\nfail_fast: true\ndefault_install_hook_types: [pre-push]\nexclude: |\n  (?x)^(\n    .*/(snapshots)/.*|\n  )$\n\nrepos:\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n      - id: mixed-line-ending\n      - id: check-yaml\n      - id: check-toml\n      - id: end-of-file-fixer\n\n  - repo: https://github.com/crate-ci/typos\n    rev: v1.42.3\n    hooks:\n      - id: typos\n\n  - repo: https://github.com/executablebooks/mdformat\n    rev: '1.0.0'\n    hooks:\n      - id: mdformat\n        language: python  # ensures that Renovate can update additional_dependencies\n        args: [--number, --compact-tables, --align-semantic-breaks-in-lists]\n        env:\n          Hello: World\n        priority: 1\n        additional_dependencies:\n          - mdformat-mkdocs==5.1.4\n          - mdformat-simple-breaks==0.1.0\n\n  - repo: local\n    hooks:\n      - id: taplo-fmt\n        name: taplo fmt\n        env:\n          EnvVar: Value\n          AnotherEnvVar: AnotherValue\n        entry: taplo fmt --config .config/taplo.toml\n        language: python\n        additional_dependencies: [\"taplo==0.9.3\"]\n        types: [toml]\n\"#;\n\n#[test]\nfn yaml_to_toml_writes_default_output() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context\n        .work_dir()\n        .child(\"config.yaml\")\n        .write_str(YAML_CONFIG)?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context\n            .command()\n            .args([\"util\", \"yaml-to-toml\", \"config.yaml\"]),\n        @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Converted `config.yaml` → `prek.toml`\n\n    ----- stderr -----\n    \"\n    );\n\n    insta::assert_snapshot!(context.read(PREK_TOML), @r#\"\n    # Configuration file for `prek`, a git hook framework written in Rust.\n    # See https://prek.j178.dev for more information.\n    #:schema https://www.schemastore.org/prek.json\n\n    fail_fast = true\n    default_install_hook_types = [\"pre-push\"]\n    exclude = \"\"\"\n    (?x)^(\n      .*/(snapshots)/.*|\n    )$\n    \"\"\"\n\n    [[repos]]\n    repo = \"builtin\"\n    hooks = [\n      { id = \"trailing-whitespace\" },\n      { id = \"mixed-line-ending\" },\n      { id = \"check-yaml\" },\n      { id = \"check-toml\" },\n      { id = \"end-of-file-fixer\" }\n    ]\n\n    [[repos]]\n    repo = \"https://github.com/crate-ci/typos\"\n    rev = \"v1.42.3\"\n    hooks = [\n      { id = \"typos\" }\n    ]\n\n    [[repos]]\n    repo = \"https://github.com/executablebooks/mdformat\"\n    rev = \"1.0.0\"\n    hooks = [\n      {\n        id = \"mdformat\",\n        language = \"python\",\n        args = [\n          \"--number\",\n          \"--compact-tables\",\n          \"--align-semantic-breaks-in-lists\"\n        ],\n        env = { Hello = \"World\" },\n        priority = 1,\n        additional_dependencies = [\n          \"mdformat-mkdocs==5.1.4\",\n          \"mdformat-simple-breaks==0.1.0\"\n        ]\n      }\n    ]\n\n    [[repos]]\n    repo = \"local\"\n    hooks = [\n      {\n        id = \"taplo-fmt\",\n        name = \"taplo fmt\",\n        env = {\n          EnvVar = \"Value\",\n          AnotherEnvVar = \"AnotherValue\"\n        },\n        entry = \"taplo fmt --config .config/taplo.toml\",\n        language = \"python\",\n        additional_dependencies = [\"taplo==0.9.3\"],\n        types = [\"toml\"]\n      }\n    ]\n    \"#);\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_force_overwrite() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context\n        .work_dir()\n        .child(\"config.yaml\")\n        .write_str(YAML_CONFIG)?;\n    context.work_dir().child(PREK_TOML).write_str(\"existing\")?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context\n            .command()\n            .args([\"util\", \"yaml-to-toml\", \"config.yaml\"]),\n        @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: File `prek.toml` already exists (use `--force` to overwrite)\n    \"\n    );\n\n    cmd_snapshot!(\n        context.filters(),\n        context\n            .command()\n            .args([\"util\", \"yaml-to-toml\", \"config.yaml\", \"--force\"]),\n        @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Converted `config.yaml` → `prek.toml`\n\n    ----- stderr -----\n    \"\n    );\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_rejects_invalid_config() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context\n        .work_dir()\n        .child(\"config.yaml\")\n        .write_str(\"repos: 123\")?;\n\n    cmd_snapshot!(\n      context.filters(),\n      context\n        .command()\n        .args([\"util\", \"yaml-to-toml\", \"config.yaml\"]),\n      @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Failed to parse `config.yaml`\n      caused by: error: line 1 column 8: unexpected event: expected sequence start\n     --> <input>:1:8\n      |\n    1 | repos: 123\n      |        ^ unexpected event: expected sequence start\n    \"\n    );\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_same_output() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context\n        .work_dir()\n        .child(\"config.yaml\")\n        .write_str(YAML_CONFIG)?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context\n            .command()\n            .args([\"util\", \"yaml-to-toml\", \"config.yaml\", \"--output\", \"config.yaml\"]),\n        @\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: Output path `config.yaml` matches input; choose a different output path\n    \"\n    );\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .assert(predicates::path::missing());\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_discovers_pre_commit_config_yaml() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(YAML_CONFIG)?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context.command().args([\"util\", \"yaml-to-toml\"]),\n        @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Converted `.pre-commit-config.yaml` → `prek.toml`\n\n    ----- stderr -----\n    \"\n    );\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .assert(predicates::path::exists());\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_discovers_pre_commit_config_yml() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YML)\n        .write_str(YAML_CONFIG)?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context.command().args([\"util\", \"yaml-to-toml\"]),\n        @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Converted `.pre-commit-config.yml` → `prek.toml`\n\n    ----- stderr -----\n    \"\n    );\n\n    context\n        .work_dir()\n        .child(PREK_TOML)\n        .assert(predicates::path::exists());\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_prefers_yaml_over_yml() -> anyhow::Result<()> {\n    let context = TestContext::new();\n\n    // Write different content to each file so we can verify which was used.\n    let yaml_only = indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: trailing-whitespace\n    \"};\n    let yml_only = indoc::indoc! {r\"\n        repos:\n          - repo: builtin\n            hooks:\n              - id: end-of-file-fixer\n    \"};\n\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YAML)\n        .write_str(yaml_only)?;\n    context\n        .work_dir()\n        .child(PRE_COMMIT_CONFIG_YML)\n        .write_str(yml_only)?;\n\n    cmd_snapshot!(\n        context.filters(),\n        context.command().args([\"util\", \"yaml-to-toml\"]),\n        @\"\n    success: true\n    exit_code: 0\n    ----- stdout -----\n    Converted `.pre-commit-config.yaml` → `prek.toml`\n\n    ----- stderr -----\n    \"\n    );\n\n    // The .yaml file contains trailing-whitespace, the .yml contains end-of-file-fixer.\n    let output = context.read(PREK_TOML);\n    assert!(\n        output.contains(\"trailing-whitespace\"),\n        \"Expected .yaml to be preferred over .yml\"\n    );\n\n    Ok(())\n}\n\n#[test]\nfn yaml_to_toml_error_when_no_config_found() {\n    let context = TestContext::new();\n\n    cmd_snapshot!(\n        context.filters(),\n        context.command().args([\"util\", \"yaml-to-toml\"]),\n        @r#\"\n    success: false\n    exit_code: 2\n    ----- stdout -----\n\n    ----- stderr -----\n    error: No `.pre-commit-config.yaml` or `.pre-commit-config.yml` found in the current directory\n\n    hint: Provide a path explicitly: prek util yaml-to-toml <CONFIG>\n    \"#\n    );\n}\n"
  },
  {
    "path": "crates/prek-consts/Cargo.toml",
    "content": "[package]\nname = \"prek-consts\"\ndescription = \"constant values for prek\"\nversion = { workspace = true }\nedition = { workspace = true }\nrust-version = { workspace = true }\nrepository = { workspace = true }\nlicense = { workspace = true }\n\n[dependencies]\ntracing = { workspace = true }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/prek-consts/src/env_vars.rs",
    "content": "use std::ffi::OsString;\n\nuse tracing::info;\n\npub struct EnvVars;\n\nimpl EnvVars {\n    pub const PATH: &'static str = \"PATH\";\n    pub const HOME: &'static str = \"HOME\";\n    pub const CI: &'static str = \"CI\";\n    pub const LC_ALL: &'static str = \"LC_ALL\";\n\n    // Git related\n    pub const GIT_DIR: &'static str = \"GIT_DIR\";\n    pub const GIT_WORK_TREE: &'static str = \"GIT_WORK_TREE\";\n    pub const GIT_TERMINAL_PROMPT: &'static str = \"GIT_TERMINAL_PROMPT\";\n\n    pub const SKIP: &'static str = \"SKIP\";\n\n    // PREK specific environment variables, public for users\n    pub const PREK_HOME: &'static str = \"PREK_HOME\";\n    pub const PREK_COLOR: &'static str = \"PREK_COLOR\";\n    pub const PREK_SKIP: &'static str = \"PREK_SKIP\";\n    pub const PREK_ALLOW_NO_CONFIG: &'static str = \"PREK_ALLOW_NO_CONFIG\";\n    pub const PREK_NO_CONCURRENCY: &'static str = \"PREK_NO_CONCURRENCY\";\n    pub const PREK_MAX_CONCURRENCY: &'static str = \"PREK_MAX_CONCURRENCY\";\n    pub const PREK_NO_FAST_PATH: &'static str = \"PREK_NO_FAST_PATH\";\n    pub const PREK_UV_SOURCE: &'static str = \"PREK_UV_SOURCE\";\n    pub const PREK_NATIVE_TLS: &'static str = \"PREK_NATIVE_TLS\";\n    pub const SSL_CERT_FILE: &'static str = \"SSL_CERT_FILE\";\n    pub const SSL_CERT_DIR: &'static str = \"SSL_CERT_DIR\";\n    pub const PREK_CONTAINER_RUNTIME: &'static str = \"PREK_CONTAINER_RUNTIME\";\n    pub const PREK_QUIET: &'static str = \"PREK_QUIET\";\n    pub const PREK_LOG_TRUNCATE_LIMIT: &'static str = \"PREK_LOG_TRUNCATE_LIMIT\";\n\n    // PREK internal environment variables\n    pub const PREK_INTERNAL__TEST_DIR: &'static str = \"PREK_INTERNAL__TEST_DIR\";\n    pub const PREK_INTERNAL__SORT_FILENAMES: &'static str = \"PREK_INTERNAL__SORT_FILENAMES\";\n    pub const PREK_INTERNAL__SKIP_POST_CHECKOUT: &'static str = \"PREK_INTERNAL__SKIP_POST_CHECKOUT\";\n    pub const PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT: &'static str =\n        \"PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT\";\n    pub const PREK_INTERNAL__BUN_BINARY_NAME: &'static str = \"PREK_INTERNAL__BUN_BINARY_NAME\";\n    pub const PREK_INTERNAL__DENO_BINARY_NAME: &'static str = \"PREK_INTERNAL__DENO_BINARY_NAME\";\n    pub const PREK_INTERNAL__GO_BINARY_NAME: &'static str = \"PREK_INTERNAL__GO_BINARY_NAME\";\n    pub const PREK_INTERNAL__NODE_BINARY_NAME: &'static str = \"PREK_INTERNAL__NODE_BINARY_NAME\";\n    pub const PREK_INTERNAL__RUSTUP_BINARY_NAME: &'static str = \"PREK_INTERNAL__RUSTUP_BINARY_NAME\";\n    pub const PREK_INTERNAL__SKIP_CABAL_UPDATE: &'static str = \"PREK_INTERNAL__SKIP_CABAL_UPDATE\";\n    pub const PREK_RUNNING_LEGACY: &'static str = \"PREK_RUNNING_LEGACY\";\n    pub const PREK_GENERATE: &'static str = \"PREK_GENERATE\";\n\n    // Python & uv related\n    pub const VIRTUAL_ENV: &'static str = \"VIRTUAL_ENV\";\n    pub const PYTHONHOME: &'static str = \"PYTHONHOME\";\n    pub const UV_PYTHON: &'static str = \"UV_PYTHON\";\n    pub const UV_SYSTEM_PYTHON: &'static str = \"UV_SYSTEM_PYTHON\";\n    pub const UV_CACHE_DIR: &'static str = \"UV_CACHE_DIR\";\n    pub const UV_PYTHON_INSTALL_DIR: &'static str = \"UV_PYTHON_INSTALL_DIR\";\n    pub const UV_MANAGED_PYTHON: &'static str = \"UV_MANAGED_PYTHON\";\n    pub const UV_NO_MANAGED_PYTHON: &'static str = \"UV_NO_MANAGED_PYTHON\";\n\n    // Node/Npm related\n    pub const NPM_CONFIG_USERCONFIG: &'static str = \"NPM_CONFIG_USERCONFIG\";\n    pub const NPM_CONFIG_PREFIX: &'static str = \"NPM_CONFIG_PREFIX\";\n    pub const NODE_PATH: &'static str = \"NODE_PATH\";\n\n    // Bun related\n    pub const BUN_INSTALL: &'static str = \"BUN_INSTALL\";\n\n    // Deno related\n    pub const DENO_DIR: &'static str = \"DENO_DIR\";\n    pub const DENO_NO_UPDATE_CHECK: &'static str = \"DENO_NO_UPDATE_CHECK\";\n\n    // GitHub API authentication (to avoid rate limits)\n    pub const GITHUB_TOKEN: &'static str = \"GITHUB_TOKEN\";\n\n    // Go related\n    pub const GOTOOLCHAIN: &'static str = \"GOTOOLCHAIN\";\n    pub const GOROOT: &'static str = \"GOROOT\";\n    pub const GOPATH: &'static str = \"GOPATH\";\n    pub const GOBIN: &'static str = \"GOBIN\";\n    pub const GOFLAGS: &'static str = \"GOFLAGS\";\n\n    // Lua related\n    pub const LUA_PATH: &'static str = \"LUA_PATH\";\n    pub const LUA_CPATH: &'static str = \"LUA_CPATH\";\n\n    // Ruby related\n    pub const PREK_RUBY_MIRROR: &'static str = \"PREK_RUBY_MIRROR\";\n    pub const GEM_HOME: &'static str = \"GEM_HOME\";\n    pub const GEM_PATH: &'static str = \"GEM_PATH\";\n    pub const BUNDLE_IGNORE_CONFIG: &'static str = \"BUNDLE_IGNORE_CONFIG\";\n    pub const BUNDLE_GEMFILE: &'static str = \"BUNDLE_GEMFILE\";\n\n    // Rust related\n    pub const RUSTUP_TOOLCHAIN: &'static str = \"RUSTUP_TOOLCHAIN\";\n    pub const RUSTUP_AUTO_INSTALL: &'static str = \"RUSTUP_AUTO_INSTALL\";\n    pub const CARGO_HOME: &'static str = \"CARGO_HOME\";\n    pub const RUSTUP_HOME: &'static str = \"RUSTUP_HOME\";\n}\n\nimpl EnvVars {\n    // Pre-commit environment variables that we support for compatibility\n    pub const PRE_COMMIT_HOME: &'static str = \"PRE_COMMIT_HOME\";\n    const PRE_COMMIT_ALLOW_NO_CONFIG: &'static str = \"PRE_COMMIT_ALLOW_NO_CONFIG\";\n    const PRE_COMMIT_NO_CONCURRENCY: &'static str = \"PRE_COMMIT_NO_CONCURRENCY\";\n}\n\nimpl EnvVars {\n    /// Read an environment variable, falling back to pre-commit corresponding variable if not found.\n    pub fn var_os(name: &str) -> Option<OsString> {\n        #[allow(clippy::disallowed_methods)]\n        std::env::var_os(name).or_else(|| {\n            let name = Self::pre_commit_name(name)?;\n            let val = std::env::var_os(name)?;\n            info!(\"Falling back to pre-commit environment variable for {name}\");\n            Some(val)\n        })\n    }\n\n    pub fn is_set(name: &str) -> bool {\n        Self::var_os(name).is_some()\n    }\n\n    /// Return whether the current process is running under CI.\n    pub fn is_under_ci() -> bool {\n        Self::is_set(Self::CI)\n    }\n\n    /// Read an environment variable, falling back to pre-commit corresponding variable if not found.\n    pub fn var(name: &str) -> Result<String, std::env::VarError> {\n        match Self::var_os(name) {\n            Some(s) => s.into_string().map_err(std::env::VarError::NotUnicode),\n            None => Err(std::env::VarError::NotPresent),\n        }\n    }\n\n    /// Read an environment var and parse as bool.\n    pub fn var_as_bool(name: &str) -> Option<bool> {\n        if let Some(val) = EnvVars::var_os(name)\n            && let Some(val) = val.to_str()\n            && let Some(val) = EnvVars::parse_boolish(val)\n        {\n            Some(val)\n        } else {\n            None\n        }\n    }\n\n    /// Parse a boolean from a string.\n    ///\n    /// Adapted from Clap's `BoolishValueParser` which is dual licensed under the MIT and Apache-2.0.\n    /// See `clap_builder/src/util/str_to_bool.rs`\n    fn parse_boolish(val: &str) -> Option<bool> {\n        // True values are `y`, `yes`, `t`, `true`, `on`, and `1`.\n        const TRUE_LITERALS: [&str; 6] = [\"y\", \"yes\", \"t\", \"true\", \"on\", \"1\"];\n\n        // False values are `n`, `no`, `f`, `false`, `off`, and `0`.\n        const FALSE_LITERALS: [&str; 6] = [\"n\", \"no\", \"f\", \"false\", \"off\", \"0\"];\n\n        let val = val.to_lowercase();\n        let pat = val.as_str();\n        if TRUE_LITERALS.contains(&pat) {\n            Some(true)\n        } else if FALSE_LITERALS.contains(&pat) {\n            Some(false)\n        } else {\n            None\n        }\n    }\n\n    fn pre_commit_name(name: &str) -> Option<&str> {\n        match name {\n            Self::PREK_ALLOW_NO_CONFIG => Some(Self::PRE_COMMIT_ALLOW_NO_CONFIG),\n            Self::PREK_NO_CONCURRENCY => Some(Self::PRE_COMMIT_NO_CONCURRENCY),\n            _ => None,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::EnvVars;\n\n    #[test]\n    fn test_parse_boolish() {\n        let true_values = [\"y\", \"yes\", \"t\", \"true\", \"on\", \"1\"];\n        let false_values = [\"n\", \"no\", \"f\", \"false\", \"off\", \"0\"];\n        for val in true_values {\n            assert_eq!(EnvVars::parse_boolish(val), Some(true),);\n            assert_eq!(EnvVars::parse_boolish(&val.to_uppercase()), Some(true),);\n        }\n        for val in false_values {\n            assert_eq!(EnvVars::parse_boolish(val), Some(false),);\n            assert_eq!(EnvVars::parse_boolish(&val.to_uppercase()), Some(false),);\n        }\n        assert_eq!(EnvVars::parse_boolish(\"maybe\"), None);\n        assert_eq!(EnvVars::parse_boolish(\"\"), None);\n        assert_eq!(EnvVars::parse_boolish(\"123\"), None);\n    }\n}\n"
  },
  {
    "path": "crates/prek-consts/src/lib.rs",
    "content": "pub mod env_vars;\n\nuse std::ffi::OsString;\nuse std::path::Path;\n\nuse env_vars::EnvVars;\n\npub const PRE_COMMIT_CONFIG_YAML: &str = \".pre-commit-config.yaml\";\npub const PRE_COMMIT_CONFIG_YML: &str = \".pre-commit-config.yml\";\npub const PREK_TOML: &str = \"prek.toml\";\npub const PRE_COMMIT_HOOKS_YAML: &str = \".pre-commit-hooks.yaml\";\n\npub static CONFIG_FILENAMES: &[&str] = &[PREK_TOML, PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML];\n\n/// Prepend paths to the current $PATH, returning the joined result.\n///\n/// The resulting `OsString` can be used to set the `PATH` environment variable.\npub fn prepend_paths(paths: &[&Path]) -> Result<OsString, std::env::JoinPathsError> {\n    std::env::join_paths(\n        paths.iter().map(|p| p.to_path_buf()).chain(\n            EnvVars::var_os(EnvVars::PATH)\n                .as_ref()\n                .iter()\n                .flat_map(std::env::split_paths),\n        ),\n    )\n}\n"
  },
  {
    "path": "crates/prek-identify/Cargo.toml",
    "content": "[package]\nname = \"prek-identify\"\ndescription = \"File identification for prek\"\nversion = { workspace = true }\nedition = { workspace = true }\nrust-version = { workspace = true }\nrepository = { workspace = true }\nlicense = { workspace = true }\n\n[features]\nserde = [\"dep:serde\"]\nschemars = [\"dep:schemars\"]\n\n[dependencies]\nanyhow = { workspace = true }\nfs-err = { workspace = true }\nphf = { workspace = true, default-features = false, features = [\"macros\"] }\nschemars = { workspace = true, optional = true }\nserde = { workspace = true, optional = true }\nshlex = { workspace = true }\nthiserror = { workspace = true }\n\n[dev-dependencies]\nindoc = { workspace = true }\nserde_json = { workspace = true }\ntempfile = { workspace = true }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/prek-identify/gen.py",
    "content": "# /// script\n# requires-python = \">=3.14\"\n# dependencies = [\n#     \"identify>=2.6.16\",\n# ]\n# ///\nfrom pathlib import Path\n\nfrom identify.identify import ALL_TAGS\nfrom identify.interpreters import INTERPRETERS\nfrom identify.extensions import EXTENSIONS, EXTENSIONS_NEED_BINARY_CHECK, NAMES\n\n\nTAG_ID_CONSTS = [\n    (\"TAG_FILE\", \"file\"),\n    (\"TAG_DIRECTORY\", \"directory\"),\n    (\"TAG_SYMLINK\", \"symlink\"),\n    (\"TAG_SOCKET\", \"socket\"),\n    (\"TAG_EXECUTABLE\", \"executable\"),\n    (\"TAG_NON_EXECUTABLE\", \"non-executable\"),\n    (\"TAG_TEXT\", \"text\"),\n    (\"TAG_BINARY\", \"binary\"),\n]\n\nTAG_SET_CONSTS = [\n    (\"TAG_SET_FILE\", [\"file\"]),\n    (\"TAG_SET_DIRECTORY\", [\"directory\"]),\n    (\"TAG_SET_SYMLINK\", [\"symlink\"]),\n    (\"TAG_SET_SOCKET\", [\"socket\"]),\n    (\"TAG_SET_TEXT\", [\"text\"]),\n    (\"TAG_SET_TEXT_OR_BINARY\", [\"text\", \"binary\"]),\n    (\"TAG_SET_EXECUTABLE_TEXT\", [\"executable\", \"text\"]),\n    (\"TAG_SET_JSON\", [\"json\"]),\n    (\"TAG_SET_JSON5\", [\"json5\"]),\n    (\"TAG_SET_TOML\", [\"toml\"]),\n    (\"TAG_SET_XML\", [\"xml\"]),\n    (\"TAG_SET_YAML\", [\"yaml\"]),\n]\n\nSELF_DIR = Path(__file__).parent\nTAGS_FILE = SELF_DIR / \"src/tags.rs\"\n\n\ndef gen():\n    with open(TAGS_FILE, \"w\", newline=\"\\n\") as f:\n        f.write(\"// This file is auto-generated by gen.py. DO NOT EDIT MANUALLY.\\n\\n\")\n        f.write(\"use crate::TagSet;\\n\\n\")\n        tags = sorted(set(ALL_TAGS))\n        tag_to_id = {tag: idx for idx, tag in enumerate(tags)}\n\n        def tagset_expr(tag_set):\n            ids = sorted(tag_to_id[tag] for tag in tag_set)\n            ids_str = \", \".join(str(tag_id) for tag_id in ids)\n            return f\"TagSet::new(&[{ids_str}])\"\n\n        f.write(f\"pub const ALL_TAGS: [&str; {len(tags)}] = [\\n\")\n        for tag in tags:\n            f.write(f'    \"{tag}\",\\n')\n        f.write(\"];\\n\\n\")\n\n        for const_name, tag in TAG_ID_CONSTS:\n            f.write(f\"pub const {const_name}: u16 = {tag_to_id[tag]};\\n\")\n        f.write(\"\\n\")\n\n        for const_name, const_tags in TAG_SET_CONSTS:\n            f.write(f\"pub const {const_name}: TagSet = {tagset_expr(const_tags)};\\n\")\n        f.write(\"\\n\")\n\n        f.write(\"pub const INTERPRETERS: phf::Map<&str, TagSet> = phf::phf_map! {\\n\")\n        for interpreter in sorted(INTERPRETERS):\n            tag_names = sorted(INTERPRETERS[interpreter])\n            tag_names_str = \", \".join(f'\"{tag}\"' for tag in tag_names)\n            f.write(f\"    // [{tag_names_str}]\\n\")\n            f.write(\n                f'    \"{interpreter}\" => {tagset_expr(INTERPRETERS[interpreter])},\\n'\n            )\n        f.write(\"};\\n\\n\")\n\n        EXTENSIONS.update(EXTENSIONS_NEED_BINARY_CHECK)\n        f.write(\"pub const EXTENSIONS: phf::Map<&str, TagSet> = phf::phf_map! {\\n\")\n        for ext in sorted(EXTENSIONS):\n            tag_names = sorted(EXTENSIONS[ext])\n            tag_names_str = \", \".join(f'\"{tag}\"' for tag in tag_names)\n            f.write(f\"    // [{tag_names_str}]\\n\")\n            f.write(f'    \"{ext}\" => {tagset_expr(EXTENSIONS[ext])},\\n')\n        f.write(\"};\\n\\n\")\n\n        f.write(\"pub const NAMES: phf::Map<&str, TagSet> = phf::phf_map! {\\n\")\n        for name in sorted(NAMES):\n            tag_names = sorted(NAMES[name])\n            tag_names_str = \", \".join(f'\"{tag}\"' for tag in tag_names)\n            f.write(f\"    // [{tag_names_str}]\\n\")\n            f.write(f'    \"{name}\" => {tagset_expr(NAMES[name])},\\n')\n        f.write(\"};\\n\")\n\n\ndef main():\n    gen()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "crates/prek-identify/src/lib.rs",
    "content": "// Copyright (c) 2017 Chris Kuehl, Anthony Sottile\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in\n// all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n\nuse std::borrow::Cow;\nuse std::io::{BufRead, Read};\nuse std::ops::BitOrAssign;\nuse std::path::Path;\n\n#[cfg(feature = \"serde\")]\nuse serde::de::{Error as DeError, SeqAccess, Visitor};\n\npub mod tags;\n\nconst TAG_WORDS: usize = tags::ALL_TAGS.len().div_ceil(64);\n\n/// A compact set of file tags represented as a fixed-size bitset.\n///\n/// Each bit corresponds to an index in [`tags::ALL_TAGS`].\n/// This keeps membership / set operations fast and allocation-free.\n#[derive(Clone, Copy, Default)]\npub struct TagSet {\n    bits: [u64; TAG_WORDS],\n}\n\nimpl std::fmt::Debug for TagSet {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_list().entries(self.iter()).finish()\n    }\n}\n\nfn tag_id(tag: &str) -> Option<usize> {\n    tags::ALL_TAGS.binary_search(&tag).ok()\n}\n\npub struct TagSetIter<'a> {\n    bits: &'a [u64; TAG_WORDS],\n    word_idx: usize,\n    cur_word: u64,\n}\n\nimpl Iterator for TagSetIter<'_> {\n    type Item = &'static str;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        loop {\n            if self.cur_word != 0 {\n                // Find index of the least-significant set bit in the current 64-bit word.\n                let tz = self.cur_word.trailing_zeros() as usize;\n                // Clear that least-significant set bit so the next call advances to the next tag.\n                self.cur_word &= self.cur_word - 1;\n\n                // `word_idx` is already incremented when `cur_word` was loaded,\n                // so we use `word_idx - 1` here to compute the global tag index.\n                let idx = (self.word_idx.saturating_sub(1) * 64) + tz;\n                return tags::ALL_TAGS.get(idx).copied();\n            }\n\n            if self.word_idx >= TAG_WORDS {\n                return None;\n            }\n\n            self.cur_word = self.bits[self.word_idx];\n            self.word_idx += 1;\n        }\n    }\n}\n\nimpl TagSet {\n    /// Constructs a [`TagSet`] from tag ids.\n    ///\n    /// `tag_ids` must reference valid indexes in [`tags::ALL_TAGS_BY_ID`].\n    /// Duplicate ids are allowed and are automatically coalesced.\n    pub const fn new(tag_ids: &[u16]) -> Self {\n        let mut bits = [0u64; TAG_WORDS];\n        let mut idx = 0;\n        while idx < tag_ids.len() {\n            let tag_id = tag_ids[idx] as usize;\n            assert!(tag_id < tags::ALL_TAGS.len(), \"tag id out of range\");\n            bits[tag_id / 64] |= 1u64 << (tag_id % 64);\n            idx += 1;\n        }\n\n        Self { bits }\n    }\n\n    /// Constructs a [`TagSet`] from tag strings.\n    ///\n    /// Unknown tags are ignored in release builds and debug-asserted in debug builds.\n    pub fn from_tags<I, S>(tags: I) -> Self\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<str>,\n    {\n        let mut bits = [0u64; TAG_WORDS];\n        for tag in tags {\n            let tag = tag.as_ref();\n            let Some(tag_id) = tag_id(tag) else {\n                debug_assert!(false, \"unknown tag: {tag}\");\n                continue;\n            };\n            bits[tag_id / 64] |= 1u64 << (tag_id % 64);\n        }\n\n        Self { bits }\n    }\n\n    pub const fn insert(&mut self, tag_id: u16) {\n        let tag_id = tag_id as usize;\n        assert!(tag_id < tags::ALL_TAGS.len(), \"tag id out of range\");\n        self.bits[tag_id / 64] |= 1u64 << (tag_id % 64);\n    }\n\n    /// Returns `true` if the two sets do not share any tag.\n    pub fn is_disjoint(&self, other: &TagSet) -> bool {\n        for idx in 0..TAG_WORDS {\n            if (self.bits[idx] & other.bits[idx]) != 0 {\n                return false;\n            }\n        }\n        true\n    }\n\n    /// Returns `true` if all tags in `self` are also present in `other`.\n    pub fn is_subset(&self, other: &TagSet) -> bool {\n        for idx in 0..TAG_WORDS {\n            if (self.bits[idx] & !other.bits[idx]) != 0 {\n                return false;\n            }\n        }\n        true\n    }\n\n    /// Iterates tags in deterministic id order.\n    pub fn iter(&self) -> TagSetIter<'_> {\n        TagSetIter {\n            bits: &self.bits,\n            word_idx: 0,\n            cur_word: 0,\n        }\n    }\n\n    /// Returns `true` if the set contains no tags.\n    pub fn is_empty(&self) -> bool {\n        self.bits.iter().all(|&w| w == 0)\n    }\n}\n\nimpl BitOrAssign<&TagSet> for TagSet {\n    fn bitor_assign(&mut self, rhs: &TagSet) {\n        for idx in 0..TAG_WORDS {\n            self.bits[idx] |= rhs.bits[idx];\n        }\n    }\n}\n\n#[cfg(feature = \"serde\")]\nimpl<'de> serde::Deserialize<'de> for TagSet {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>,\n    {\n        struct TagSetVisitor;\n\n        impl<'de> Visitor<'de> for TagSetVisitor {\n            type Value = TagSet;\n\n            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n                formatter.write_str(\"a sequence of tag strings\")\n            }\n\n            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>\n            where\n                A: SeqAccess<'de>,\n            {\n                let mut tags = TagSet::default();\n                while let Some(tag) = seq.next_element::<Cow<str>>()? {\n                    let Some(tag_id) = tag_id(&tag) else {\n                        let msg = format!(\n                            \"Type tag `{tag}` is not recognized. Check for typos or upgrade prek to get new tags.\"\n                        );\n                        return Err(A::Error::custom(msg));\n                    };\n                    let tag_id = u16::try_from(tag_id)\n                        .map_err(|_| A::Error::custom(\"tag id out of range\"))?;\n                    tags.insert(tag_id);\n                }\n                Ok(tags)\n            }\n        }\n\n        deserializer.deserialize_seq(TagSetVisitor)\n    }\n}\n\n#[cfg(feature = \"schemars\")]\nimpl schemars::JsonSchema for TagSet {\n    fn inline_schema() -> bool {\n        true\n    }\n\n    fn schema_name() -> Cow<'static, str> {\n        Cow::Borrowed(\"TagSet\")\n    }\n\n    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {\n        schemars::json_schema!({\n            \"type\": \"array\",\n            \"items\": {\n                \"type\": \"string\",\n            },\n            \"uniqueItems\": true,\n        })\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum Error {\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    #[error(transparent)]\n    Shebang(#[from] ShebangError),\n}\n\n/// Identify tags for a file at the given path.\npub fn tags_from_path(path: &Path) -> Result<TagSet, Error> {\n    let metadata = std::fs::symlink_metadata(path)?;\n    if metadata.is_dir() {\n        return Ok(tags::TAG_SET_DIRECTORY);\n    } else if metadata.is_symlink() {\n        return Ok(tags::TAG_SET_SYMLINK);\n    }\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::FileTypeExt;\n        let file_type = metadata.file_type();\n        if file_type.is_socket() {\n            return Ok(tags::TAG_SET_SOCKET);\n        }\n    };\n\n    let mut tags = tags::TAG_SET_FILE;\n\n    let executable;\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        executable = metadata.permissions().mode() & 0o111 != 0;\n    }\n    #[cfg(not(unix))]\n    {\n        // `pre-commit/identify` uses `os.access(path, os.X_OK)` to check for executability on Windows.\n        // This would actually return true for any file.\n        // We keep this behavior for compatibility.\n        executable = true;\n    }\n\n    if executable {\n        tags.insert(tags::TAG_EXECUTABLE);\n    } else {\n        tags.insert(tags::TAG_NON_EXECUTABLE);\n    }\n\n    let filename_tags = tags_from_filename(path);\n    tags |= &filename_tags;\n    if executable {\n        if let Ok(shebang) = parse_shebang(path) {\n            let interpreter_tags = tags_from_interpreter(shebang[0].as_str());\n            tags |= &interpreter_tags;\n        }\n    }\n\n    if tags.is_disjoint(&tags::TAG_SET_TEXT_OR_BINARY) {\n        if is_text_file(path) {\n            tags.insert(tags::TAG_TEXT);\n        } else {\n            tags.insert(tags::TAG_BINARY);\n        }\n    }\n\n    Ok(tags)\n}\n\nfn tags_from_filename(filename: &Path) -> TagSet {\n    let ext = filename.extension().and_then(|ext| ext.to_str());\n    let filename = filename\n        .file_name()\n        .and_then(|name| name.to_str())\n        .expect(\"Invalid filename\");\n\n    let mut result = tags::NAMES\n        .get(filename)\n        .or_else(|| {\n            // Allow e.g. \"Dockerfile.xenial\" to match \"Dockerfile\".\n            filename\n                .split('.')\n                .next()\n                .and_then(|name| tags::NAMES.get(name))\n        })\n        .copied()\n        .unwrap_or_default();\n\n    if let Some(ext) = ext {\n        // Check if extension is already lowercase to avoid allocation\n        if ext.chars().all(|c| c.is_ascii_lowercase()) {\n            if let Some(tags) = tags::EXTENSIONS.get(ext) {\n                result |= tags;\n            }\n        } else {\n            let ext_lower = ext.to_ascii_lowercase();\n            if let Some(tags) = tags::EXTENSIONS.get(ext_lower.as_str()) {\n                result |= tags;\n            }\n        }\n    }\n\n    result\n}\n\nfn tags_from_interpreter(interpreter: &str) -> TagSet {\n    let mut name = interpreter\n        .rfind('/')\n        .map(|pos| &interpreter[pos + 1..])\n        .unwrap_or(interpreter);\n\n    while !name.is_empty() {\n        if let Some(tags) = tags::INTERPRETERS.get(name) {\n            return *tags;\n        }\n\n        // python3.12.3 should match python3.12.3, python3.12, python3, python\n        if let Some(pos) = name.rfind('.') {\n            name = &name[..pos];\n        } else {\n            break;\n        }\n    }\n\n    TagSet::default()\n}\n\n#[derive(thiserror::Error, Debug)]\npub enum ShebangError {\n    #[error(\"No shebang found\")]\n    NoShebang,\n    #[error(\"Shebang contains non-printable characters\")]\n    NonPrintableChars,\n    #[error(\"Failed to parse shebang\")]\n    ParseFailed,\n    #[error(\"No command found in shebang\")]\n    NoCommand,\n    #[error(\"IO error: {0}\")]\n    IoError(#[from] std::io::Error),\n}\n\nfn starts_with(slice: &[String], prefix: &[&str]) -> bool {\n    slice.len() >= prefix.len() && slice.iter().zip(prefix.iter()).all(|(s, p)| s == p)\n}\n\n/// Parse nix-shell shebangs, which may span multiple lines.\n/// See: <https://nixos.wiki/wiki/Nix-shell_shebang>\n/// Example:\n/// `#!nix-shell -i python3 -p python3` would return `[\"python3\"]`\nfn parse_nix_shebang<R: BufRead>(reader: &mut R, mut cmd: Vec<String>) -> Vec<String> {\n    loop {\n        let Ok(buf) = reader.fill_buf() else {\n            break;\n        };\n\n        if buf.len() < 2 || &buf[..2] != b\"#!\" {\n            break;\n        }\n\n        reader.consume(2);\n\n        let mut next_line = String::new();\n        match reader.read_line(&mut next_line) {\n            Ok(0) => break,\n            Ok(_) => {}\n            Err(err) => {\n                if err.kind() == std::io::ErrorKind::InvalidData {\n                    return cmd;\n                }\n                break;\n            }\n        }\n\n        let trimmed = next_line.trim();\n        if trimmed.is_empty() {\n            continue;\n        }\n\n        if let Some(line_tokens) = shlex::split(trimmed) {\n            for idx in 0..line_tokens.len().saturating_sub(1) {\n                if line_tokens[idx] == \"-i\" {\n                    if let Some(interpreter) = line_tokens.get(idx + 1) {\n                        cmd = vec![interpreter.clone()];\n                    }\n                }\n            }\n        }\n    }\n\n    cmd\n}\n\npub fn parse_shebang(path: &Path) -> Result<Vec<String>, ShebangError> {\n    let file = std::fs::File::open(path)?;\n    let mut reader = std::io::BufReader::new(file);\n    let mut line = String::new();\n    reader.read_line(&mut line)?;\n    if !line.starts_with(\"#!\") {\n        return Err(ShebangError::NoShebang);\n    }\n\n    // Require only printable ASCII\n    if line\n        .bytes()\n        .any(|b| !(0x20..=0x7E).contains(&b) && !(0x09..=0x0D).contains(&b))\n    {\n        return Err(ShebangError::NonPrintableChars);\n    }\n\n    let mut tokens = shlex::split(line[2..].trim()).ok_or(ShebangError::ParseFailed)?;\n    let mut cmd =\n        if starts_with(&tokens, &[\"/usr/bin/env\", \"-S\"]) || starts_with(&tokens, &[\"env\", \"-S\"]) {\n            tokens.drain(0..2);\n            tokens\n        } else if starts_with(&tokens, &[\"/usr/bin/env\"]) || starts_with(&tokens, &[\"env\"]) {\n            tokens.drain(0..1);\n            tokens\n        } else {\n            tokens\n        };\n    if cmd.is_empty() {\n        return Err(ShebangError::NoCommand);\n    }\n    if cmd[0] == \"nix-shell\" {\n        cmd = parse_nix_shebang(&mut reader, cmd);\n    }\n    if cmd.is_empty() {\n        return Err(ShebangError::NoCommand);\n    }\n\n    Ok(cmd)\n}\n\n// Lookup table for text character detection.\nstatic IS_TEXT_CHAR: [u32; 8] = {\n    let mut table = [0u32; 8];\n    let mut i = 0;\n    while i < 256 {\n        // Printable ASCII (0x20..0x7F)\n        // High bit set (>= 0x80)\n        // Control characters: 7, 8, 9, 10, 11, 12, 13, 27\n        let is_text =\n            (i >= 0x20 && i < 0x7F) || i >= 0x80 || matches!(i, 7 | 8 | 9 | 10 | 11 | 12 | 13 | 27);\n        if is_text {\n            table[i / 32] |= 1 << (i % 32);\n        }\n        i += 1;\n    }\n    table\n};\n\nfn is_text_char(b: u8) -> bool {\n    let idx = b as usize;\n    (IS_TEXT_CHAR[idx / 32] & (1 << (idx % 32))) != 0\n}\n\n/// Return whether the first KB of contents seems to be binary.\n///\n/// This is roughly based on libmagic's binary/text detection:\n/// <https://github.com/file/file/blob/df74b09b9027676088c797528edcaae5a9ce9ad0/src/encoding.c#L203-L228>\nfn is_text_file(path: &Path) -> bool {\n    let mut buffer = [0; 1024];\n    let Ok(mut file) = fs_err::File::open(path) else {\n        return false;\n    };\n\n    let Ok(bytes_read) = file.read(&mut buffer) else {\n        return false;\n    };\n    if bytes_read == 0 {\n        return true;\n    }\n\n    buffer[..bytes_read].iter().all(|&b| is_text_char(b))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{TagSet, tags};\n    use std::io::Write;\n    use std::path::Path;\n\n    fn assert_tagset(actual: &TagSet, expected: &[&'static str]) {\n        let mut actual_vec: Vec<_> = actual.iter().collect();\n        actual_vec.sort_unstable();\n        let mut expected_vec = expected.to_vec();\n        expected_vec.sort_unstable();\n        assert_eq!(actual_vec, expected_vec);\n    }\n\n    #[test]\n    #[cfg(unix)]\n    fn tags_from_path() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        let src = dir.path().join(\"source.txt\");\n        let dest = dir.path().join(\"link.txt\");\n        fs_err::File::create(&src)?;\n        std::os::unix::fs::symlink(&src, &dest)?;\n\n        let tags = super::tags_from_path(dir.path())?;\n        assert_tagset(&tags, &[\"directory\"]);\n        let tags = super::tags_from_path(&src)?;\n        assert_tagset(&tags, &[\"plain-text\", \"non-executable\", \"file\", \"text\"]);\n        let tags = super::tags_from_path(&dest)?;\n        assert_tagset(&tags, &[\"symlink\"]);\n\n        Ok(())\n    }\n\n    #[test]\n    #[cfg(windows)]\n    fn tags_from_path() -> anyhow::Result<()> {\n        let dir = tempfile::tempdir()?;\n        let src = dir.path().join(\"source.txt\");\n        fs_err::File::create(&src)?;\n\n        let tags = super::tags_from_path(dir.path())?;\n        assert_tagset(&tags, &[\"directory\"]);\n        let tags = super::tags_from_path(&src)?;\n        assert_tagset(&tags, &[\"plain-text\", \"executable\", \"file\", \"text\"]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn tags_from_filename() {\n        let tags = super::tags_from_filename(Path::new(\"test.py\"));\n        assert_tagset(&tags, &[\"python\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"bitbake.bbappend\"));\n        assert_tagset(&tags, &[\"bitbake\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"project.fsproj\"));\n        assert_tagset(&tags, &[\"fsproj\", \"msbuild\", \"text\", \"xml\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"data.json\"));\n        assert_tagset(&tags, &[\"json\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"build.props\"));\n        assert_tagset(&tags, &[\"msbuild\", \"text\", \"xml\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"profile.psd1\"));\n        assert_tagset(&tags, &[\"powershell\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"style.xslt\"));\n        assert_tagset(&tags, &[\"text\", \"xml\", \"xsl\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"Pipfile\"));\n        assert_tagset(&tags, &[\"toml\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"Pipfile.lock\"));\n        assert_tagset(&tags, &[\"json\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"file.pdf\"));\n        assert_tagset(&tags, &[\"pdf\", \"binary\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"FILE.PDF\"));\n        assert_tagset(&tags, &[\"pdf\", \"binary\"]);\n\n        let tags = super::tags_from_filename(Path::new(\".envrc\"));\n        assert_tagset(&tags, &[\"bash\", \"shell\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"meson.options\"));\n        assert_tagset(&tags, &[\"meson\", \"meson-options\", \"text\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"Tiltfile\"));\n        assert_tagset(&tags, &[\"text\", \"tiltfile\"]);\n\n        let tags = super::tags_from_filename(Path::new(\"Tiltfile.dev\"));\n        assert_tagset(&tags, &[\"text\", \"tiltfile\"]);\n    }\n\n    #[test]\n    fn tags_from_interpreter() {\n        let tags = super::tags_from_interpreter(\"/usr/bin/python3\");\n        assert_tagset(&tags, &[\"python\", \"python3\"]);\n\n        let tags = super::tags_from_interpreter(\"/usr/bin/python3.12\");\n        assert_tagset(&tags, &[\"python\", \"python3\"]);\n\n        let tags = super::tags_from_interpreter(\"/usr/bin/python3.12.3\");\n        assert_tagset(&tags, &[\"python\", \"python3\"]);\n\n        let tags = super::tags_from_interpreter(\"python\");\n        assert_tagset(&tags, &[\"python\"]);\n\n        let tags = super::tags_from_interpreter(\"sh\");\n        assert_tagset(&tags, &[\"shell\", \"sh\"]);\n\n        let tags = super::tags_from_interpreter(\"invalid\");\n        assert!(tags.is_empty());\n    }\n\n    #[test]\n    fn tagset_new_iter_and_is_empty() {\n        let empty = TagSet::new(&[]);\n        assert!(empty.is_empty());\n        assert_eq!(empty.iter().count(), 0);\n\n        let binary_id = u16::try_from(super::tag_id(\"binary\").expect(\"binary id\")).unwrap();\n        let text_id = u16::try_from(super::tag_id(\"text\").expect(\"text id\")).unwrap();\n        let set = TagSet::new(&[text_id, binary_id, text_id]);\n\n        assert!(!set.is_empty());\n        assert_eq!(set.iter().collect::<Vec<_>>(), vec![\"binary\", \"text\"]);\n    }\n\n    #[test]\n    fn tagset_from_tags_intersects_subset_and_bitor_assign() {\n        let a = TagSet::from_tags([\"python\", \"text\"]);\n        let b = TagSet::from_tags([\"python\"]);\n        let c = TagSet::from_tags([\"binary\"]);\n\n        assert!(b.is_subset(&a));\n        assert!(!a.is_subset(&b));\n        assert!(!a.is_disjoint(&b));\n        assert!(a.is_disjoint(&c));\n\n        let mut merged = b;\n        merged |= &c;\n        assert_tagset(&merged, &[\"python\", \"binary\"]);\n    }\n\n    #[test]\n    fn tagset_new_panics_on_out_of_range_id() {\n        let out_of_range = u16::try_from(tags::ALL_TAGS.len()).unwrap();\n        let result = std::panic::catch_unwind(|| TagSet::new(&[out_of_range]));\n        assert!(result.is_err());\n    }\n\n    #[cfg(feature = \"serde\")]\n    #[test]\n    fn tagset_deserialize_from_string_slice() {\n        let parsed: TagSet =\n            serde_json::from_str(r#\"[\"python\",\"text\"]\"#).expect(\"should parse tags\");\n        assert_tagset(&parsed, &[\"python\", \"text\"]);\n    }\n\n    #[cfg(feature = \"serde\")]\n    #[test]\n    fn tagset_deserialize_unknown_tag_errors() {\n        let err = serde_json::from_str::<TagSet>(r#\"[\"not-a-real-tag\"]\"#).unwrap_err();\n        assert!(\n            err.to_string()\n                .contains(\"Type tag `not-a-real-tag` is not recognized\"),\n            \"unexpected error: {err}\"\n        );\n    }\n\n    #[test]\n    fn parse_shebang_nix_shell_interpreter() -> anyhow::Result<()> {\n        let mut file = tempfile::NamedTempFile::new()?;\n        writeln!(\n            file,\n            indoc::indoc! {r#\"\n            #!/usr/bin/env nix-shell\n            #! nix-shell --pure -i bash -p \"python3.withPackages (p: [ p.numpy p.sympy ])\"\n            #! nix-shell -I nixpkgs=https://example.com\n            echo hi\n            \"#}\n        )?;\n        file.flush()?;\n\n        let cmd = super::parse_shebang(file.path())?;\n        assert_eq!(cmd, vec![\"bash\"]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn parse_shebang_nix_shell_without_interpreter() -> anyhow::Result<()> {\n        let mut file = tempfile::NamedTempFile::new()?;\n        writeln!(\n            file,\n            indoc::indoc! {r\"\n            #!/usr/bin/env nix-shell -p python3\n            #! nix-shell --pure -I nixpkgs=https://example.com\n            echo hi\n            \"}\n        )?;\n        file.flush()?;\n\n        let cmd = super::parse_shebang(file.path())?;\n        assert_eq!(cmd, vec![\"nix-shell\", \"-p\", \"python3\"]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/prek-identify/src/tags.rs",
    "content": "// This file is auto-generated by gen.py. DO NOT EDIT MANUALLY.\n\nuse crate::TagSet;\n\npub const ALL_TAGS: [&str; 311] = [\n    \"adobe-illustrator\",\n    \"alpm\",\n    \"apinotes\",\n    \"asar\",\n    \"asciidoc\",\n    \"ash\",\n    \"asm\",\n    \"aspectj\",\n    \"astro\",\n    \"audio\",\n    \"avif\",\n    \"avro-schema\",\n    \"awk\",\n    \"babelrc\",\n    \"bash\",\n    \"batch\",\n    \"bats\",\n    \"bazel\",\n    \"bazelrc\",\n    \"beancount\",\n    \"bib\",\n    \"binary\",\n    \"bitbake\",\n    \"bitmap\",\n    \"bowerrc\",\n    \"browserslistrc\",\n    \"bzip2\",\n    \"bzip3\",\n    \"c\",\n    \"c#\",\n    \"c#script\",\n    \"c++\",\n    \"c2hs\",\n    \"cargo\",\n    \"cargo-lock\",\n    \"cbsd\",\n    \"clojure\",\n    \"clojurescript\",\n    \"cmake\",\n    \"codespellrc\",\n    \"coffee\",\n    \"coveragerc\",\n    \"crystal\",\n    \"csh\",\n    \"cson\",\n    \"csproj\",\n    \"css\",\n    \"csslintrc\",\n    \"csv\",\n    \"cuda\",\n    \"cue\",\n    \"cylc\",\n    \"cython\",\n    \"dart\",\n    \"dash\",\n    \"dbc\",\n    \"def\",\n    \"diff\",\n    \"directory\",\n    \"dockerfile\",\n    \"dockerignore\",\n    \"dotenv\",\n    \"dtd\",\n    \"editorconfig\",\n    \"edn\",\n    \"ejs\",\n    \"ejson\",\n    \"elixir\",\n    \"elm\",\n    \"entitlements\",\n    \"eot\",\n    \"eps\",\n    \"erb\",\n    \"erlang\",\n    \"executable\",\n    \"expect\",\n    \"f#\",\n    \"f#script\",\n    \"file\",\n    \"fish\",\n    \"fits\",\n    \"flake8\",\n    \"fortran\",\n    \"fsproj\",\n    \"gdscript\",\n    \"geojson\",\n    \"ggb\",\n    \"gherkin\",\n    \"gif\",\n    \"gitattributes\",\n    \"gitconfig\",\n    \"gitignore\",\n    \"gitlint\",\n    \"gitmodules\",\n    \"gleam\",\n    \"go\",\n    \"go-mod\",\n    \"go-sum\",\n    \"gotmpl\",\n    \"gpx\",\n    \"graphql\",\n    \"groovy\",\n    \"gyb\",\n    \"gyp\",\n    \"gzip\",\n    \"handlebars\",\n    \"haskell\",\n    \"hcl\",\n    \"header\",\n    \"hgrc\",\n    \"hlsl\",\n    \"html\",\n    \"icalendar\",\n    \"icns\",\n    \"icon\",\n    \"idl\",\n    \"idris\",\n    \"image\",\n    \"inc\",\n    \"ini\",\n    \"inl\",\n    \"ino\",\n    \"inx\",\n    \"ipxe\",\n    \"isort\",\n    \"jade\",\n    \"jar\",\n    \"java\",\n    \"java-properties\",\n    \"javascript\",\n    \"jbuilder\",\n    \"jenkins\",\n    \"jinja\",\n    \"jpeg\",\n    \"jshintrc\",\n    \"json\",\n    \"json5\",\n    \"jsonld\",\n    \"jsonnet\",\n    \"jsx\",\n    \"julia\",\n    \"jupyter\",\n    \"kml\",\n    \"kotlin\",\n    \"ksh\",\n    \"lazarus\",\n    \"lazarus-form\",\n    \"lean\",\n    \"lektor\",\n    \"lektorproject\",\n    \"less\",\n    \"liquid\",\n    \"literate-haskell\",\n    \"lua\",\n    \"m4\",\n    \"magik\",\n    \"mailmap\",\n    \"makefile\",\n    \"manifest\",\n    \"map\",\n    \"markdown\",\n    \"mdx\",\n    \"mention-bot\",\n    \"meson\",\n    \"meson-options\",\n    \"metal\",\n    \"mib\",\n    \"modulemap\",\n    \"msbuild\",\n    \"musescore\",\n    \"mustache\",\n    \"myst\",\n    \"ngdoc\",\n    \"nim\",\n    \"nimble\",\n    \"nix\",\n    \"non-executable\",\n    \"npmignore\",\n    \"nunjucks\",\n    \"objective-c\",\n    \"objective-c++\",\n    \"ocaml\",\n    \"otf\",\n    \"p12\",\n    \"pascal\",\n    \"pdbrc\",\n    \"pdf\",\n    \"pem\",\n    \"perl\",\n    \"php\",\n    \"php7\",\n    \"php8\",\n    \"piskel\",\n    \"pkgbuild\",\n    \"plain-text\",\n    \"plantuml\",\n    \"plist\",\n    \"png\",\n    \"pofile\",\n    \"pom\",\n    \"powershell\",\n    \"ppm\",\n    \"prettierignore\",\n    \"prisma\",\n    \"proto\",\n    \"pug\",\n    \"puppet\",\n    \"purescript\",\n    \"pyi\",\n    \"pylintrc\",\n    \"pypirc\",\n    \"pyproj\",\n    \"pyproject\",\n    \"python\",\n    \"python2\",\n    \"python3\",\n    \"pyz\",\n    \"qml\",\n    \"r\",\n    \"relax-ng\",\n    \"resx\",\n    \"robot\",\n    \"rst\",\n    \"ruby\",\n    \"rust\",\n    \"salt\",\n    \"salt-lint\",\n    \"sas\",\n    \"sass\",\n    \"sbt\",\n    \"scala\",\n    \"scheme\",\n    \"scons\",\n    \"scss\",\n    \"sh\",\n    \"shell\",\n    \"sln\",\n    \"slnx\",\n    \"socket\",\n    \"solidity\",\n    \"spec\",\n    \"sql\",\n    \"stylus\",\n    \"svelte\",\n    \"svg\",\n    \"swf\",\n    \"swift\",\n    \"swiftdeps\",\n    \"symlink\",\n    \"system-verilog\",\n    \"tar\",\n    \"tcsh\",\n    \"templ\",\n    \"terraform\",\n    \"tex\",\n    \"text\",\n    \"textproto\",\n    \"thrift\",\n    \"tiff\",\n    \"tiltfile\",\n    \"toml\",\n    \"ts\",\n    \"tsv\",\n    \"tsx\",\n    \"ttf\",\n    \"twig\",\n    \"twisted\",\n    \"txsprofile\",\n    \"urdf\",\n    \"vb\",\n    \"vbproj\",\n    \"vcxproj\",\n    \"vdx\",\n    \"verilog\",\n    \"vhdl\",\n    \"vim\",\n    \"vtl\",\n    \"vue\",\n    \"wav\",\n    \"webp\",\n    \"wheel\",\n    \"wkt\",\n    \"woff\",\n    \"woff2\",\n    \"wsdl\",\n    \"wsgi\",\n    \"xacro\",\n    \"xaml\",\n    \"xcconfig\",\n    \"xcodebuild\",\n    \"xcprivacy\",\n    \"xcscheme\",\n    \"xcsettings\",\n    \"xctestplan\",\n    \"xcworkspacedata\",\n    \"xhtml\",\n    \"xliff\",\n    \"xml\",\n    \"xquery\",\n    \"xsd\",\n    \"xsl\",\n    \"yaml\",\n    \"yamlld\",\n    \"yamllint\",\n    \"yang\",\n    \"yin\",\n    \"zcml\",\n    \"zig\",\n    \"zip\",\n    \"zpt\",\n    \"zsh\",\n];\n\npub const TAG_FILE: u16 = 78;\npub const TAG_DIRECTORY: u16 = 58;\npub const TAG_SYMLINK: u16 = 248;\npub const TAG_SOCKET: u16 = 238;\npub const TAG_EXECUTABLE: u16 = 74;\npub const TAG_NON_EXECUTABLE: u16 = 176;\npub const TAG_TEXT: u16 = 255;\npub const TAG_BINARY: u16 = 21;\n\npub const TAG_SET_FILE: TagSet = TagSet::new(&[78]);\npub const TAG_SET_DIRECTORY: TagSet = TagSet::new(&[58]);\npub const TAG_SET_SYMLINK: TagSet = TagSet::new(&[248]);\npub const TAG_SET_SOCKET: TagSet = TagSet::new(&[238]);\npub const TAG_SET_TEXT: TagSet = TagSet::new(&[255]);\npub const TAG_SET_TEXT_OR_BINARY: TagSet = TagSet::new(&[21, 255]);\npub const TAG_SET_EXECUTABLE_TEXT: TagSet = TagSet::new(&[74, 255]);\npub const TAG_SET_JSON: TagSet = TagSet::new(&[135]);\npub const TAG_SET_JSON5: TagSet = TagSet::new(&[136]);\npub const TAG_SET_TOML: TagSet = TagSet::new(&[260]);\npub const TAG_SET_XML: TagSet = TagSet::new(&[297]);\npub const TAG_SET_YAML: TagSet = TagSet::new(&[301]);\n\npub const INTERPRETERS: phf::Map<&str, TagSet> = phf::phf_map! {\n    // [\"ash\", \"shell\"]\n    \"ash\" => TagSet::new(&[5, 235]),\n    // [\"awk\"]\n    \"awk\" => TagSet::new(&[12]),\n    // [\"bash\", \"shell\"]\n    \"bash\" => TagSet::new(&[14, 235]),\n    // [\"bash\", \"bats\", \"shell\"]\n    \"bats\" => TagSet::new(&[14, 16, 235]),\n    // [\"cbsd\", \"shell\"]\n    \"cbsd\" => TagSet::new(&[35, 235]),\n    // [\"csh\", \"shell\"]\n    \"csh\" => TagSet::new(&[43, 235]),\n    // [\"dash\", \"shell\"]\n    \"dash\" => TagSet::new(&[54, 235]),\n    // [\"erlang\"]\n    \"escript\" => TagSet::new(&[73]),\n    // [\"expect\"]\n    \"expect\" => TagSet::new(&[75]),\n    // [\"ksh\", \"shell\"]\n    \"ksh\" => TagSet::new(&[144, 235]),\n    // [\"javascript\"]\n    \"node\" => TagSet::new(&[129]),\n    // [\"javascript\"]\n    \"nodejs\" => TagSet::new(&[129]),\n    // [\"perl\"]\n    \"perl\" => TagSet::new(&[188]),\n    // [\"php\"]\n    \"php\" => TagSet::new(&[189]),\n    // [\"php\", \"php7\"]\n    \"php7\" => TagSet::new(&[189, 190]),\n    // [\"php\", \"php8\"]\n    \"php8\" => TagSet::new(&[189, 191]),\n    // [\"python\"]\n    \"python\" => TagSet::new(&[213]),\n    // [\"python\", \"python2\"]\n    \"python2\" => TagSet::new(&[213, 214]),\n    // [\"python\", \"python3\"]\n    \"python3\" => TagSet::new(&[213, 215]),\n    // [\"ruby\"]\n    \"ruby\" => TagSet::new(&[223]),\n    // [\"sh\", \"shell\"]\n    \"sh\" => TagSet::new(&[234, 235]),\n    // [\"shell\", \"tcsh\"]\n    \"tcsh\" => TagSet::new(&[235, 251]),\n    // [\"shell\", \"zsh\"]\n    \"zsh\" => TagSet::new(&[235, 310]),\n};\n\npub const EXTENSIONS: phf::Map<&str, TagSet> = phf::phf_map! {\n    // [\"asciidoc\", \"text\"]\n    \"adoc\" => TagSet::new(&[4, 255]),\n    // [\"adobe-illustrator\", \"binary\"]\n    \"ai\" => TagSet::new(&[0, 21]),\n    // [\"aspectj\", \"text\"]\n    \"aj\" => TagSet::new(&[7, 255]),\n    // [\"apinotes\", \"text\"]\n    \"apinotes\" => TagSet::new(&[2, 255]),\n    // [\"asar\", \"binary\"]\n    \"asar\" => TagSet::new(&[3, 21]),\n    // [\"asciidoc\", \"text\"]\n    \"asciidoc\" => TagSet::new(&[4, 255]),\n    // [\"asm\", \"text\"]\n    \"asm\" => TagSet::new(&[6, 255]),\n    // [\"astro\", \"text\"]\n    \"astro\" => TagSet::new(&[8, 255]),\n    // [\"avif\", \"binary\", \"image\"]\n    \"avif\" => TagSet::new(&[10, 21, 117]),\n    // [\"avro-schema\", \"text\"]\n    \"avsc\" => TagSet::new(&[11, 255]),\n    // [\"bash\", \"shell\", \"text\"]\n    \"bash\" => TagSet::new(&[14, 235, 255]),\n    // [\"batch\", \"text\"]\n    \"bat\" => TagSet::new(&[15, 255]),\n    // [\"bash\", \"bats\", \"shell\", \"text\"]\n    \"bats\" => TagSet::new(&[14, 16, 235, 255]),\n    // [\"bazel\", \"text\"]\n    \"bazel\" => TagSet::new(&[17, 255]),\n    // [\"bitbake\", \"text\"]\n    \"bb\" => TagSet::new(&[22, 255]),\n    // [\"bitbake\", \"text\"]\n    \"bbappend\" => TagSet::new(&[22, 255]),\n    // [\"bitbake\", \"text\"]\n    \"bbclass\" => TagSet::new(&[22, 255]),\n    // [\"beancount\", \"text\"]\n    \"beancount\" => TagSet::new(&[19, 255]),\n    // [\"bib\", \"text\"]\n    \"bib\" => TagSet::new(&[20, 255]),\n    // [\"binary\", \"bitmap\", \"image\"]\n    \"bmp\" => TagSet::new(&[21, 23, 117]),\n    // [\"binary\", \"bzip2\"]\n    \"bz2\" => TagSet::new(&[21, 26]),\n    // [\"binary\", \"bzip3\"]\n    \"bz3\" => TagSet::new(&[21, 27]),\n    // [\"bazel\", \"text\"]\n    \"bzl\" => TagSet::new(&[17, 255]),\n    // [\"c\", \"text\"]\n    \"c\" => TagSet::new(&[28, 255]),\n    // [\"c++\", \"text\"]\n    \"c++\" => TagSet::new(&[31, 255]),\n    // [\"c++\", \"text\"]\n    \"c++m\" => TagSet::new(&[31, 255]),\n    // [\"c++\", \"text\"]\n    \"cc\" => TagSet::new(&[31, 255]),\n    // [\"c++\", \"text\"]\n    \"ccm\" => TagSet::new(&[31, 255]),\n    // [\"text\"]\n    \"cfg\" => TagSet::new(&[255]),\n    // [\"c2hs\", \"text\"]\n    \"chs\" => TagSet::new(&[32, 255]),\n    // [\"javascript\", \"text\"]\n    \"cjs\" => TagSet::new(&[129, 255]),\n    // [\"clojure\", \"text\"]\n    \"clj\" => TagSet::new(&[36, 255]),\n    // [\"clojure\", \"text\"]\n    \"cljc\" => TagSet::new(&[36, 255]),\n    // [\"clojure\", \"clojurescript\", \"text\"]\n    \"cljs\" => TagSet::new(&[36, 37, 255]),\n    // [\"cmake\", \"text\"]\n    \"cmake\" => TagSet::new(&[38, 255]),\n    // [\"batch\", \"text\"]\n    \"cmd\" => TagSet::new(&[15, 255]),\n    // [\"text\"]\n    \"cnf\" => TagSet::new(&[255]),\n    // [\"coffee\", \"text\"]\n    \"coffee\" => TagSet::new(&[40, 255]),\n    // [\"text\"]\n    \"conf\" => TagSet::new(&[255]),\n    // [\"c++\", \"text\"]\n    \"cpp\" => TagSet::new(&[31, 255]),\n    // [\"c++\", \"text\"]\n    \"cppm\" => TagSet::new(&[31, 255]),\n    // [\"crystal\", \"text\"]\n    \"cr\" => TagSet::new(&[42, 255]),\n    // [\"pem\", \"text\"]\n    \"crt\" => TagSet::new(&[187, 255]),\n    // [\"c#\", \"text\"]\n    \"cs\" => TagSet::new(&[29, 255]),\n    // [\"csh\", \"shell\", \"text\"]\n    \"csh\" => TagSet::new(&[43, 235, 255]),\n    // [\"cson\", \"text\"]\n    \"cson\" => TagSet::new(&[44, 255]),\n    // [\"csproj\", \"msbuild\", \"text\", \"xml\"]\n    \"csproj\" => TagSet::new(&[45, 168, 255, 297]),\n    // [\"css\", \"text\"]\n    \"css\" => TagSet::new(&[46, 255]),\n    // [\"csv\", \"text\"]\n    \"csv\" => TagSet::new(&[48, 255]),\n    // [\"c#\", \"c#script\", \"text\"]\n    \"csx\" => TagSet::new(&[29, 30, 255]),\n    // [\"cuda\", \"text\"]\n    \"cu\" => TagSet::new(&[49, 255]),\n    // [\"cue\", \"text\"]\n    \"cue\" => TagSet::new(&[50, 255]),\n    // [\"cuda\", \"text\"]\n    \"cuh\" => TagSet::new(&[49, 255]),\n    // [\"c++\", \"text\"]\n    \"cxx\" => TagSet::new(&[31, 255]),\n    // [\"c++\", \"text\"]\n    \"cxxm\" => TagSet::new(&[31, 255]),\n    // [\"cylc\", \"text\"]\n    \"cylc\" => TagSet::new(&[51, 255]),\n    // [\"dart\", \"text\"]\n    \"dart\" => TagSet::new(&[53, 255]),\n    // [\"dbc\", \"text\"]\n    \"dbc\" => TagSet::new(&[55, 255]),\n    // [\"def\", \"text\"]\n    \"def\" => TagSet::new(&[56, 255]),\n    // [\"diff\", \"text\"]\n    \"diff\" => TagSet::new(&[57, 255]),\n    // [\"binary\"]\n    \"dll\" => TagSet::new(&[21]),\n    // [\"dtd\", \"text\"]\n    \"dtd\" => TagSet::new(&[62, 255]),\n    // [\"binary\", \"jar\", \"zip\"]\n    \"ear\" => TagSet::new(&[21, 126, 308]),\n    // [\"clojure\", \"edn\", \"text\"]\n    \"edn\" => TagSet::new(&[36, 64, 255]),\n    // [\"ejs\", \"text\"]\n    \"ejs\" => TagSet::new(&[65, 255]),\n    // [\"ejson\", \"json\", \"text\"]\n    \"ejson\" => TagSet::new(&[66, 135, 255]),\n    // [\"elm\", \"text\"]\n    \"elm\" => TagSet::new(&[68, 255]),\n    // [\"entitlements\", \"plist\"]\n    \"entitlements\" => TagSet::new(&[69, 196]),\n    // [\"dotenv\", \"text\"]\n    \"env\" => TagSet::new(&[61, 255]),\n    // [\"binary\", \"eot\"]\n    \"eot\" => TagSet::new(&[21, 70]),\n    // [\"binary\", \"eps\"]\n    \"eps\" => TagSet::new(&[21, 71]),\n    // [\"erb\", \"text\"]\n    \"erb\" => TagSet::new(&[72, 255]),\n    // [\"erlang\", \"text\"]\n    \"erl\" => TagSet::new(&[73, 255]),\n    // [\"erlang\", \"text\"]\n    \"escript\" => TagSet::new(&[73, 255]),\n    // [\"elixir\", \"text\"]\n    \"ex\" => TagSet::new(&[67, 255]),\n    // [\"binary\"]\n    \"exe\" => TagSet::new(&[21]),\n    // [\"elixir\", \"text\"]\n    \"exs\" => TagSet::new(&[67, 255]),\n    // [\"text\", \"yaml\"]\n    \"eyaml\" => TagSet::new(&[255, 301]),\n    // [\"fortran\", \"text\"]\n    \"f03\" => TagSet::new(&[82, 255]),\n    // [\"fortran\", \"text\"]\n    \"f08\" => TagSet::new(&[82, 255]),\n    // [\"fortran\", \"text\"]\n    \"f90\" => TagSet::new(&[82, 255]),\n    // [\"fortran\", \"text\"]\n    \"f95\" => TagSet::new(&[82, 255]),\n    // [\"gherkin\", \"text\"]\n    \"feature\" => TagSet::new(&[87, 255]),\n    // [\"fish\", \"text\"]\n    \"fish\" => TagSet::new(&[79, 255]),\n    // [\"binary\", \"fits\"]\n    \"fits\" => TagSet::new(&[21, 80]),\n    // [\"f#\", \"text\"]\n    \"fs\" => TagSet::new(&[76, 255]),\n    // [\"fsproj\", \"msbuild\", \"text\", \"xml\"]\n    \"fsproj\" => TagSet::new(&[83, 168, 255, 297]),\n    // [\"f#\", \"f#script\", \"text\"]\n    \"fsx\" => TagSet::new(&[76, 77, 255]),\n    // [\"gdscript\", \"text\"]\n    \"gd\" => TagSet::new(&[84, 255]),\n    // [\"ruby\", \"text\"]\n    \"gemspec\" => TagSet::new(&[223, 255]),\n    // [\"geojson\", \"json\", \"text\"]\n    \"geojson\" => TagSet::new(&[85, 135, 255]),\n    // [\"binary\", \"ggb\", \"zip\"]\n    \"ggb\" => TagSet::new(&[21, 86, 308]),\n    // [\"binary\", \"gif\", \"image\"]\n    \"gif\" => TagSet::new(&[21, 88, 117]),\n    // [\"gleam\", \"text\"]\n    \"gleam\" => TagSet::new(&[94, 255]),\n    // [\"go\", \"text\"]\n    \"go\" => TagSet::new(&[95, 255]),\n    // [\"gotmpl\", \"text\"]\n    \"gotmpl\" => TagSet::new(&[98, 255]),\n    // [\"gpx\", \"text\", \"xml\"]\n    \"gpx\" => TagSet::new(&[99, 255, 297]),\n    // [\"groovy\", \"text\"]\n    \"gradle\" => TagSet::new(&[101, 255]),\n    // [\"graphql\", \"text\"]\n    \"graphql\" => TagSet::new(&[100, 255]),\n    // [\"groovy\", \"text\"]\n    \"groovy\" => TagSet::new(&[101, 255]),\n    // [\"gyb\", \"text\"]\n    \"gyb\" => TagSet::new(&[102, 255]),\n    // [\"gyp\", \"python\", \"text\"]\n    \"gyp\" => TagSet::new(&[103, 213, 255]),\n    // [\"gyp\", \"python\", \"text\"]\n    \"gypi\" => TagSet::new(&[103, 213, 255]),\n    // [\"binary\", \"gzip\"]\n    \"gz\" => TagSet::new(&[21, 104]),\n    // [\"c\", \"c++\", \"header\", \"text\"]\n    \"h\" => TagSet::new(&[28, 31, 108, 255]),\n    // [\"handlebars\", \"text\"]\n    \"hbs\" => TagSet::new(&[105, 255]),\n    // [\"hcl\", \"text\"]\n    \"hcl\" => TagSet::new(&[107, 255]),\n    // [\"c++\", \"header\", \"text\"]\n    \"hh\" => TagSet::new(&[31, 108, 255]),\n    // [\"hlsl\", \"text\"]\n    \"hlsl\" => TagSet::new(&[110, 255]),\n    // [\"hlsl\", \"text\"]\n    \"hlsli\" => TagSet::new(&[110, 255]),\n    // [\"c++\", \"header\", \"text\"]\n    \"hpp\" => TagSet::new(&[31, 108, 255]),\n    // [\"erlang\", \"text\"]\n    \"hrl\" => TagSet::new(&[73, 255]),\n    // [\"haskell\", \"text\"]\n    \"hs\" => TagSet::new(&[106, 255]),\n    // [\"html\", \"text\"]\n    \"htm\" => TagSet::new(&[111, 255]),\n    // [\"html\", \"text\"]\n    \"html\" => TagSet::new(&[111, 255]),\n    // [\"c++\", \"header\", \"text\"]\n    \"hxx\" => TagSet::new(&[31, 108, 255]),\n    // [\"binary\", \"icns\"]\n    \"icns\" => TagSet::new(&[21, 113]),\n    // [\"binary\", \"icon\"]\n    \"ico\" => TagSet::new(&[21, 114]),\n    // [\"icalendar\", \"text\"]\n    \"ics\" => TagSet::new(&[112, 255]),\n    // [\"idl\", \"text\"]\n    \"idl\" => TagSet::new(&[115, 255]),\n    // [\"idris\", \"text\"]\n    \"idr\" => TagSet::new(&[116, 255]),\n    // [\"inc\", \"text\"]\n    \"inc\" => TagSet::new(&[118, 255]),\n    // [\"ini\", \"text\"]\n    \"ini\" => TagSet::new(&[119, 255]),\n    // [\"c++\", \"inl\", \"text\"]\n    \"inl\" => TagSet::new(&[31, 120, 255]),\n    // [\"c++\", \"ino\", \"text\"]\n    \"ino\" => TagSet::new(&[31, 121, 255]),\n    // [\"inx\", \"text\", \"xml\"]\n    \"inx\" => TagSet::new(&[122, 255, 297]),\n    // [\"c++\", \"text\"]\n    \"ipp\" => TagSet::new(&[31, 255]),\n    // [\"ipxe\", \"text\"]\n    \"ipxe\" => TagSet::new(&[123, 255]),\n    // [\"json\", \"jupyter\", \"text\"]\n    \"ipynb\" => TagSet::new(&[135, 141, 255]),\n    // [\"c++\", \"text\"]\n    \"ixx\" => TagSet::new(&[31, 255]),\n    // [\"jinja\", \"text\"]\n    \"j2\" => TagSet::new(&[132, 255]),\n    // [\"jade\", \"text\"]\n    \"jade\" => TagSet::new(&[125, 255]),\n    // [\"binary\", \"jar\", \"zip\"]\n    \"jar\" => TagSet::new(&[21, 126, 308]),\n    // [\"java\", \"text\"]\n    \"java\" => TagSet::new(&[127, 255]),\n    // [\"jbuilder\", \"ruby\", \"text\"]\n    \"jbuilder\" => TagSet::new(&[130, 223, 255]),\n    // [\"groovy\", \"jenkins\", \"text\"]\n    \"jenkins\" => TagSet::new(&[101, 131, 255]),\n    // [\"groovy\", \"jenkins\", \"text\"]\n    \"jenkinsfile\" => TagSet::new(&[101, 131, 255]),\n    // [\"jinja\", \"text\"]\n    \"jinja\" => TagSet::new(&[132, 255]),\n    // [\"jinja\", \"text\"]\n    \"jinja2\" => TagSet::new(&[132, 255]),\n    // [\"julia\", \"text\"]\n    \"jl\" => TagSet::new(&[140, 255]),\n    // [\"binary\", \"image\", \"jpeg\"]\n    \"jpeg\" => TagSet::new(&[21, 117, 133]),\n    // [\"binary\", \"image\", \"jpeg\"]\n    \"jpg\" => TagSet::new(&[21, 117, 133]),\n    // [\"javascript\", \"text\"]\n    \"js\" => TagSet::new(&[129, 255]),\n    // [\"json\", \"text\"]\n    \"json\" => TagSet::new(&[135, 255]),\n    // [\"json5\", \"text\"]\n    \"json5\" => TagSet::new(&[136, 255]),\n    // [\"json\", \"jsonld\", \"text\"]\n    \"jsonld\" => TagSet::new(&[135, 137, 255]),\n    // [\"jsonnet\", \"text\"]\n    \"jsonnet\" => TagSet::new(&[138, 255]),\n    // [\"jsx\", \"text\"]\n    \"jsx\" => TagSet::new(&[139, 255]),\n    // [\"pem\", \"text\"]\n    \"key\" => TagSet::new(&[187, 255]),\n    // [\"kml\", \"text\", \"xml\"]\n    \"kml\" => TagSet::new(&[142, 255, 297]),\n    // [\"kotlin\", \"text\"]\n    \"kt\" => TagSet::new(&[143, 255]),\n    // [\"kotlin\", \"text\"]\n    \"kts\" => TagSet::new(&[143, 255]),\n    // [\"lean\", \"text\"]\n    \"lean\" => TagSet::new(&[147, 255]),\n    // [\"ini\", \"lektorproject\", \"text\"]\n    \"lektorproject\" => TagSet::new(&[119, 149, 255]),\n    // [\"less\", \"text\"]\n    \"less\" => TagSet::new(&[150, 255]),\n    // [\"lazarus\", \"lazarus-form\", \"text\"]\n    \"lfm\" => TagSet::new(&[145, 146, 255]),\n    // [\"literate-haskell\", \"text\"]\n    \"lhs\" => TagSet::new(&[152, 255]),\n    // [\"jsonnet\", \"text\"]\n    \"libsonnet\" => TagSet::new(&[138, 255]),\n    // [\"idris\", \"text\"]\n    \"lidr\" => TagSet::new(&[116, 255]),\n    // [\"liquid\", \"text\"]\n    \"liquid\" => TagSet::new(&[151, 255]),\n    // [\"lazarus\", \"text\", \"xml\"]\n    \"lpi\" => TagSet::new(&[145, 255, 297]),\n    // [\"lazarus\", \"pascal\", \"text\"]\n    \"lpr\" => TagSet::new(&[145, 184, 255]),\n    // [\"lektor\", \"text\"]\n    \"lr\" => TagSet::new(&[148, 255]),\n    // [\"lua\", \"text\"]\n    \"lua\" => TagSet::new(&[153, 255]),\n    // [\"objective-c\", \"text\"]\n    \"m\" => TagSet::new(&[179, 255]),\n    // [\"m4\", \"text\"]\n    \"m4\" => TagSet::new(&[154, 255]),\n    // [\"magik\", \"text\"]\n    \"magik\" => TagSet::new(&[155, 255]),\n    // [\"makefile\", \"text\"]\n    \"make\" => TagSet::new(&[157, 255]),\n    // [\"manifest\", \"text\"]\n    \"manifest\" => TagSet::new(&[158, 255]),\n    // [\"map\", \"text\"]\n    \"map\" => TagSet::new(&[159, 255]),\n    // [\"markdown\", \"text\"]\n    \"markdown\" => TagSet::new(&[160, 255]),\n    // [\"markdown\", \"text\"]\n    \"md\" => TagSet::new(&[160, 255]),\n    // [\"mdx\", \"text\"]\n    \"mdx\" => TagSet::new(&[161, 255]),\n    // [\"meson\", \"text\"]\n    \"meson\" => TagSet::new(&[163, 255]),\n    // [\"metal\", \"text\"]\n    \"metal\" => TagSet::new(&[165, 255]),\n    // [\"mib\", \"text\"]\n    \"mib\" => TagSet::new(&[166, 255]),\n    // [\"javascript\", \"text\"]\n    \"mjs\" => TagSet::new(&[129, 255]),\n    // [\"makefile\", \"text\"]\n    \"mk\" => TagSet::new(&[157, 255]),\n    // [\"ocaml\", \"text\"]\n    \"ml\" => TagSet::new(&[181, 255]),\n    // [\"ocaml\", \"text\"]\n    \"mli\" => TagSet::new(&[181, 255]),\n    // [\"c++\", \"objective-c++\", \"text\"]\n    \"mm\" => TagSet::new(&[31, 180, 255]),\n    // [\"modulemap\", \"text\"]\n    \"modulemap\" => TagSet::new(&[167, 255]),\n    // [\"musescore\", \"text\", \"xml\"]\n    \"mscx\" => TagSet::new(&[169, 255, 297]),\n    // [\"binary\", \"musescore\", \"zip\"]\n    \"mscz\" => TagSet::new(&[21, 169, 308]),\n    // [\"mustache\", \"text\"]\n    \"mustache\" => TagSet::new(&[170, 255]),\n    // [\"myst\", \"text\"]\n    \"myst\" => TagSet::new(&[171, 255]),\n    // [\"ngdoc\", \"text\"]\n    \"ngdoc\" => TagSet::new(&[172, 255]),\n    // [\"nim\", \"text\"]\n    \"nim\" => TagSet::new(&[173, 255]),\n    // [\"nimble\", \"text\"]\n    \"nimble\" => TagSet::new(&[174, 255]),\n    // [\"nim\", \"text\"]\n    \"nims\" => TagSet::new(&[173, 255]),\n    // [\"nix\", \"text\"]\n    \"nix\" => TagSet::new(&[175, 255]),\n    // [\"nunjucks\", \"text\"]\n    \"njk\" => TagSet::new(&[178, 255]),\n    // [\"binary\", \"otf\"]\n    \"otf\" => TagSet::new(&[21, 182]),\n    // [\"binary\", \"p12\"]\n    \"p12\" => TagSet::new(&[21, 183]),\n    // [\"pascal\", \"text\"]\n    \"pas\" => TagSet::new(&[184, 255]),\n    // [\"diff\", \"text\"]\n    \"patch\" => TagSet::new(&[57, 255]),\n    // [\"binary\", \"pdf\"]\n    \"pdf\" => TagSet::new(&[21, 186]),\n    // [\"pem\", \"text\"]\n    \"pem\" => TagSet::new(&[187, 255]),\n    // [\"php\", \"text\"]\n    \"php\" => TagSet::new(&[189, 255]),\n    // [\"php\", \"text\"]\n    \"php4\" => TagSet::new(&[189, 255]),\n    // [\"php\", \"text\"]\n    \"php5\" => TagSet::new(&[189, 255]),\n    // [\"php\", \"text\"]\n    \"phtml\" => TagSet::new(&[189, 255]),\n    // [\"json\", \"piskel\", \"text\"]\n    \"piskel\" => TagSet::new(&[135, 192, 255]),\n    // [\"perl\", \"text\"]\n    \"pl\" => TagSet::new(&[188, 255]),\n    // [\"plantuml\", \"text\"]\n    \"plantuml\" => TagSet::new(&[195, 255]),\n    // [\"plist\"]\n    \"plist\" => TagSet::new(&[196]),\n    // [\"perl\", \"text\"]\n    \"pm\" => TagSet::new(&[188, 255]),\n    // [\"binary\", \"image\", \"png\"]\n    \"png\" => TagSet::new(&[21, 117, 197]),\n    // [\"pofile\", \"text\"]\n    \"po\" => TagSet::new(&[198, 255]),\n    // [\"pom\", \"text\", \"xml\"]\n    \"pom\" => TagSet::new(&[199, 255, 297]),\n    // [\"puppet\", \"text\"]\n    \"pp\" => TagSet::new(&[206, 255]),\n    // [\"image\", \"ppm\"]\n    \"ppm\" => TagSet::new(&[117, 201]),\n    // [\"prisma\", \"text\"]\n    \"prisma\" => TagSet::new(&[203, 255]),\n    // [\"java-properties\", \"text\"]\n    \"properties\" => TagSet::new(&[128, 255]),\n    // [\"msbuild\", \"text\", \"xml\"]\n    \"props\" => TagSet::new(&[168, 255, 297]),\n    // [\"proto\", \"text\"]\n    \"proto\" => TagSet::new(&[204, 255]),\n    // [\"powershell\", \"text\"]\n    \"ps1\" => TagSet::new(&[200, 255]),\n    // [\"powershell\", \"text\"]\n    \"psd1\" => TagSet::new(&[200, 255]),\n    // [\"powershell\", \"text\"]\n    \"psm1\" => TagSet::new(&[200, 255]),\n    // [\"pug\", \"text\"]\n    \"pug\" => TagSet::new(&[205, 255]),\n    // [\"plantuml\", \"text\"]\n    \"puml\" => TagSet::new(&[195, 255]),\n    // [\"purescript\", \"text\"]\n    \"purs\" => TagSet::new(&[207, 255]),\n    // [\"cython\", \"text\"]\n    \"pxd\" => TagSet::new(&[52, 255]),\n    // [\"cython\", \"text\"]\n    \"pxi\" => TagSet::new(&[52, 255]),\n    // [\"python\", \"text\"]\n    \"py\" => TagSet::new(&[213, 255]),\n    // [\"pyi\", \"text\"]\n    \"pyi\" => TagSet::new(&[208, 255]),\n    // [\"msbuild\", \"pyproj\", \"text\", \"xml\"]\n    \"pyproj\" => TagSet::new(&[168, 211, 255, 297]),\n    // [\"python\", \"text\"]\n    \"pyt\" => TagSet::new(&[213, 255]),\n    // [\"python\", \"text\"]\n    \"pyw\" => TagSet::new(&[213, 255]),\n    // [\"cython\", \"text\"]\n    \"pyx\" => TagSet::new(&[52, 255]),\n    // [\"binary\", \"pyz\"]\n    \"pyz\" => TagSet::new(&[21, 216]),\n    // [\"binary\", \"pyz\"]\n    \"pyzw\" => TagSet::new(&[21, 216]),\n    // [\"qml\", \"text\"]\n    \"qml\" => TagSet::new(&[217, 255]),\n    // [\"r\", \"text\"]\n    \"r\" => TagSet::new(&[218, 255]),\n    // [\"ruby\", \"text\"]\n    \"rake\" => TagSet::new(&[223, 255]),\n    // [\"ruby\", \"text\"]\n    \"rb\" => TagSet::new(&[223, 255]),\n    // [\"resx\", \"text\", \"xml\"]\n    \"resx\" => TagSet::new(&[220, 255, 297]),\n    // [\"relax-ng\", \"text\", \"xml\"]\n    \"rng\" => TagSet::new(&[219, 255, 297]),\n    // [\"robot\", \"text\"]\n    \"robot\" => TagSet::new(&[221, 255]),\n    // [\"rust\", \"text\"]\n    \"rs\" => TagSet::new(&[224, 255]),\n    // [\"rst\", \"text\"]\n    \"rst\" => TagSet::new(&[222, 255]),\n    // [\"asm\", \"text\"]\n    \"s\" => TagSet::new(&[6, 255]),\n    // [\"sas\", \"text\"]\n    \"sas\" => TagSet::new(&[227, 255]),\n    // [\"sass\", \"text\"]\n    \"sass\" => TagSet::new(&[228, 255]),\n    // [\"sbt\", \"scala\", \"text\"]\n    \"sbt\" => TagSet::new(&[229, 230, 255]),\n    // [\"scala\", \"text\"]\n    \"sc\" => TagSet::new(&[230, 255]),\n    // [\"scala\", \"text\"]\n    \"scala\" => TagSet::new(&[230, 255]),\n    // [\"scheme\", \"text\"]\n    \"scm\" => TagSet::new(&[231, 255]),\n    // [\"scss\", \"text\"]\n    \"scss\" => TagSet::new(&[233, 255]),\n    // [\"shell\", \"text\"]\n    \"sh\" => TagSet::new(&[235, 255]),\n    // [\"sln\", \"text\"]\n    \"sln\" => TagSet::new(&[236, 255]),\n    // [\"msbuild\", \"slnx\", \"text\", \"xml\"]\n    \"slnx\" => TagSet::new(&[168, 237, 255, 297]),\n    // [\"salt\", \"text\"]\n    \"sls\" => TagSet::new(&[225, 255]),\n    // [\"binary\"]\n    \"so\" => TagSet::new(&[21]),\n    // [\"solidity\", \"text\"]\n    \"sol\" => TagSet::new(&[239, 255]),\n    // [\"spec\", \"text\"]\n    \"spec\" => TagSet::new(&[240, 255]),\n    // [\"sql\", \"text\"]\n    \"sql\" => TagSet::new(&[241, 255]),\n    // [\"scheme\", \"text\"]\n    \"ss\" => TagSet::new(&[231, 255]),\n    // [\"tex\", \"text\"]\n    \"sty\" => TagSet::new(&[254, 255]),\n    // [\"stylus\", \"text\"]\n    \"styl\" => TagSet::new(&[242, 255]),\n    // [\"system-verilog\", \"text\"]\n    \"sv\" => TagSet::new(&[249, 255]),\n    // [\"svelte\", \"text\"]\n    \"svelte\" => TagSet::new(&[243, 255]),\n    // [\"image\", \"svg\", \"text\", \"xml\"]\n    \"svg\" => TagSet::new(&[117, 244, 255, 297]),\n    // [\"system-verilog\", \"text\"]\n    \"svh\" => TagSet::new(&[249, 255]),\n    // [\"binary\", \"swf\"]\n    \"swf\" => TagSet::new(&[21, 245]),\n    // [\"swift\", \"text\"]\n    \"swift\" => TagSet::new(&[246, 255]),\n    // [\"swiftdeps\", \"text\"]\n    \"swiftdeps\" => TagSet::new(&[247, 255]),\n    // [\"python\", \"text\", \"twisted\"]\n    \"tac\" => TagSet::new(&[213, 255, 266]),\n    // [\"binary\", \"tar\"]\n    \"tar\" => TagSet::new(&[21, 250]),\n    // [\"msbuild\", \"text\", \"xml\"]\n    \"targets\" => TagSet::new(&[168, 255, 297]),\n    // [\"templ\", \"text\"]\n    \"templ\" => TagSet::new(&[252, 255]),\n    // [\"tex\", \"text\"]\n    \"tex\" => TagSet::new(&[254, 255]),\n    // [\"text\", \"textproto\"]\n    \"textproto\" => TagSet::new(&[255, 256]),\n    // [\"terraform\", \"text\"]\n    \"tf\" => TagSet::new(&[253, 255]),\n    // [\"terraform\", \"text\"]\n    \"tfvars\" => TagSet::new(&[253, 255]),\n    // [\"binary\", \"gzip\"]\n    \"tgz\" => TagSet::new(&[21, 104]),\n    // [\"text\", \"thrift\"]\n    \"thrift\" => TagSet::new(&[255, 257]),\n    // [\"binary\", \"image\", \"tiff\"]\n    \"tiff\" => TagSet::new(&[21, 117, 258]),\n    // [\"text\", \"toml\"]\n    \"toml\" => TagSet::new(&[255, 260]),\n    // [\"c++\", \"text\"]\n    \"tpp\" => TagSet::new(&[31, 255]),\n    // [\"text\", \"ts\"]\n    \"ts\" => TagSet::new(&[255, 261]),\n    // [\"text\", \"tsv\"]\n    \"tsv\" => TagSet::new(&[255, 262]),\n    // [\"text\", \"tsx\"]\n    \"tsx\" => TagSet::new(&[255, 263]),\n    // [\"binary\", \"ttf\"]\n    \"ttf\" => TagSet::new(&[21, 264]),\n    // [\"text\", \"twig\"]\n    \"twig\" => TagSet::new(&[255, 265]),\n    // [\"ini\", \"text\", \"txsprofile\"]\n    \"txsprofile\" => TagSet::new(&[119, 255, 267]),\n    // [\"plain-text\", \"text\"]\n    \"txt\" => TagSet::new(&[194, 255]),\n    // [\"text\", \"textproto\"]\n    \"txtpb\" => TagSet::new(&[255, 256]),\n    // [\"text\", \"urdf\", \"xml\"]\n    \"urdf\" => TagSet::new(&[255, 268, 297]),\n    // [\"text\", \"verilog\"]\n    \"v\" => TagSet::new(&[255, 273]),\n    // [\"text\", \"vb\"]\n    \"vb\" => TagSet::new(&[255, 269]),\n    // [\"msbuild\", \"text\", \"vbproj\", \"xml\"]\n    \"vbproj\" => TagSet::new(&[168, 255, 270, 297]),\n    // [\"msbuild\", \"text\", \"vcxproj\", \"xml\"]\n    \"vcxproj\" => TagSet::new(&[168, 255, 271, 297]),\n    // [\"text\", \"vdx\"]\n    \"vdx\" => TagSet::new(&[255, 272]),\n    // [\"text\", \"verilog\"]\n    \"vh\" => TagSet::new(&[255, 273]),\n    // [\"text\", \"vhdl\"]\n    \"vhd\" => TagSet::new(&[255, 274]),\n    // [\"text\", \"vim\"]\n    \"vim\" => TagSet::new(&[255, 275]),\n    // [\"text\", \"vtl\"]\n    \"vtl\" => TagSet::new(&[255, 276]),\n    // [\"text\", \"vue\"]\n    \"vue\" => TagSet::new(&[255, 277]),\n    // [\"binary\", \"jar\", \"zip\"]\n    \"war\" => TagSet::new(&[21, 126, 308]),\n    // [\"audio\", \"binary\", \"wav\"]\n    \"wav\" => TagSet::new(&[9, 21, 278]),\n    // [\"binary\", \"image\", \"webp\"]\n    \"webp\" => TagSet::new(&[21, 117, 279]),\n    // [\"binary\", \"wheel\", \"zip\"]\n    \"whl\" => TagSet::new(&[21, 280, 308]),\n    // [\"text\", \"wkt\"]\n    \"wkt\" => TagSet::new(&[255, 281]),\n    // [\"binary\", \"woff\"]\n    \"woff\" => TagSet::new(&[21, 282]),\n    // [\"binary\", \"woff2\"]\n    \"woff2\" => TagSet::new(&[21, 283]),\n    // [\"text\", \"wsdl\", \"xml\"]\n    \"wsdl\" => TagSet::new(&[255, 284, 297]),\n    // [\"python\", \"text\", \"wsgi\"]\n    \"wsgi\" => TagSet::new(&[213, 255, 285]),\n    // [\"text\", \"urdf\", \"xacro\", \"xml\"]\n    \"xacro\" => TagSet::new(&[255, 268, 286, 297]),\n    // [\"text\", \"xaml\", \"xml\"]\n    \"xaml\" => TagSet::new(&[255, 287, 297]),\n    // [\"text\", \"xcconfig\", \"xcodebuild\"]\n    \"xcconfig\" => TagSet::new(&[255, 288, 289]),\n    // [\"plist\", \"xcodebuild\", \"xcprivacy\"]\n    \"xcprivacy\" => TagSet::new(&[196, 289, 290]),\n    // [\"text\", \"xcodebuild\", \"xcscheme\", \"xml\"]\n    \"xcscheme\" => TagSet::new(&[255, 289, 291, 297]),\n    // [\"plist\", \"xcodebuild\", \"xcsettings\"]\n    \"xcsettings\" => TagSet::new(&[196, 289, 292]),\n    // [\"json\", \"text\", \"xcodebuild\", \"xctestplan\"]\n    \"xctestplan\" => TagSet::new(&[135, 255, 289, 293]),\n    // [\"text\", \"xcodebuild\", \"xcworkspacedata\", \"xml\"]\n    \"xcworkspacedata\" => TagSet::new(&[255, 289, 294, 297]),\n    // [\"html\", \"text\", \"xhtml\", \"xml\"]\n    \"xhtml\" => TagSet::new(&[111, 255, 295, 297]),\n    // [\"text\", \"xliff\", \"xml\"]\n    \"xlf\" => TagSet::new(&[255, 296, 297]),\n    // [\"text\", \"xliff\", \"xml\"]\n    \"xliff\" => TagSet::new(&[255, 296, 297]),\n    // [\"text\", \"xml\"]\n    \"xml\" => TagSet::new(&[255, 297]),\n    // [\"text\", \"xquery\"]\n    \"xq\" => TagSet::new(&[255, 298]),\n    // [\"text\", \"xquery\"]\n    \"xql\" => TagSet::new(&[255, 298]),\n    // [\"text\", \"xquery\"]\n    \"xqm\" => TagSet::new(&[255, 298]),\n    // [\"text\", \"xquery\"]\n    \"xqu\" => TagSet::new(&[255, 298]),\n    // [\"text\", \"xquery\"]\n    \"xquery\" => TagSet::new(&[255, 298]),\n    // [\"text\", \"xquery\"]\n    \"xqy\" => TagSet::new(&[255, 298]),\n    // [\"text\", \"xml\", \"xsd\"]\n    \"xsd\" => TagSet::new(&[255, 297, 299]),\n    // [\"text\", \"xml\", \"xsl\"]\n    \"xsl\" => TagSet::new(&[255, 297, 300]),\n    // [\"text\", \"xml\", \"xsl\"]\n    \"xslt\" => TagSet::new(&[255, 297, 300]),\n    // [\"text\", \"yaml\"]\n    \"yaml\" => TagSet::new(&[255, 301]),\n    // [\"text\", \"yaml\", \"yamlld\"]\n    \"yamlld\" => TagSet::new(&[255, 301, 302]),\n    // [\"text\", \"yang\"]\n    \"yang\" => TagSet::new(&[255, 304]),\n    // [\"text\", \"xml\", \"yin\"]\n    \"yin\" => TagSet::new(&[255, 297, 305]),\n    // [\"text\", \"yaml\"]\n    \"yml\" => TagSet::new(&[255, 301]),\n    // [\"text\", \"xml\", \"zcml\"]\n    \"zcml\" => TagSet::new(&[255, 297, 306]),\n    // [\"text\", \"zig\"]\n    \"zig\" => TagSet::new(&[255, 307]),\n    // [\"binary\", \"zip\"]\n    \"zip\" => TagSet::new(&[21, 308]),\n    // [\"text\", \"zpt\"]\n    \"zpt\" => TagSet::new(&[255, 309]),\n    // [\"shell\", \"text\", \"zsh\"]\n    \"zsh\" => TagSet::new(&[235, 255, 310]),\n};\n\npub const NAMES: phf::Map<&str, TagSet> = phf::phf_map! {\n    // [\"text\", \"yaml\"]\n    \".ansible-lint\" => TagSet::new(&[255, 301]),\n    // [\"babelrc\", \"json\", \"text\"]\n    \".babelrc\" => TagSet::new(&[13, 135, 255]),\n    // [\"bash\", \"shell\", \"text\"]\n    \".bash_aliases\" => TagSet::new(&[14, 235, 255]),\n    // [\"bash\", \"shell\", \"text\"]\n    \".bash_profile\" => TagSet::new(&[14, 235, 255]),\n    // [\"bash\", \"shell\", \"text\"]\n    \".bashrc\" => TagSet::new(&[14, 235, 255]),\n    // [\"bazelrc\", \"text\"]\n    \".bazelrc\" => TagSet::new(&[18, 255]),\n    // [\"bowerrc\", \"json\", \"text\"]\n    \".bowerrc\" => TagSet::new(&[24, 135, 255]),\n    // [\"browserslistrc\", \"text\"]\n    \".browserslistrc\" => TagSet::new(&[25, 255]),\n    // [\"text\", \"yaml\"]\n    \".clang-format\" => TagSet::new(&[255, 301]),\n    // [\"text\", \"yaml\"]\n    \".clang-tidy\" => TagSet::new(&[255, 301]),\n    // [\"codespellrc\", \"ini\", \"text\"]\n    \".codespellrc\" => TagSet::new(&[39, 119, 255]),\n    // [\"coveragerc\", \"ini\", \"text\"]\n    \".coveragerc\" => TagSet::new(&[41, 119, 255]),\n    // [\"csh\", \"shell\", \"text\"]\n    \".cshrc\" => TagSet::new(&[43, 235, 255]),\n    // [\"csslintrc\", \"json\", \"text\"]\n    \".csslintrc\" => TagSet::new(&[47, 135, 255]),\n    // [\"dockerignore\", \"text\"]\n    \".dockerignore\" => TagSet::new(&[60, 255]),\n    // [\"editorconfig\", \"text\"]\n    \".editorconfig\" => TagSet::new(&[63, 255]),\n    // [\"bash\", \"shell\", \"text\"]\n    \".envrc\" => TagSet::new(&[14, 235, 255]),\n    // [\"flake8\", \"ini\", \"text\"]\n    \".flake8\" => TagSet::new(&[81, 119, 255]),\n    // [\"gitattributes\", \"text\"]\n    \".gitattributes\" => TagSet::new(&[89, 255]),\n    // [\"gitconfig\", \"ini\", \"text\"]\n    \".gitconfig\" => TagSet::new(&[90, 119, 255]),\n    // [\"gitignore\", \"text\"]\n    \".gitignore\" => TagSet::new(&[91, 255]),\n    // [\"gitlint\", \"ini\", \"text\"]\n    \".gitlint\" => TagSet::new(&[92, 119, 255]),\n    // [\"gitmodules\", \"text\"]\n    \".gitmodules\" => TagSet::new(&[93, 255]),\n    // [\"hgrc\", \"ini\", \"text\"]\n    \".hgrc\" => TagSet::new(&[109, 119, 255]),\n    // [\"ini\", \"isort\", \"text\"]\n    \".isort.cfg\" => TagSet::new(&[119, 124, 255]),\n    // [\"jshintrc\", \"json\", \"text\"]\n    \".jshintrc\" => TagSet::new(&[134, 135, 255]),\n    // [\"mailmap\", \"text\"]\n    \".mailmap\" => TagSet::new(&[156, 255]),\n    // [\"json\", \"mention-bot\", \"text\"]\n    \".mention-bot\" => TagSet::new(&[135, 162, 255]),\n    // [\"npmignore\", \"text\"]\n    \".npmignore\" => TagSet::new(&[177, 255]),\n    // [\"pdbrc\", \"python\", \"text\"]\n    \".pdbrc\" => TagSet::new(&[185, 213, 255]),\n    // [\"gitignore\", \"prettierignore\", \"text\"]\n    \".prettierignore\" => TagSet::new(&[91, 202, 255]),\n    // [\"ini\", \"pypirc\", \"text\"]\n    \".pypirc\" => TagSet::new(&[119, 210, 255]),\n    // [\"ini\", \"text\"]\n    \".rstcheck.cfg\" => TagSet::new(&[119, 255]),\n    // [\"salt-lint\", \"text\", \"yaml\"]\n    \".salt-lint\" => TagSet::new(&[226, 255, 301]),\n    // [\"ini\", \"text\"]\n    \".sqlfluff\" => TagSet::new(&[119, 255]),\n    // [\"text\", \"yaml\", \"yamllint\"]\n    \".yamllint\" => TagSet::new(&[255, 301, 303]),\n    // [\"shell\", \"text\", \"zsh\"]\n    \".zlogin\" => TagSet::new(&[235, 255, 310]),\n    // [\"shell\", \"text\", \"zsh\"]\n    \".zlogout\" => TagSet::new(&[235, 255, 310]),\n    // [\"shell\", \"text\", \"zsh\"]\n    \".zprofile\" => TagSet::new(&[235, 255, 310]),\n    // [\"shell\", \"text\", \"zsh\"]\n    \".zshenv\" => TagSet::new(&[235, 255, 310]),\n    // [\"shell\", \"text\", \"zsh\"]\n    \".zshrc\" => TagSet::new(&[235, 255, 310]),\n    // [\"plain-text\", \"text\"]\n    \"AUTHORS\" => TagSet::new(&[194, 255]),\n    // [\"bazel\", \"text\"]\n    \"BUILD\" => TagSet::new(&[17, 255]),\n    // [\"ruby\", \"text\"]\n    \"Brewfile\" => TagSet::new(&[223, 255]),\n    // [\"plain-text\", \"text\"]\n    \"CHANGELOG\" => TagSet::new(&[194, 255]),\n    // [\"cmake\", \"text\"]\n    \"CMakeLists.txt\" => TagSet::new(&[38, 255]),\n    // [\"plain-text\", \"text\"]\n    \"CONTRIBUTING\" => TagSet::new(&[194, 255]),\n    // [\"plain-text\", \"text\"]\n    \"COPYING\" => TagSet::new(&[194, 255]),\n    // [\"cargo-lock\", \"text\", \"toml\"]\n    \"Cargo.lock\" => TagSet::new(&[34, 255, 260]),\n    // [\"cargo\", \"text\", \"toml\"]\n    \"Cargo.toml\" => TagSet::new(&[33, 255, 260]),\n    // [\"dockerfile\", \"text\"]\n    \"Containerfile\" => TagSet::new(&[59, 255]),\n    // [\"dockerfile\", \"text\"]\n    \"Dockerfile\" => TagSet::new(&[59, 255]),\n    // [\"ruby\", \"text\"]\n    \"Fastfile\" => TagSet::new(&[223, 255]),\n    // [\"makefile\", \"text\"]\n    \"GNUmakefile\" => TagSet::new(&[157, 255]),\n    // [\"ruby\", \"text\"]\n    \"Gemfile\" => TagSet::new(&[223, 255]),\n    // [\"text\"]\n    \"Gemfile.lock\" => TagSet::new(&[255]),\n    // [\"groovy\", \"jenkins\", \"text\"]\n    \"Jenkinsfile\" => TagSet::new(&[101, 131, 255]),\n    // [\"plain-text\", \"text\"]\n    \"LICENSE\" => TagSet::new(&[194, 255]),\n    // [\"plain-text\", \"text\"]\n    \"MAINTAINERS\" => TagSet::new(&[194, 255]),\n    // [\"makefile\", \"text\"]\n    \"Makefile\" => TagSet::new(&[157, 255]),\n    // [\"plain-text\", \"text\"]\n    \"NEWS\" => TagSet::new(&[194, 255]),\n    // [\"plain-text\", \"text\"]\n    \"NOTICE\" => TagSet::new(&[194, 255]),\n    // [\"plain-text\", \"text\"]\n    \"PATENTS\" => TagSet::new(&[194, 255]),\n    // [\"alpm\", \"bash\", \"pkgbuild\", \"shell\", \"text\"]\n    \"PKGBUILD\" => TagSet::new(&[1, 14, 193, 235, 255]),\n    // [\"text\", \"toml\"]\n    \"Pipfile\" => TagSet::new(&[255, 260]),\n    // [\"json\", \"text\"]\n    \"Pipfile.lock\" => TagSet::new(&[135, 255]),\n    // [\"plain-text\", \"text\"]\n    \"README\" => TagSet::new(&[194, 255]),\n    // [\"ruby\", \"text\"]\n    \"Rakefile\" => TagSet::new(&[223, 255]),\n    // [\"scons\", \"text\"]\n    \"SConscript\" => TagSet::new(&[232, 255]),\n    // [\"scons\", \"text\"]\n    \"SConstruct\" => TagSet::new(&[232, 255]),\n    // [\"scons\", \"text\"]\n    \"SCsub\" => TagSet::new(&[232, 255]),\n    // [\"text\", \"tiltfile\"]\n    \"Tiltfile\" => TagSet::new(&[255, 259]),\n    // [\"ruby\", \"text\"]\n    \"Vagrantfile\" => TagSet::new(&[223, 255]),\n    // [\"bazel\", \"text\"]\n    \"WORKSPACE\" => TagSet::new(&[17, 255]),\n    // [\"bitbake\", \"text\"]\n    \"bblayers.conf\" => TagSet::new(&[22, 255]),\n    // [\"bitbake\", \"text\"]\n    \"bitbake.conf\" => TagSet::new(&[22, 255]),\n    // [\"ruby\", \"text\"]\n    \"config.ru\" => TagSet::new(&[223, 255]),\n    // [\"bazel\", \"text\"]\n    \"copy.bara.sky\" => TagSet::new(&[17, 255]),\n    // [\"bash\", \"shell\", \"text\"]\n    \"direnvrc\" => TagSet::new(&[14, 235, 255]),\n    // [\"go-mod\", \"text\"]\n    \"go.mod\" => TagSet::new(&[96, 255]),\n    // [\"go-sum\", \"text\"]\n    \"go.sum\" => TagSet::new(&[97, 255]),\n    // [\"makefile\", \"text\"]\n    \"makefile\" => TagSet::new(&[157, 255]),\n    // [\"meson\", \"text\"]\n    \"meson.build\" => TagSet::new(&[163, 255]),\n    // [\"meson\", \"meson-options\", \"text\"]\n    \"meson.options\" => TagSet::new(&[163, 164, 255]),\n    // [\"meson\", \"meson-options\", \"text\"]\n    \"meson_options.txt\" => TagSet::new(&[163, 164, 255]),\n    // [\"text\", \"toml\"]\n    \"poetry.lock\" => TagSet::new(&[255, 260]),\n    // [\"pom\", \"text\", \"xml\"]\n    \"pom.xml\" => TagSet::new(&[199, 255, 297]),\n    // [\"ini\", \"pylintrc\", \"text\"]\n    \"pylintrc\" => TagSet::new(&[119, 209, 255]),\n    // [\"pyproject\", \"text\", \"toml\"]\n    \"pyproject.toml\" => TagSet::new(&[212, 255, 260]),\n    // [\"erlang\", \"text\"]\n    \"rebar.config\" => TagSet::new(&[73, 255]),\n    // [\"ini\", \"text\"]\n    \"setup.cfg\" => TagSet::new(&[119, 255]),\n    // [\"erlang\", \"text\"]\n    \"sys.config\" => TagSet::new(&[73, 255]),\n    // [\"erlang\", \"text\"]\n    \"sys.config.src\" => TagSet::new(&[73, 255]),\n    // [\"text\", \"toml\"]\n    \"uv.lock\" => TagSet::new(&[255, 260]),\n    // [\"python\", \"text\"]\n    \"wscript\" => TagSet::new(&[213, 255]),\n};\n"
  },
  {
    "path": "crates/prek-pty/Cargo.toml",
    "content": "[package]\nname = \"prek-pty\"\ndescription = \"pty utilities for prek\"\nversion = { workspace = true }\nedition = { workspace = true }\nrust-version = { workspace = true }\nrepository = { workspace = true }\nlicense = { workspace = true }\n\n[dependencies]\nrustix = { workspace = true }\ntokio = { workspace = true }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/prek-pty/LICENSE",
    "content": "This software is Copyright (c) 2021 by Jesse Luehrs.\n\nThis is free software, licensed under:\n\n  The MIT (X11) License\n\nThe MIT License\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the Software\nwithout restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copies of the Software, and to permit persons to\nwhom the Software is furnished to do so, subject to the\nfollowing conditions:\n\nThe above copyright notice and this permission notice shall\nbe included in all copies or substantial portions of the\nSoftware.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT\nWARRANTY OF ANY KIND, EXPRESS OR IMPLIED,\nINCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR\nPURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "crates/prek-pty/src/error.rs",
    "content": "/// Error type for errors from this crate\n#[derive(Debug)]\npub enum Error {\n    /// error came from `std::io::Error`\n    Io(std::io::Error),\n    /// error came from `nix::Error`\n    Rustix(rustix::io::Errno),\n    /// unsplit was called on halves of two different ptys\n    Unsplit(crate::pty::OwnedReadPty, crate::pty::OwnedWritePty),\n}\n\nimpl std::fmt::Display for Error {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::Io(e) => write!(f, \"{e}\"),\n            Self::Rustix(e) => write!(f, \"{e}\"),\n            Self::Unsplit(..) => {\n                write!(f, \"unsplit called on halves of two different ptys\")\n            }\n        }\n    }\n}\n\nimpl From<std::io::Error> for Error {\n    fn from(e: std::io::Error) -> Self {\n        Self::Io(e)\n    }\n}\n\nimpl From<rustix::io::Errno> for Error {\n    fn from(e: rustix::io::Errno) -> Self {\n        Self::Rustix(e)\n    }\n}\n\nimpl std::error::Error for Error {\n    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n        match self {\n            Self::Io(e) => Some(e),\n            Self::Rustix(e) => Some(e),\n            Self::Unsplit(..) => None,\n        }\n    }\n}\n\n/// Convenience wrapper for `Result`s using [`Error`](Error)\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/prek-pty/src/lib.rs",
    "content": "// Vendored crate from: https://crates.io/crates/pty-process\n\n#![cfg(unix)]\n\nmod error;\n#[allow(clippy::module_inception)]\nmod pty;\nmod sys;\nmod types;\n\npub use error::{Error, Result};\npub use pty::{OwnedReadPty, OwnedWritePty, Pts, Pty, ReadPty, WritePty, open};\npub use types::Size;\n"
  },
  {
    "path": "crates/prek-pty/src/pty.rs",
    "content": "#![allow(clippy::module_name_repetitions)]\nuse std::io::Write as _;\n\ntype AsyncPty = tokio::io::unix::AsyncFd<crate::sys::Pty>;\n\n/// Allocate and return a new pty and pts.\n///\n/// # Errors\n/// Returns an error if the pty failed to be allocated, or if we were\n/// unable to put it into non-blocking mode.\npub fn open() -> crate::Result<(Pty, Pts)> {\n    let pty = crate::sys::Pty::open()?;\n    let pts = pty.pts()?;\n    pty.set_nonblocking()?;\n    let pty = tokio::io::unix::AsyncFd::new(pty)?;\n    Ok((Pty(pty), Pts(pts)))\n}\n\n/// An allocated pty\npub struct Pty(AsyncPty);\n\nimpl Pty {\n    /// Use the provided file descriptor as a pty.\n    ///\n    /// # Safety\n    /// The provided file descriptor must be valid, open, belong to a pty,\n    /// and put into nonblocking mode.\n    ///\n    /// # Errors\n    /// Returns an error if it fails to be registered with the async runtime.\n    pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> crate::Result<Self> {\n        Ok(Self(tokio::io::unix::AsyncFd::new(unsafe {\n            crate::sys::Pty::from_fd(fd)\n        })?))\n    }\n\n    /// Change the terminal size associated with the pty.\n    ///\n    /// # Errors\n    /// Returns an error if we were unable to set the terminal size.\n    pub fn resize(&self, size: crate::Size) -> crate::Result<()> {\n        self.0.get_ref().set_term_size(size)\n    }\n\n    /// Splits a `Pty` into a read half and a write half, which can be used to\n    /// read from and write to the pty concurrently. Does not allocate, but\n    /// the returned halves cannot be moved to independent tasks.\n    pub fn split(&self) -> (ReadPty<'_>, WritePty<'_>) {\n        (ReadPty(&self.0), WritePty(&self.0))\n    }\n\n    /// Splits a `Pty` into a read half and a write half, which can be used to\n    /// read from and write to the pty concurrently. This method requires an\n    /// allocation, but the returned halves can be moved to independent tasks.\n    /// The original `Pty` instance can be recovered via the\n    /// [`OwnedReadPty::unsplit`] method.\n    #[must_use]\n    pub fn into_split(self) -> (OwnedReadPty, OwnedWritePty) {\n        let Self(pt) = self;\n        let read_pt = std::sync::Arc::new(pt);\n        let write_pt = std::sync::Arc::clone(&read_pt);\n        (OwnedReadPty(read_pt), OwnedWritePty(write_pt))\n    }\n}\n\nimpl From<Pty> for std::os::fd::OwnedFd {\n    fn from(pty: Pty) -> Self {\n        pty.0.into_inner().into()\n    }\n}\n\nimpl std::os::fd::AsFd for Pty {\n    fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {\n        self.0.get_ref().as_fd()\n    }\n}\n\nimpl std::os::fd::AsRawFd for Pty {\n    fn as_raw_fd(&self) -> std::os::fd::RawFd {\n        self.0.get_ref().as_raw_fd()\n    }\n}\n\nimpl tokio::io::AsyncRead for Pty {\n    fn poll_read(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &mut tokio::io::ReadBuf,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        poll_read(&self.0, cx, buf)\n    }\n}\n\nimpl tokio::io::AsyncWrite for Pty {\n    fn poll_write(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &[u8],\n    ) -> std::task::Poll<std::io::Result<usize>> {\n        poll_write(&self.0, cx, buf)\n    }\n\n    fn poll_flush(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        poll_flush(&self.0, cx)\n    }\n\n    fn poll_shutdown(\n        self: std::pin::Pin<&mut Self>,\n        _cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<Result<(), std::io::Error>> {\n        std::task::Poll::Ready(Ok(()))\n    }\n}\n\n/// The child end of the pty\n///\n/// See [`open`] and [`Command::spawn`](crate::Command::spawn)\npub struct Pts(pub(crate) crate::sys::Pts);\n\nimpl Pts {\n    /// Use the provided file descriptor as a pts.\n    ///\n    /// # Safety\n    /// The provided file descriptor must be valid, open, and belong to the\n    /// child end of a pty.\n    #[must_use]\n    pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> Self {\n        Self(unsafe { crate::sys::Pts::from_fd(fd) })\n    }\n\n    pub fn setup_subprocess(\n        &self,\n    ) -> std::io::Result<(\n        std::process::Stdio,\n        std::process::Stdio,\n        std::process::Stdio,\n    )> {\n        self.0.setup_subprocess()\n    }\n\n    pub fn session_leader(&self) -> impl FnMut() -> std::io::Result<()> + use<> {\n        self.0.session_leader()\n    }\n}\n\nimpl std::os::fd::AsFd for Pts {\n    fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {\n        self.0.as_fd()\n    }\n}\n\nimpl std::os::fd::AsRawFd for Pts {\n    fn as_raw_fd(&self) -> std::os::fd::RawFd {\n        self.0.as_raw_fd()\n    }\n}\n\n/// Borrowed read half of a [`Pty`]\npub struct ReadPty<'a>(&'a AsyncPty);\n\nimpl tokio::io::AsyncRead for ReadPty<'_> {\n    fn poll_read(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &mut tokio::io::ReadBuf,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        poll_read(self.0, cx, buf)\n    }\n}\n\n/// Borrowed write half of a [`Pty`]\npub struct WritePty<'a>(&'a AsyncPty);\n\nimpl WritePty<'_> {\n    /// Change the terminal size associated with the pty.\n    ///\n    /// # Errors\n    /// Returns an error if we were unable to set the terminal size.\n    pub fn resize(&self, size: crate::Size) -> crate::Result<()> {\n        self.0.get_ref().set_term_size(size)\n    }\n}\n\nimpl tokio::io::AsyncWrite for WritePty<'_> {\n    fn poll_write(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &[u8],\n    ) -> std::task::Poll<std::io::Result<usize>> {\n        poll_write(self.0, cx, buf)\n    }\n\n    fn poll_flush(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        poll_flush(self.0, cx)\n    }\n\n    fn poll_shutdown(\n        self: std::pin::Pin<&mut Self>,\n        _cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<Result<(), std::io::Error>> {\n        std::task::Poll::Ready(Ok(()))\n    }\n}\n\n/// Owned read half of a [`Pty`]\n#[derive(Debug)]\npub struct OwnedReadPty(std::sync::Arc<AsyncPty>);\n\nimpl OwnedReadPty {\n    /// Attempt to join the two halves of a `Pty` back into a single instance.\n    /// The two halves must have originated from calling\n    /// [`into_split`](Pty::into_split) on a single instance.\n    ///\n    /// # Errors\n    /// Returns an error if the two halves came from different [`Pty`]\n    /// instances. The mismatched halves are returned as part of the error.\n    pub fn unsplit(self, write_half: OwnedWritePty) -> crate::Result<Pty> {\n        let Self(read_pt) = self;\n        let OwnedWritePty(write_pt) = write_half;\n        if std::sync::Arc::ptr_eq(&read_pt, &write_pt) {\n            drop(write_pt);\n            Ok(Pty(std::sync::Arc::try_unwrap(read_pt)\n                // it shouldn't be possible for more than two references to\n                // the same pty to exist\n                .unwrap_or_else(|_| unreachable!())))\n        } else {\n            Err(crate::Error::Unsplit(\n                Self(read_pt),\n                OwnedWritePty(write_pt),\n            ))\n        }\n    }\n}\n\nimpl tokio::io::AsyncRead for OwnedReadPty {\n    fn poll_read(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &mut tokio::io::ReadBuf,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        poll_read(&self.0, cx, buf)\n    }\n}\n\n/// Owned write half of a [`Pty`]\n#[derive(Debug)]\npub struct OwnedWritePty(std::sync::Arc<AsyncPty>);\n\nimpl OwnedWritePty {\n    /// Change the terminal size associated with the pty.\n    ///\n    /// # Errors\n    /// Returns an error if we were unable to set the terminal size.\n    pub fn resize(&self, size: crate::Size) -> crate::Result<()> {\n        self.0.get_ref().set_term_size(size)\n    }\n}\n\nimpl tokio::io::AsyncWrite for OwnedWritePty {\n    fn poll_write(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &[u8],\n    ) -> std::task::Poll<std::io::Result<usize>> {\n        poll_write(&self.0, cx, buf)\n    }\n\n    fn poll_flush(\n        self: std::pin::Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        poll_flush(&self.0, cx)\n    }\n\n    fn poll_shutdown(\n        self: std::pin::Pin<&mut Self>,\n        _cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<Result<(), std::io::Error>> {\n        std::task::Poll::Ready(Ok(()))\n    }\n}\n\nfn poll_read(\n    pty: &AsyncPty,\n    cx: &mut std::task::Context<'_>,\n    buf: &mut tokio::io::ReadBuf,\n) -> std::task::Poll<std::io::Result<()>> {\n    loop {\n        let mut guard = match pty.poll_read_ready(cx) {\n            std::task::Poll::Ready(guard) => guard,\n            std::task::Poll::Pending => return std::task::Poll::Pending,\n        }?;\n        let prev_filled = buf.filled().len();\n        // SAFETY: we only pass b to read_buf, which never uninitializes any\n        // part of the buffer it is given\n        let b = unsafe { buf.unfilled_mut() };\n        match guard.try_io(|inner| inner.get_ref().read_buf(b)) {\n            Ok(Ok((filled, _unfilled))) => {\n                let bytes = filled.len();\n                // SAFETY: read_buf is given a buffer that starts at the end\n                // of the filled section, and then both initializes and fills\n                // some amount of the buffer after that (and never\n                // deinitializes anything). we know that at least this many\n                // bytes have been initialized (they either were filled and\n                // initialized previously, or the call to read_buf did), and\n                // assume_init will ignore any attempts to shrink the\n                // initialized space, so this call is always safe.\n                unsafe { buf.assume_init(prev_filled + bytes) };\n                buf.advance(bytes);\n                return std::task::Poll::Ready(Ok(()));\n            }\n            Ok(Err(e)) => return std::task::Poll::Ready(Err(e)),\n            Err(_would_block) => {}\n        }\n    }\n}\n\nfn poll_write(\n    pty: &AsyncPty,\n    cx: &mut std::task::Context<'_>,\n    buf: &[u8],\n) -> std::task::Poll<std::io::Result<usize>> {\n    loop {\n        let mut guard = match pty.poll_write_ready(cx) {\n            std::task::Poll::Ready(guard) => guard,\n            std::task::Poll::Pending => return std::task::Poll::Pending,\n        }?;\n        match guard.try_io(|inner| inner.get_ref().write(buf)) {\n            Ok(result) => return std::task::Poll::Ready(result),\n            Err(_would_block) => {}\n        }\n    }\n}\n\nfn poll_flush(\n    pty: &AsyncPty,\n    cx: &mut std::task::Context<'_>,\n) -> std::task::Poll<std::io::Result<()>> {\n    loop {\n        let mut guard = match pty.poll_write_ready(cx) {\n            std::task::Poll::Ready(guard) => guard,\n            std::task::Poll::Pending => return std::task::Poll::Pending,\n        }?;\n        match guard.try_io(|inner| inner.get_ref().flush()) {\n            Ok(_) => return std::task::Poll::Ready(Ok(())),\n            Err(_would_block) => {}\n        }\n    }\n}\n"
  },
  {
    "path": "crates/prek-pty/src/sys.rs",
    "content": "use std::os::{\n    fd::{AsRawFd as _, FromRawFd as _},\n    unix::prelude::{OpenOptionsExt as _, OsStrExt as _},\n};\n\n#[derive(Debug)]\npub struct Pty(std::os::fd::OwnedFd);\n\nimpl Pty {\n    pub fn open() -> crate::Result<Self> {\n        let pt = rustix::pty::openpt(\n            // can't use CLOEXEC here because it's linux-specific\n            rustix::pty::OpenptFlags::RDWR | rustix::pty::OpenptFlags::NOCTTY,\n        )?;\n        rustix::pty::grantpt(&pt)?;\n        rustix::pty::unlockpt(&pt)?;\n\n        let mut flags = rustix::io::fcntl_getfd(&pt)?;\n        flags |= rustix::io::FdFlags::CLOEXEC;\n        rustix::io::fcntl_setfd(&pt, flags)?;\n\n        Ok(Self(pt))\n    }\n\n    pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> Self {\n        Self(fd)\n    }\n\n    pub fn set_term_size(&self, size: crate::Size) -> crate::Result<()> {\n        Ok(rustix::termios::tcsetwinsize(\n            &self.0,\n            rustix::termios::Winsize::from(size),\n        )?)\n    }\n\n    pub fn pts(&self) -> crate::Result<Pts> {\n        Ok(Pts(std::fs::OpenOptions::new()\n            .read(true)\n            .write(true)\n            .custom_flags(rustix::fs::OFlags::NOCTTY.bits().try_into().unwrap())\n            .open(std::ffi::OsStr::from_bytes(\n                rustix::pty::ptsname(&self.0, vec![])?.as_bytes(),\n            ))?\n            .into()))\n    }\n\n    pub fn set_nonblocking(&self) -> rustix::io::Result<()> {\n        let mut opts = rustix::fs::fcntl_getfl(&self.0)?;\n        opts |= rustix::fs::OFlags::NONBLOCK;\n        rustix::fs::fcntl_setfl(&self.0, opts)?;\n\n        Ok(())\n    }\n\n    pub fn read_buf<'a>(\n        &self,\n        buf: &'a mut [std::mem::MaybeUninit<u8>],\n    ) -> std::io::Result<(&'a mut [u8], &'a mut [std::mem::MaybeUninit<u8>])> {\n        rustix::io::read(&self.0, buf).map_err(std::io::Error::from)\n    }\n}\n\nimpl From<Pty> for std::os::fd::OwnedFd {\n    fn from(pty: Pty) -> Self {\n        let Pty(nix_ptymaster) = pty;\n        let raw_fd = nix_ptymaster.as_raw_fd();\n        std::mem::forget(nix_ptymaster);\n\n        // Safety: nix::pty::PtyMaster is required to contain a valid file\n        // descriptor, and we ensured that the file descriptor will remain\n        // valid by skipping the drop implementation for nix::pty::PtyMaster\n        unsafe { Self::from_raw_fd(raw_fd) }\n    }\n}\n\nimpl std::os::fd::AsFd for Pty {\n    fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {\n        let raw_fd = self.0.as_raw_fd();\n\n        // Safety: nix::pty::PtyMaster is required to contain a valid file\n        // descriptor, and it is owned by self\n        unsafe { std::os::fd::BorrowedFd::borrow_raw(raw_fd) }\n    }\n}\n\nimpl std::os::fd::AsRawFd for Pty {\n    fn as_raw_fd(&self) -> std::os::fd::RawFd {\n        self.0.as_raw_fd()\n    }\n}\n\nimpl std::io::Read for Pty {\n    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {\n        rustix::io::read(&self.0, buf).map_err(std::io::Error::from)\n    }\n}\n\nimpl std::io::Write for Pty {\n    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {\n        rustix::io::write(&self.0, buf).map_err(std::io::Error::from)\n    }\n\n    fn flush(&mut self) -> std::io::Result<()> {\n        Ok(())\n    }\n}\n\nimpl std::io::Read for &Pty {\n    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {\n        rustix::io::read(&self.0, buf).map_err(std::io::Error::from)\n    }\n}\n\nimpl std::io::Write for &Pty {\n    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {\n        rustix::io::write(&self.0, buf).map_err(std::io::Error::from)\n    }\n\n    fn flush(&mut self) -> std::io::Result<()> {\n        Ok(())\n    }\n}\n\npub struct Pts(std::os::fd::OwnedFd);\n\nimpl Pts {\n    pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> Self {\n        Self(fd)\n    }\n\n    pub fn setup_subprocess(\n        &self,\n    ) -> std::io::Result<(\n        std::process::Stdio,\n        std::process::Stdio,\n        std::process::Stdio,\n    )> {\n        Ok((\n            self.0.try_clone()?.into(),\n            self.0.try_clone()?.into(),\n            self.0.try_clone()?.into(),\n        ))\n    }\n\n    pub fn session_leader(&self) -> impl FnMut() -> std::io::Result<()> + use<> {\n        let pts_fd = self.0.as_raw_fd();\n        move || {\n            rustix::process::setsid()?;\n            rustix::process::ioctl_tiocsctty(unsafe {\n                std::os::fd::BorrowedFd::borrow_raw(pts_fd)\n            })?;\n            Ok(())\n        }\n    }\n}\n\nimpl From<Pts> for std::os::fd::OwnedFd {\n    fn from(pts: Pts) -> Self {\n        pts.0\n    }\n}\n\nimpl std::os::fd::AsFd for Pts {\n    fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {\n        self.0.as_fd()\n    }\n}\n\nimpl std::os::fd::AsRawFd for Pts {\n    fn as_raw_fd(&self) -> std::os::fd::RawFd {\n        self.0.as_raw_fd()\n    }\n}\n"
  },
  {
    "path": "crates/prek-pty/src/types.rs",
    "content": "/// Represents the size of the pty.\n#[derive(Debug, Clone, Copy)]\npub struct Size {\n    row: u16,\n    col: u16,\n    xpixel: u16,\n    ypixel: u16,\n}\n\nimpl Size {\n    /// Returns a [`Size`](Size) instance with the given number of rows and\n    /// columns.\n    #[must_use]\n    pub fn new(row: u16, col: u16) -> Self {\n        Self {\n            row,\n            col,\n            xpixel: 0,\n            ypixel: 0,\n        }\n    }\n\n    /// Returns a [`Size`](Size) instance with the given number of rows and\n    /// columns, as well as the given pixel dimensions.\n    #[must_use]\n    pub fn new_with_pixel(row: u16, col: u16, xpixel: u16, ypixel: u16) -> Self {\n        Self {\n            row,\n            col,\n            xpixel,\n            ypixel,\n        }\n    }\n}\n\nimpl From<Size> for rustix::termios::Winsize {\n    fn from(size: Size) -> Self {\n        Self {\n            ws_row: size.row,\n            ws_col: size.col,\n            ws_xpixel: size.xpixel,\n            ws_ypixel: size.ypixel,\n        }\n    }\n}\n"
  },
  {
    "path": "dist-workspace.toml",
    "content": "[workspace]\nmembers = [\"cargo:.\"]\n\n# Config for 'dist'\n[dist]\n# The preferred dist version to use in CI (Cargo.toml SemVer syntax)\ncargo-dist-version = \"0.31.0\"\n# The archive format to use for non-windows builds (defaults .tar.xz)\nunix-archive = \".tar.gz\"\n# CI backends to support\nci = \"github\"\n# Whether CI should include auto-generated code to build local artifacts\nbuild-local-artifacts = false\n# Whether CI should trigger releases with dispatches instead of tag pushes\ndispatch-releases = true\n# Which actions to run on pull requests\npr-run-mode = \"skip\"\n# Which phase dist should use to create the GitHub release\ngithub-release = \"announce\"\n# Whether to enable GitHub Attestations\ngithub-attestations = true\n# When to generate GitHub Attestations\ngithub-attestations-phase = \"announce\"\n# Whether to publish prereleases to package managers\npublish-prereleases = true\n# The installers to generate for each app\ninstallers = [\"shell\", \"powershell\", \"npm\", \"homebrew\"]\n# A namespace to use when publishing this package to the npm registry\nnpm-scope = \"@j178\"\n# Whether to produce an npm lockfile\nnpm-shrinkwrap = false\n# Target platforms to build apps for (Rust target-triple syntax)\ntargets = [\n  \"aarch64-apple-darwin\",\n  \"aarch64-unknown-linux-gnu\",\n  \"aarch64-unknown-linux-musl\",\n  \"aarch64-pc-windows-msvc\",\n  \"arm-unknown-linux-musleabihf\",\n  \"armv7-unknown-linux-gnueabihf\",\n  \"armv7-unknown-linux-musleabihf\",\n  \"x86_64-apple-darwin\",\n  \"riscv64gc-unknown-linux-gnu\",\n  \"s390x-unknown-linux-gnu\",\n  \"x86_64-unknown-linux-gnu\",\n  \"x86_64-unknown-linux-musl\",\n  \"x86_64-pc-windows-msvc\",\n  \"i686-unknown-linux-gnu\",\n  \"i686-unknown-linux-musl\",\n  \"i686-pc-windows-msvc\",\n]\n# Local artifacts jobs to run in CI\nlocal-artifacts-jobs = [\"./build-binaries\", \"./build-docker\"]\n# Publish jobs to run in CI\npublish-jobs = [\"./publish-crates\", \"./publish-pypi\", \"./publish-npm\"]\n# Post-announce jobs to run in CI\npost-announce-jobs = [\n  \"./publish-docs\",\n  \"./publish-homebrew\",\n  \"./publish-prek-action\",\n  \"./publish-winget\",\n]\ngithub-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\" = {} }\n# Whether to install an updater program\ninstall-updater = false\n# Path that installers should place binaries in\ninstall-path = [\"$XDG_BIN_HOME/\", \"$XDG_DATA_HOME/../bin\", \"~/.local/bin\"]\n\n[dist.github-custom-runners]\nglobal = \"ubuntu-latest\"\n\n[dist.github-action-commits]\n\"actions/checkout\" = \"de0fac2e4500dabe0009e67214ff5f5447ce83dd\" # v6.0.2\n\"actions/upload-artifact\" = \"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f\" # v7.0.0\n\"actions/download-artifact\" = \"70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\" # v8.0.0\n\"actions/attest-build-provenance\" = \"a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32\" # v4.1.0\n"
  },
  {
    "path": "docs/assets/badge-v0.json",
    "content": "{\n  \"label\": \"prek\",\n  \"message\": \"enabled\",\n  \"logoSvg\": \"<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" xml:space=\\\"preserve\\\" viewBox=\\\"0 0 150.7 163\\\"><path fill=\\\"#fff\\\" d=\\\"M84.3 0c-2.6-.3-5.8 10-8.9 10-3 0-6.2-10.1-9-9.8-2.7.4-3.8 11-6.7 11.7-2.9.7-8.5-8.5-11-7.6-2.5 1-1.7 11.8-4.6 13.2-2.8 1.4-9.4-6.6-11.9-5-2.4 1.5 0 11.7-2.4 13.8-2.3 2.1-10.2-3.2-12-1.2-2 2 2.3 11 .5 13.6-1.8 2.6-9.6-.2-11 2.3-1.5 2.5 3.7 9.4 2.5 12.4-1.2 3-9 3.4-9.7 6.2-.8 2.7 5.1 7.9 5.1 10.6.2 3.6-6.8 3.3-3.6 17 2 5.4 2.5 30.7 40.6 48.5-2.2 17.3-7.8 18.5-8.5 23.2.2 3.2 3.7 4.3 4.4 4.1 1.2 0 17-6 28.8-22.2a86.5 86.5 0 0 0 65.2-20 68.7 68.7 0 0 0 18.2-43.5c.7-4.2-4.4-4.4-4.5-7.3-.1-2.8 5.6-7.6 4.8-10.3-.6-4.1-9.1-2.8-9.7-7-.5-3.1 3.6-9.6 2.7-11.5-1.3-2.6-10.1-1-11-2.7-1.5-2.8 2.2-11.1.2-13.2-1.9-2.1-10.3 2.5-11.6 1-2-2.4.3-12-2.7-13.7-3.3-1.8-9.8 6.2-11.6 5-2.6-1.6-2.4-12.4-5-13.4-2.5-1-7.3 8.3-10.2 7.6C88.7 11 86.9.3 84.3 0zM106.8 40c1.4 0 3 1 3.1 2 .2 2.3-.5 1.9-7.8 14-7.2 12.2-27.4 44.6-30.3 50-3 5.7-7.1 10.8-12.2 5.4a569 569 0 0 1-20.6-24c-4.6-5.7-3.6-5.2-3.7-5.5-.1-.4-.7-1.8.9-3.6 3.3-4 6.2-5.4 8.5-6.9 2.4-1.5 2.9-2 5.3.5 2.4 2.4 7.8 10 10.1 12.3 2.4 2.4 2.1 1 9.6-6.4C77.2 70.2 88 57 95.5 49.4c7.5-7.6 8-9.6 11.3-9.5z\\\"/></svg>\",\n  \"logoWidth\": 10,\n  \"labelColor\": \"grey\",\n  \"color\": \"#ff600a\"\n}\n"
  },
  {
    "path": "docs/authoring-hooks.md",
    "content": "# Authoring Hooks\n\nThis page is for hook authors who publish a repository consumed by end users.\nIf you only need to configure hooks in your own project, see [Configuration](configuration.md).\n\n## Manifest file: `.pre-commit-hooks.yaml`\n\nHook repositories must include a `.pre-commit-hooks.yaml` file at the repo root.\nThe manifest is a YAML list of hook definitions. Each hook entry must include:\n\n- `id`: stable identifier used in end-user configs\n- `name`: human-friendly label shown in output\n- `entry`: command to execute\n- `language`: execution environment (for example `python`, `node`, `system`)\n\nHooks should exit non-zero on failure (or modify files and exit non-zero for fixers).\n\nCommon optional fields include `args`, `files`, `exclude`, `types`, `types_or`,\n`stages`, `pass_filenames`, `description`, `additional_dependencies`, and\n`require_serial`.\n\n`prek` follows the upstream pre-commit manifest format. For the full field list\nand semantics, see: [https://pre-commit.com/#new-hooks](https://pre-commit.com/#new-hooks).\n\nExample:\n\n```yaml\n- id: format-json\n  name: format json\n  entry: python3 -m tools.format_json\n  language: python\n  files: \"\\\\.json$\"\n\n- id: lint-shell\n  name: shellcheck\n  entry: shellcheck\n  language: system\n  types: [shell]\n```\n\n## Choosing hook stages\n\nHook authors can declare which Git hook stages they support with `stages` in\n`.pre-commit-hooks.yaml`. End users can override that list in their\nconfiguration. If neither is set, `prek` falls back to the top-level\n`default_stages` (which defaults to all stages).\n\nThe `manual` stage is special: it never runs automatically and is only executed\nwhen a user explicitly runs `prek run --hook-stage manual <hook-id>`.\n\nExample:\n\n```yaml\n- id: lint\n  name: lint\n  entry: my-lint\n  language: python\n  stages: [pre-commit, pre-merge-commit, pre-push, manual]\n```\n\n## Passing arguments to hooks\n\nWhen users configure a hook with `args`, `prek` passes those arguments before\nthe list of file paths. If `args` is empty or omitted, only file paths are\nprovided.\n\nExample end-user config:\n\n```yaml\nrepos:\n  - repo: https://github.com/example/hook-repo\n    rev: v1.0.0\n    hooks:\n      - id: my-hook\n        args: [--max-line-length=120]\n```\n\nInvocation shape:\n\n```text\nmy-hook --max-line-length=120 path/to/file1 path/to/file2\n```\n\n## Versioning for `prek auto-update`\n\nEnd users pin your repository using the `rev` field in their config. To make\n[`prek auto-update`](cli.md#prek-auto-update) work as expected, publish git tags for releases:\n\n- Prefer semantic version tags like `v1.2.3` or `1.2.3`.\n- Push tags to the remote (annotated or lightweight tags both work).\n- Avoid moving tags; treat them as immutable release references.\n\n`prek auto-update` selects the newest tag by default. With `--bleeding-edge`, it\nuses the default branch tip instead of tags. With `--freeze`, it writes commit\nSHAs into `rev` instead of tag names.\n\n## Develop locally with `prek try-repo`\n\n[`prek try-repo`](cli.md#prek-try-repo) runs hooks from a repository without publishing a release. This\nis handy while iterating on a hook.\n\n```bash\n# In another repository where you want to test the hook\nprek try-repo ../path/to/hook-repo my-hook-id --verbose\n```\n\nNotes:\n\n- `prek try-repo` accepts any path or git URL `git clone` understands.\n- For `prepare-commit-msg` or `commit-msg` hooks, pass the appropriate\n  `--commit-msg-filename` argument when testing.\n\n## Validation and CI\n\nValidate your manifest locally with [`prek validate-manifest`](cli.md#prek-validate-manifest):\n\n```bash\nprek validate-manifest .pre-commit-hooks.yaml\n```\n\nThis ensures the manifest is well-formed before publishing a release tag.\n"
  },
  {
    "path": "docs/benchmark.md",
    "content": "# Benchmarks\n\nThis page presents benchmarks comparing prek vs pre-commit.\n\nCaveats:\n\n- Benchmark performance may vary based on hardware, OS, network conditions, and other factors.\n- Benchmarks are not exhaustive; results may vary with different repositories and configurations.\n- prek is under active development; performance may improve over time.\n\nEnvironment:\n\npre-commit version: 4.3.0\nprek version: 0.2.0\n\nOS: macOS 15.5\nCPU: Apple M3 Pro\nRAM: 18GB\n\n## Cold installation\n\nHere is a benchmark of installing hooks from [Apache Airflow](https://github.com/apache/airflow), which has a large and complex pre-commit configuration.\n\nSteps:\n\n```console\nuv tool install prek@0.2.0\nuv tool install pre-commit@4.3.0\n\ngit clone https://github.com/apache/airflow\ncd airflow\ngit checkout 3.0.6\n\nhyperfine \\\n    --prepare 'prek clean && pre-commit clean && uv cache clean' \\\n    --setup 'prek --version && pre-commit --version' \\\n    --runs 1 \\\n    'prek prepare-hooks' \\\n    'pre-commit install-hooks'\n```\n\nResults:\n\n```\nBenchmark 1: prek prepare-hooks\n  Time (abs ≡):        18.395 s               [User: 11.234 s, System: 9.979 s]\n\nBenchmark 2: pre-commit install-hooks\n  Time (abs ≡):        186.990 s               [User: 68.774 s, System: 39.379 s]\n\nSummary\n  prek prepare-hooks ran\n   10.17 times faster than pre-commit install-hooks\n```\n\nDisk usage after installation:\n\n```console\n$ du -sh ~/.cache/prek ~/.cache/pre-commit\n810M\t/Users/Jo/.cache/prek\n1.6G\t/Users/Jo/.cache/pre-commit\n```\n\n## Runtime benchmarks\n\nSince 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.\n\n### With prek fast path\n\nSteps:\n\n```console\ngit clone https://github.com/python/cpython\ncd cpython\ngit checkout v3.14.0rc2\n\nhyperfine \\\n    --warmup 3 \\\n    --setup 'prek --version && pre-commit --version' \\\n    --runs 5 \\\n    'prek run -a check-toml' \\\n    'pre-commit run -a check-toml'\n```\n\nResults:\n\n```console\nBenchmark 1: prek run -a check-toml\n  Time (mean ± σ):      77.1 ms ±   2.5 ms    [User: 44.1 ms, System: 128.5 ms]\n  Range (min … max):    75.1 ms …  81.3 ms    5 runs\n\nBenchmark 2: pre-commit run -a check-toml\n  Time (mean ± σ):     351.6 ms ±  25.0 ms    [User: 214.5 ms, System: 195.5 ms]\n  Range (min … max):   332.8 ms … 393.2 ms    5 runs\n\nSummary\n  prek run -a check-toml ran\n    4.56 ± 0.36 times faster than pre-commit run -a check-toml\n```\n\n### Without prek fast path\n\nSteps:\n\n```console\nhyperfine \\\n    --warmup 3 \\\n    --setup 'prek --version && pre-commit --version' \\\n    --runs 5 \\\n    'PREK_NO_FAST_PATH=1 prek run -a check-toml' \\\n    'pre-commit run -a check-toml'\n```\n\nResults:\n\n```\nBenchmark 1: PREK_NO_FAST_PATH=1 prek run -a check-toml\n  Time (mean ± σ):     137.3 ms ±   5.1 ms    [User: 111.0 ms, System: 147.5 ms]\n  Range (min … max):   131.9 ms … 144.0 ms    5 runs\n\nBenchmark 2: pre-commit run -a check-toml\n  Time (mean ± σ):     397.6 ms ±  49.2 ms    [User: 217.6 ms, System: 197.7 ms]\n  Range (min … max):   332.6 ms … 440.7 ms    5 runs\n\nSummary\n  PREK_NO_FAST_PATH=1 prek run -a check-toml ran\n    2.90 ± 0.37 times faster than pre-commit run -a check-toml\n```\n\n## Benchmark from the community\n\n- [Ready Prek Go!](https://hugovk.dev/blog/2025/ready-prek-go/) from Hugo van Kemenade.\n"
  },
  {
    "path": "docs/builtin.md",
    "content": "# Built-in Fast Hooks\n\nprek 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.\n\nBuilt-in hooks come into play in two ways:\n\n1. **Automatic Fast Path**: Automatically replacing execution for known remote repositories.\n2. **Explicit Builtin Repository**: Using `repo: builtin` for offline, zero-setup hooks.\n\n## 1. Automatic Fast Path\n\nWhen 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.\n\nThe 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.\nNote that the `rev` field is ignored for detection purposes.\n\nThis provides a speed boost while keeping your configuration compatible with the original `pre-commit` tool.\n\n```yaml\nrepos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks  # Enables fast path\n    rev: v4.5.0  # This is ignored for fast path detection\n    hooks:\n      - id: trailing-whitespace\n```\n\n!!! note\n\n    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.\n\n### Supported Hooks\n\nCurrently, only part of hooks from `https://github.com/pre-commit/pre-commit-hooks` is supported. More popular repositories may be added over time.\n\n### <https://github.com/pre-commit/pre-commit-hooks>\n\n- [`trailing-whitespace`](https://github.com/pre-commit/pre-commit-hooks#trailing-whitespace) (Trim trailing whitespace)\n- [`check-added-large-files`](https://github.com/pre-commit/pre-commit-hooks#check-added-large-files) (Prevent committing large files)\n- [`check-case-conflict`](https://github.com/pre-commit/pre-commit-hooks#check-case-conflict) (Check for files that would conflict in case-insensitive filesystems)\n- [`end-of-file-fixer`](https://github.com/pre-commit/pre-commit-hooks#end-of-file-fixer) (Ensure newline at EOF)\n- [`fix-byte-order-marker`](https://github.com/pre-commit/pre-commit-hooks#fix-byte-order-marker) (Remove UTF-8 byte order marker)\n- [`check-json`](https://github.com/pre-commit/pre-commit-hooks#check-json) (Validate JSON files)\n- [`check-toml`](https://github.com/pre-commit/pre-commit-hooks#check-toml) (Validate TOML files)\n- [`check-yaml`](https://github.com/pre-commit/pre-commit-hooks#check-yaml) (Validate YAML files)\n- [`check-xml`](https://github.com/pre-commit/pre-commit-hooks#check-xml) (Validate XML files)\n- [`mixed-line-ending`](https://github.com/pre-commit/pre-commit-hooks#mixed-line-ending) (Normalize or check line endings)\n- [`check-symlinks`](https://github.com/pre-commit/pre-commit-hooks#check-symlinks) (Check for broken symlinks)\n- [`check-merge-conflict`](https://github.com/pre-commit/pre-commit-hooks#check-merge-conflict) (Check for merge conflicts)\n- [`detect-private-key`](https://github.com/pre-commit/pre-commit-hooks#detect-private-key) (Detect private keys)\n- [`no-commit-to-branch`](https://github.com/pre-commit/pre-commit-hooks#no-commit-to-branch) (Prevent committing to protected branches)\n- [`check-executables-have-shebangs`](https://github.com/pre-commit/pre-commit-hooks#check-executables-have-shebangs) (Ensures that (non-binary) executables have a shebang)\n\n#### Notes\n\n- `check-yaml` fast path does not yet support the `--unsafe` flag; for those cases, the automatic fast path is skipped.\n- Other hooks from the repository which have no fast path implementation will run via the standard method.\n\n### Disabling the fast path\n\nIf you need to compare with the original behavior or encounter differences:\n\n```bash\nPREK_NO_FAST_PATH=1 prek run\n```\n\nThis forces prek to fall back to the standard execution path.\n\n## 2. Explicit Builtin Repository\n\nYou can explicitly tell `prek` to use its internal hooks by setting `repo: builtin`.\n\nThis mode has significant benefits:\n\n- **No network required**: Does not clone any repository.\n- **No environment setup**: Does not create Python environments or install dependencies.\n- **Maximum speed**: Instant startup and execution.\n\n**Note**: Configurations using `repo: builtin` are **not compatible** with the standard `pre-commit` tool.\n\n```yaml\nrepos:\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n      - id: check-added-large-files\n```\n\n### Supported Hooks\n\nFor `repo: builtin`, the following hooks are supported:\n\n- [`trailing-whitespace`](#trailing-whitespace) (Trim trailing whitespace)\n- [`check-added-large-files`](#check-added-large-files) (Prevent committing large files)\n- [`check-case-conflict`](#check-case-conflict) (Check for files that would conflict in case-insensitive filesystems)\n- [`end-of-file-fixer`](#end-of-file-fixer) (Ensure newline at EOF)\n- [`fix-byte-order-marker`](#fix-byte-order-marker) (Remove UTF-8 byte order marker)\n- [`check-json`](#check-json) (Validate JSON files)\n- [`check-json5`](#check-json5) (Validate JSON5 files)\n- [`check-toml`](#check-toml) (Validate TOML files)\n- [`check-yaml`](#check-yaml) (Validate YAML files)\n- [`check-xml`](#check-xml) (Validate XML files)\n- [`mixed-line-ending`](#mixed-line-ending) (Normalize or check line endings)\n- [`check-symlinks`](#check-symlinks) (Check for broken symlinks)\n- [`check-merge-conflict`](#check-merge-conflict) (Check for merge conflicts)\n- [`detect-private-key`](#detect-private-key) (Detect private keys)\n- [`no-commit-to-branch`](#no-commit-to-branch) (Prevent committing to protected branches)\n- [`check-executables-have-shebangs`](#check-executables-have-shebangs) (Ensures that (non-binary) executables have a shebang)\n\n### Hook Reference\n\nThis section documents the built-in (Rust) implementations used by `repo: builtin`.\n\n#### Configuration notes\n\n- Configure arguments via `args: [...]` just like `pre-commit`.\n- For `repo: builtin`, `entry` is not allowed and `language` must be `system` (it is fine to omit `language`).\n- 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.\n\nExample:\n\n```yaml\nrepos:\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n        args: [--markdown-linebreak-ext=md]\n      - id: check-added-large-files\n        args: [--maxkb=1024]\n```\n\n---\n\n#### `trailing-whitespace`\n\nTrims trailing whitespace from each line.\n\n**Supported arguments** (compatible with `pre-commit-hooks`):\n\n- `--markdown-linebreak-ext=<ext>` (repeatable / comma-separated)\n    - Preserves Markdown hard line breaks (two trailing spaces) for files with the given extension(s).\n    - Use `--markdown-linebreak-ext=*` to treat **all** files as Markdown.\n- `--chars=<chars>`\n    - Trim only the specified set of characters instead of “all trailing whitespace”.\n    - Example: `args: [--chars, \" \\t\"]` (space + tab).\n\n**Caveats**\n\n- `--markdown-linebreak-ext` values must be extensions only (no path separators).\n\n---\n\n#### `check-added-large-files`\n\nPrevents giant files from being committed.\n\n**Supported arguments** (compatible with `pre-commit-hooks`):\n\n- `--maxkb=<N>` (default: `500`)\n    - Maximum allowed file size, in kibibytes.\n- `--enforce-all`\n    - Check all matched files, not just those staged for addition.\n\n**Caveats**\n\n- By default, only files staged for **addition** are checked.\n- Files configured with `filter=lfs` (via git attributes) are skipped.\n\n---\n\n#### `check-case-conflict`\n\nChecks for paths that would conflict on a case-insensitive filesystem (for example macOS / Windows).\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- The check includes parent directories as well as file paths, to catch directory-level case conflicts.\n\n---\n\n#### `end-of-file-fixer`\n\nEnsures files end in a newline and only a newline.\n\n**Supported arguments**\n\n- None.\n\n**Behavior / caveats**\n\n- Empty files are left unchanged.\n- Files containing only newlines are truncated to empty.\n- If a file has no trailing newline, a single `\\n` is appended (even if the file otherwise uses CRLF).\n- If a file has trailing newlines, they are reduced to exactly one trailing line ending.\n\n---\n\n#### `fix-byte-order-marker`\n\nRemoves a UTF-8 byte order marker (BOM) from the beginning of a file.\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- Only removes the UTF-8 BOM (`EF BB BF`).\n\n---\n\n#### `check-json`\n\nAttempts to load all JSON files to verify syntax.\n\n**Supported arguments**\n\n- None.\n\n**Caveats / differences**\n\n- This implementation rejects **duplicate object keys** (errors with `duplicate key ...`).\n- The parser disables the default recursion limit and uses a stack-friendly drop strategy for deeply nested JSON.\n\n---\n\n#### `check-json5`\n\nAttempts to load all JSON5 files to verify syntax.\n\n**Supported arguments**\n\n- None.\n\n**Caveats / differences**\n\n- This implementation rejects **duplicate object keys** (errors with `duplicate key ...`).\n\n---\n\n#### `check-toml`\n\nAttempts to load all TOML files to verify syntax.\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- Files must be valid UTF-8; invalid UTF-8 is reported as an error.\n- May report multiple parse errors for a single file.\n\n---\n\n#### `check-yaml`\n\nAttempts to load all YAML files to verify syntax.\n\n**Supported arguments** (partially compatible with `pre-commit-hooks`):\n\n- `-m`, `--allow-multiple-documents` (alias: `--multi`)\n    - Allow YAML multi-document syntax (`---`).\n\n**Caveats / differences**\n\n- `--unsafe` is not supported.\n    - With `repo: builtin`, passing `--unsafe` is treated as an unknown argument.\n\n---\n\n#### `check-xml`\n\nAttempts to load all XML files to verify syntax.\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- Empty files are treated as invalid XML.\n- Fails if there is “junk after the document element” (multiple top-level roots).\n\n---\n\n#### `mixed-line-ending`\n\nReplaces or checks mixed line endings.\n\n**Supported arguments** (compatible with `pre-commit-hooks`, plus one extra mode):\n\n- `--fix=<mode>` (default: `auto`)\n    - `auto`: replace with the most frequent line ending in the file.\n    - `no`: check only (do not modify files).\n    - `lf`: convert to LF (`\\n`).\n    - `crlf`: convert to CRLF (`\\r\\n`).\n    - `cr`: convert to CR (`\\r`) (extra mode in `prek`).\n\n**Caveats**\n\n- Empty and binary files (containing NUL) are skipped.\n- Upstream note: forcing `lf` / `crlf` may not behave as expected with git CRLF conversion settings (for example `core.autocrlf`).\n\n---\n\n#### `check-symlinks`\n\nChecks for symlinks which do not point to anything.\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- Relies on filesystem symlink support. On Windows, symlink creation and detection can be permission-dependent.\n\n---\n\n#### `check-merge-conflict`\n\nChecks for merge conflict strings.\n\n**Supported arguments** (compatible with `pre-commit-hooks`):\n\n- `--assume-in-merge`\n    - Allow running the hook even when there is no merge/rebase state detected.\n\n**Caveats**\n\n- By default, this hook exits successfully when not in a merge/rebase state.\n- Detects common conflict markers only when they appear at the start of a line.\n\n---\n\n#### `detect-private-key`\n\nDetects the presence of private keys.\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- 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.).\n  It can produce false positives/negatives.\n\n---\n\n#### `no-commit-to-branch`\n\nProtects specific branches from direct commits.\n\n**Supported arguments** (compatible with `pre-commit-hooks`):\n\n- `-b`, `--branch <branch>` (repeatable, default: `main`, `master`)\n- `-p`, `--pattern <regex>` (repeatable)\n\n**Caveats**\n\n- This hook is configured as `always_run: true` by default, and does not take filenames.\n  As a result, `files`, `exclude`, `types`, etc. are ignored unless you explicitly set `always_run: false`.\n- If HEAD is detached (no current branch), the hook does nothing.\n\n---\n\n#### `check-executables-have-shebangs`\n\nChecks that non-binary executables have a proper shebang.\n\n**Supported arguments**\n\n- None.\n\n**Caveats**\n\n- The check is intentionally lightweight: it only verifies that the file starts with `#!`.\n- On systems where the executable bit is not tracked by the filesystem, `prek` consults git’s staged mode bits.\n"
  },
  {
    "path": "docs/changelog.md",
    "content": "<!-- Loads the changelog from the repository root -->\n\n--8<-- \"CHANGELOG.md\"\n"
  },
  {
    "path": "docs/cli.md",
    "content": "# CLI Reference\n\n## prek\n\nBetter pre-commit, re-engineered in Rust\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek [OPTIONS] [HOOK|PROJECT]... [COMMAND]\n```\n\n<h3 class=\"cli-reference\">Commands</h3>\n\n<dl class=\"cli-reference\"><dt><a href=\"#prek-install\"><code>prek install</code></a></dt><dd><p>Install prek Git shims under the <code>.git/hooks/</code> directory</p></dd>\n<dt><a href=\"#prek-prepare-hooks\"><code>prek prepare-hooks</code></a></dt><dd><p>Prepare environments for all hooks used in the config file</p></dd>\n<dt><a href=\"#prek-run\"><code>prek run</code></a></dt><dd><p>Run hooks</p></dd>\n<dt><a href=\"#prek-list\"><code>prek list</code></a></dt><dd><p>List hooks configured in the current workspace</p></dd>\n<dt><a href=\"#prek-uninstall\"><code>prek uninstall</code></a></dt><dd><p>Uninstall prek Git shims</p></dd>\n<dt><a href=\"#prek-validate-config\"><code>prek validate-config</code></a></dt><dd><p>Validate configuration files (prek.toml or .pre-commit-config.yaml)</p></dd>\n<dt><a href=\"#prek-validate-manifest\"><code>prek validate-manifest</code></a></dt><dd><p>Validate <code>.pre-commit-hooks.yaml</code> files</p></dd>\n<dt><a href=\"#prek-sample-config\"><code>prek sample-config</code></a></dt><dd><p>Produce a sample configuration file (prek.toml or .pre-commit-config.yaml)</p></dd>\n<dt><a href=\"#prek-auto-update\"><code>prek auto-update</code></a></dt><dd><p>Auto-update the <code>rev</code> field of repositories in the config file to the latest version</p></dd>\n<dt><a href=\"#prek-cache\"><code>prek cache</code></a></dt><dd><p>Manage the prek cache</p></dd>\n<dt><a href=\"#prek-try-repo\"><code>prek try-repo</code></a></dt><dd><p>Try the pre-commit hooks in the current repo</p></dd>\n<dt><a href=\"#prek-util\"><code>prek util</code></a></dt><dd><p>Utility commands</p></dd>\n<dt><a href=\"#prek-self\"><code>prek self</code></a></dt><dd><p><code>prek</code> self management</p></dd>\n</dl>\n\n## prek install\n\nInstall prek Git shims under the `.git/hooks/` directory.\n\nThe 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.\n\nA hook's `stages` field does not affect which Git shims this command installs.\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek install [OPTIONS] [HOOK|PROJECT]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-install--includes\"><a href=\"#prek-install--includes\"><code>HOOK|PROJECT</code></a></dt><dd><p>Include the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Run all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Run all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Run only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times to select multiple hooks/projects.</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-install--allow-missing-config\"><a href=\"#prek-install--allow-missing-config\"><code>--allow-missing-config</code></a></dt><dd><p>Allow a missing configuration file</p>\n</dd><dt id=\"prek-install--cd\"><a href=\"#prek-install--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-install--color\"><a href=\"#prek-install--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-install--config\"><a href=\"#prek-install--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-install--git-dir\"><a href=\"#prek-install--git-dir\"><code>--git-dir</code></a> <i>git-dir</i></dt><dd><p>Install Git shims into the <code>hooks</code> subdirectory of the given git directory (<code>&lt;GIT_DIR&gt;/hooks/</code>).</p>\n<p>When this flag is used, <code>prek install</code> bypasses the safety check that normally refuses to install shims while <code>core.hooksPath</code> is set. Git itself will still ignore <code>.git/hooks</code> while <code>core.hooksPath</code> is configured, so ensure your Git configuration points to the directory where the shim is installed if you want it to be executed.</p>\n</dd><dt id=\"prek-install--help\"><a href=\"#prek-install--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-install--hook-type\"><a href=\"#prek-install--hook-type\"><code>--hook-type</code></a>, <code>-t</code> <i>hook-type</i></dt><dd><p>Which Git shim(s) to install.</p>\n<p>Specifies which Git hook type(s) you want to install shims for. Can be specified multiple times to install shims for multiple hook types.</p>\n<p>If not specified, uses <code>default_install_hook_types</code> from the config file, or defaults to <code>pre-commit</code> if that is also not set.</p>\n<p>Note: This is different from a hook's <code>stages</code> parameter in the config file, which declares which stages a hook <em>can</em> run in.</p>\n<p>Possible values:</p>\n<ul>\n<li><code>commit-msg</code></li>\n<li><code>post-checkout</code></li>\n<li><code>post-commit</code></li>\n<li><code>post-merge</code></li>\n<li><code>post-rewrite</code></li>\n<li><code>pre-commit</code></li>\n<li><code>pre-merge-commit</code></li>\n<li><code>pre-push</code></li>\n<li><code>pre-rebase</code></li>\n<li><code>prepare-commit-msg</code></li>\n</ul></dd><dt id=\"prek-install--log-file\"><a href=\"#prek-install--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-install--no-progress\"><a href=\"#prek-install--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-install--overwrite\"><a href=\"#prek-install--overwrite\"><code>--overwrite</code></a>, <code>-f</code></dt><dd><p>Overwrite existing Git shims</p>\n</dd><dt id=\"prek-install--prepare-hooks\"><a href=\"#prek-install--prepare-hooks\"><code>--prepare-hooks</code></a>, <code>--install-hooks</code></dt><dd><p>Also prepare environments for all hooks used in the config file</p>\n</dd><dt id=\"prek-install--quiet\"><a href=\"#prek-install--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-install--refresh\"><a href=\"#prek-install--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-install--skip\"><a href=\"#prek-install--skip\"><code>--skip</code></a> <i>hook|project</i></dt><dd><p>Skip the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Skip all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Skip all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Skip only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times. Also accepts <code>PREK_SKIP</code> or <code>SKIP</code> environment variables (comma-delimited).</p>\n</dd><dt id=\"prek-install--verbose\"><a href=\"#prek-install--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-install--version\"><a href=\"#prek-install--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek prepare-hooks\n\nPrepare environments for all hooks used in the config file.\n\nThis command does not install Git shims. To install the Git shims along with the hook environments in one command, use `prek install --prepare-hooks`.\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek prepare-hooks [OPTIONS] [HOOK|PROJECT]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-prepare-hooks--includes\"><a href=\"#prek-prepare-hooks--includes\"><code>HOOK|PROJECT</code></a></dt><dd><p>Include the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Run all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Run all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Run only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times to select multiple hooks/projects.</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-prepare-hooks--cd\"><a href=\"#prek-prepare-hooks--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-prepare-hooks--color\"><a href=\"#prek-prepare-hooks--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-prepare-hooks--config\"><a href=\"#prek-prepare-hooks--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-prepare-hooks--help\"><a href=\"#prek-prepare-hooks--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-prepare-hooks--log-file\"><a href=\"#prek-prepare-hooks--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-prepare-hooks--no-progress\"><a href=\"#prek-prepare-hooks--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-prepare-hooks--quiet\"><a href=\"#prek-prepare-hooks--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-prepare-hooks--refresh\"><a href=\"#prek-prepare-hooks--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-prepare-hooks--skip\"><a href=\"#prek-prepare-hooks--skip\"><code>--skip</code></a> <i>hook|project</i></dt><dd><p>Skip the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Skip all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Skip all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Skip only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times. Also accepts <code>PREK_SKIP</code> or <code>SKIP</code> environment variables (comma-delimited).</p>\n</dd><dt id=\"prek-prepare-hooks--verbose\"><a href=\"#prek-prepare-hooks--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-prepare-hooks--version\"><a href=\"#prek-prepare-hooks--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek run\n\nRun hooks\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek run [OPTIONS] [HOOK|PROJECT]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-run--includes\"><a href=\"#prek-run--includes\"><code>HOOK|PROJECT</code></a></dt><dd><p>Include the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Run all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Run all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Run only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times to select multiple hooks/projects.</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-run--all-files\"><a href=\"#prek-run--all-files\"><code>--all-files</code></a>, <code>-a</code></dt><dd><p>Run on all files in the repo</p>\n</dd><dt id=\"prek-run--cd\"><a href=\"#prek-run--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-run--color\"><a href=\"#prek-run--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-run--config\"><a href=\"#prek-run--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-run--directory\"><a href=\"#prek-run--directory\"><code>--directory</code></a>, <code>-d</code> <i>dir</i></dt><dd><p>Run hooks on all files in the specified directories.</p>\n<p>You can specify multiple directories. It can be used in conjunction with <code>--files</code>.</p>\n</dd><dt id=\"prek-run--dry-run\"><a href=\"#prek-run--dry-run\"><code>--dry-run</code></a></dt><dd><p>Do not run the hooks, but print the hooks that would have been run</p>\n</dd><dt id=\"prek-run--fail-fast\"><a href=\"#prek-run--fail-fast\"><code>--fail-fast</code></a></dt><dd><p>Stop running hooks after the first failure</p>\n</dd><dt id=\"prek-run--files\"><a href=\"#prek-run--files\"><code>--files</code></a> <i>files</i></dt><dd><p>Specific filenames to run hooks on</p>\n</dd><dt id=\"prek-run--from-ref\"><a href=\"#prek-run--from-ref\"><code>--from-ref</code></a>, <code>--source</code>, <code>-s</code> <i>from-ref</i></dt><dd><p>The original ref in a <code>&lt;from_ref&gt;...&lt;to_ref&gt;</code> diff expression. Files changed in this diff will be run through the hooks</p>\n</dd><dt id=\"prek-run--help\"><a href=\"#prek-run--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-run--last-commit\"><a href=\"#prek-run--last-commit\"><code>--last-commit</code></a></dt><dd><p>Run hooks against the last commit. Equivalent to <code>--from-ref HEAD~1 --to-ref HEAD</code></p>\n</dd><dt id=\"prek-run--log-file\"><a href=\"#prek-run--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-run--no-progress\"><a href=\"#prek-run--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-run--quiet\"><a href=\"#prek-run--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-run--refresh\"><a href=\"#prek-run--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-run--show-diff-on-failure\"><a href=\"#prek-run--show-diff-on-failure\"><code>--show-diff-on-failure</code></a></dt><dd><p>When hooks fail, run <code>git diff</code> directly afterward</p>\n</dd><dt id=\"prek-run--skip\"><a href=\"#prek-run--skip\"><code>--skip</code></a> <i>hook|project</i></dt><dd><p>Skip the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Skip all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Skip all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Skip only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times. Also accepts <code>PREK_SKIP</code> or <code>SKIP</code> environment variables (comma-delimited).</p>\n</dd><dt id=\"prek-run--stage\"><a href=\"#prek-run--stage\"><code>--stage</code></a>, <code>--hook-stage</code> <i>stage</i></dt><dd><p>The stage during which the hook is fired.</p>\n<p>When specified, only hooks configured for that stage (for example <code>manual</code>, <code>pre-commit</code>, or <code>pre-push</code>) will run. Defaults to <code>pre-commit</code> if not specified. For hooks specified directly in the command line, fallback to <code>manual</code> stage if no hooks found for <code>pre-commit</code> stage.</p>\n<p>Possible values:</p>\n<ul>\n<li><code>manual</code></li>\n<li><code>commit-msg</code></li>\n<li><code>post-checkout</code></li>\n<li><code>post-commit</code></li>\n<li><code>post-merge</code></li>\n<li><code>post-rewrite</code></li>\n<li><code>pre-commit</code></li>\n<li><code>pre-merge-commit</code></li>\n<li><code>pre-push</code></li>\n<li><code>pre-rebase</code></li>\n<li><code>prepare-commit-msg</code></li>\n</ul></dd><dt id=\"prek-run--to-ref\"><a href=\"#prek-run--to-ref\"><code>--to-ref</code></a>, <code>--origin</code>, <code>-o</code> <i>to-ref</i></dt><dd><p>The destination ref in a <code>from_ref...to_ref</code> diff expression. Defaults to <code>HEAD</code> if <code>from_ref</code> is specified</p>\n</dd><dt id=\"prek-run--verbose\"><a href=\"#prek-run--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-run--version\"><a href=\"#prek-run--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek list\n\nList hooks configured in the current workspace\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek list [OPTIONS] [HOOK|PROJECT]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-list--includes\"><a href=\"#prek-list--includes\"><code>HOOK|PROJECT</code></a></dt><dd><p>Include the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Run all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Run all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Run only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times to select multiple hooks/projects.</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-list--cd\"><a href=\"#prek-list--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-list--color\"><a href=\"#prek-list--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-list--config\"><a href=\"#prek-list--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-list--help\"><a href=\"#prek-list--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-list--hook-stage\"><a href=\"#prek-list--hook-stage\"><code>--hook-stage</code></a> <i>hook-stage</i></dt><dd><p>Show only hooks that has the specified stage</p>\n<p>Possible values:</p>\n<ul>\n<li><code>manual</code></li>\n<li><code>commit-msg</code></li>\n<li><code>post-checkout</code></li>\n<li><code>post-commit</code></li>\n<li><code>post-merge</code></li>\n<li><code>post-rewrite</code></li>\n<li><code>pre-commit</code></li>\n<li><code>pre-merge-commit</code></li>\n<li><code>pre-push</code></li>\n<li><code>pre-rebase</code></li>\n<li><code>prepare-commit-msg</code></li>\n</ul></dd><dt id=\"prek-list--language\"><a href=\"#prek-list--language\"><code>--language</code></a> <i>language</i></dt><dd><p>Show only hooks that are implemented in the specified language</p>\n<p>Possible values:</p>\n<ul>\n<li><code>bun</code></li>\n<li><code>conda</code></li>\n<li><code>coursier</code></li>\n<li><code>dart</code></li>\n<li><code>deno</code></li>\n<li><code>docker</code></li>\n<li><code>docker-image</code></li>\n<li><code>dotnet</code></li>\n<li><code>fail</code></li>\n<li><code>golang</code></li>\n<li><code>haskell</code></li>\n<li><code>julia</code></li>\n<li><code>lua</code></li>\n<li><code>node</code></li>\n<li><code>perl</code></li>\n<li><code>pygrep</code></li>\n<li><code>python</code></li>\n<li><code>r</code></li>\n<li><code>ruby</code></li>\n<li><code>rust</code></li>\n<li><code>script</code></li>\n<li><code>swift</code></li>\n<li><code>system</code></li>\n</ul></dd><dt id=\"prek-list--log-file\"><a href=\"#prek-list--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-list--no-progress\"><a href=\"#prek-list--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-list--output-format\"><a href=\"#prek-list--output-format\"><code>--output-format</code></a> <i>output-format</i></dt><dd><p>The output format</p>\n<p>[default: text]</p><p>Possible values:</p>\n<ul>\n<li><code>text</code></li>\n<li><code>json</code></li>\n</ul></dd><dt id=\"prek-list--quiet\"><a href=\"#prek-list--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-list--refresh\"><a href=\"#prek-list--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-list--skip\"><a href=\"#prek-list--skip\"><code>--skip</code></a> <i>hook|project</i></dt><dd><p>Skip the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Skip all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Skip all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Skip only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times. Also accepts <code>PREK_SKIP</code> or <code>SKIP</code> environment variables (comma-delimited).</p>\n</dd><dt id=\"prek-list--verbose\"><a href=\"#prek-list--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-list--version\"><a href=\"#prek-list--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek uninstall\n\nUninstall prek Git shims\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek uninstall [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-uninstall--all\"><a href=\"#prek-uninstall--all\"><code>--all</code></a></dt><dd><p>Uninstall all prek-managed Git shims.</p>\n<p>Scans the hooks directory and removes every hook managed by prek, regardless of hook type.</p>\n</dd><dt id=\"prek-uninstall--cd\"><a href=\"#prek-uninstall--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-uninstall--color\"><a href=\"#prek-uninstall--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-uninstall--config\"><a href=\"#prek-uninstall--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-uninstall--help\"><a href=\"#prek-uninstall--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-uninstall--hook-type\"><a href=\"#prek-uninstall--hook-type\"><code>--hook-type</code></a>, <code>-t</code> <i>hook-type</i></dt><dd><p>Which Git shim(s) to uninstall.</p>\n<p>Specifies which Git hook type(s) you want to uninstall shims for. Can be specified multiple times to uninstall shims for multiple hook types.</p>\n<p>If not specified, uses <code>default_install_hook_types</code> from the config file, or defaults to <code>pre-commit</code> if that is also not set. Use <code>--all</code> to remove all prek-managed hooks.</p>\n<p>Possible values:</p>\n<ul>\n<li><code>commit-msg</code></li>\n<li><code>post-checkout</code></li>\n<li><code>post-commit</code></li>\n<li><code>post-merge</code></li>\n<li><code>post-rewrite</code></li>\n<li><code>pre-commit</code></li>\n<li><code>pre-merge-commit</code></li>\n<li><code>pre-push</code></li>\n<li><code>pre-rebase</code></li>\n<li><code>prepare-commit-msg</code></li>\n</ul></dd><dt id=\"prek-uninstall--log-file\"><a href=\"#prek-uninstall--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-uninstall--no-progress\"><a href=\"#prek-uninstall--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-uninstall--quiet\"><a href=\"#prek-uninstall--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-uninstall--refresh\"><a href=\"#prek-uninstall--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-uninstall--verbose\"><a href=\"#prek-uninstall--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-uninstall--version\"><a href=\"#prek-uninstall--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek validate-config\n\nValidate configuration files (prek.toml or .pre-commit-config.yaml)\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek validate-config [OPTIONS] [CONFIG]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-validate-config--configs\"><a href=\"#prek-validate-config--configs\"><code>CONFIG</code></a></dt><dd><p>The path to the configuration file</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-validate-config--cd\"><a href=\"#prek-validate-config--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-validate-config--color\"><a href=\"#prek-validate-config--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-validate-config--config\"><a href=\"#prek-validate-config--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-validate-config--help\"><a href=\"#prek-validate-config--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-validate-config--log-file\"><a href=\"#prek-validate-config--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-validate-config--no-progress\"><a href=\"#prek-validate-config--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-validate-config--quiet\"><a href=\"#prek-validate-config--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-validate-config--refresh\"><a href=\"#prek-validate-config--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-validate-config--verbose\"><a href=\"#prek-validate-config--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-validate-config--version\"><a href=\"#prek-validate-config--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek validate-manifest\n\nValidate `.pre-commit-hooks.yaml` files\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek validate-manifest [OPTIONS] [MANIFEST]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-validate-manifest--manifests\"><a href=\"#prek-validate-manifest--manifests\"><code>MANIFEST</code></a></dt><dd><p>The path to the manifest file</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-validate-manifest--cd\"><a href=\"#prek-validate-manifest--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-validate-manifest--color\"><a href=\"#prek-validate-manifest--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-validate-manifest--config\"><a href=\"#prek-validate-manifest--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-validate-manifest--help\"><a href=\"#prek-validate-manifest--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-validate-manifest--log-file\"><a href=\"#prek-validate-manifest--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-validate-manifest--no-progress\"><a href=\"#prek-validate-manifest--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-validate-manifest--quiet\"><a href=\"#prek-validate-manifest--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-validate-manifest--refresh\"><a href=\"#prek-validate-manifest--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-validate-manifest--verbose\"><a href=\"#prek-validate-manifest--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-validate-manifest--version\"><a href=\"#prek-validate-manifest--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek sample-config\n\nProduce a sample configuration file (prek.toml or .pre-commit-config.yaml)\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek sample-config [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-sample-config--cd\"><a href=\"#prek-sample-config--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-sample-config--color\"><a href=\"#prek-sample-config--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-sample-config--config\"><a href=\"#prek-sample-config--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-sample-config--file\"><a href=\"#prek-sample-config--file\"><code>--file</code></a>, <code>-f</code> <i>file</i></dt><dd><p>Write the sample config to a file.</p>\n<p>Defaults to <code>.pre-commit-config.yaml</code> unless <code>--format toml</code> is set, which uses <code>prek.toml</code>. If a path is provided without <code>--format</code>, the format is inferred from the file extension (<code>.toml</code> uses TOML).</p>\n</dd><dt id=\"prek-sample-config--format\"><a href=\"#prek-sample-config--format\"><code>--format</code></a> <i>format</i></dt><dd><p>Select the sample configuration format</p>\n<p>Possible values:</p>\n<ul>\n<li><code>yaml</code></li>\n<li><code>toml</code></li>\n</ul></dd><dt id=\"prek-sample-config--help\"><a href=\"#prek-sample-config--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-sample-config--log-file\"><a href=\"#prek-sample-config--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-sample-config--no-progress\"><a href=\"#prek-sample-config--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-sample-config--quiet\"><a href=\"#prek-sample-config--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-sample-config--refresh\"><a href=\"#prek-sample-config--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-sample-config--verbose\"><a href=\"#prek-sample-config--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-sample-config--version\"><a href=\"#prek-sample-config--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek auto-update\n\nAuto-update the `rev` field of repositories in the config file to the latest version\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek auto-update [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-auto-update--bleeding-edge\"><a href=\"#prek-auto-update--bleeding-edge\"><code>--bleeding-edge</code></a></dt><dd><p>Update to the bleeding edge of the default branch instead of the latest tagged version</p>\n</dd><dt id=\"prek-auto-update--cd\"><a href=\"#prek-auto-update--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-auto-update--color\"><a href=\"#prek-auto-update--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-auto-update--config\"><a href=\"#prek-auto-update--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-auto-update--cooldown-days\"><a href=\"#prek-auto-update--cooldown-days\"><code>--cooldown-days</code></a> <i>days</i></dt><dd><p>Minimum release age (in days) required for a version to be eligible.</p>\n<p>The age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags. A value of <code>0</code> disables this check.</p>\n<p>[default: 0]</p></dd><dt id=\"prek-auto-update--dry-run\"><a href=\"#prek-auto-update--dry-run\"><code>--dry-run</code></a></dt><dd><p>Do not write changes to the config file, only display what would be changed</p>\n</dd><dt id=\"prek-auto-update--freeze\"><a href=\"#prek-auto-update--freeze\"><code>--freeze</code></a></dt><dd><p>Store &quot;frozen&quot; hashes in <code>rev</code> instead of tag names</p>\n</dd><dt id=\"prek-auto-update--help\"><a href=\"#prek-auto-update--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-auto-update--jobs\"><a href=\"#prek-auto-update--jobs\"><code>--jobs</code></a>, <code>-j</code> <i>jobs</i></dt><dd><p>Number of threads to use</p>\n<p>[default: 0]</p></dd><dt id=\"prek-auto-update--log-file\"><a href=\"#prek-auto-update--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-auto-update--no-progress\"><a href=\"#prek-auto-update--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-auto-update--quiet\"><a href=\"#prek-auto-update--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-auto-update--refresh\"><a href=\"#prek-auto-update--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-auto-update--repo\"><a href=\"#prek-auto-update--repo\"><code>--repo</code></a> <i>repo</i></dt><dd><p>Only update this repository. This option may be specified multiple times</p>\n</dd><dt id=\"prek-auto-update--verbose\"><a href=\"#prek-auto-update--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-auto-update--version\"><a href=\"#prek-auto-update--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek cache\n\nManage the prek cache\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek cache [OPTIONS] <COMMAND>\n```\n\n<h3 class=\"cli-reference\">Commands</h3>\n\n<dl class=\"cli-reference\"><dt><a href=\"#prek-cache-dir\"><code>prek cache dir</code></a></dt><dd><p>Show the location of the prek cache</p></dd>\n<dt><a href=\"#prek-cache-gc\"><code>prek cache gc</code></a></dt><dd><p>Remove unused cached repositories, hook environments, and other data</p></dd>\n<dt><a href=\"#prek-cache-clean\"><code>prek cache clean</code></a></dt><dd><p>Remove all prek cached data</p></dd>\n<dt><a href=\"#prek-cache-size\"><code>prek cache size</code></a></dt><dd><p>Show the size of the prek cache</p></dd>\n</dl>\n\n### prek cache dir\n\nShow the location of the prek cache\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek cache dir [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-cache-dir--cd\"><a href=\"#prek-cache-dir--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-cache-dir--color\"><a href=\"#prek-cache-dir--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-cache-dir--config\"><a href=\"#prek-cache-dir--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-cache-dir--help\"><a href=\"#prek-cache-dir--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-cache-dir--log-file\"><a href=\"#prek-cache-dir--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-cache-dir--no-progress\"><a href=\"#prek-cache-dir--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-cache-dir--quiet\"><a href=\"#prek-cache-dir--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-cache-dir--refresh\"><a href=\"#prek-cache-dir--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-cache-dir--verbose\"><a href=\"#prek-cache-dir--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-cache-dir--version\"><a href=\"#prek-cache-dir--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n### prek cache gc\n\nRemove unused cached repositories, hook environments, and other data\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek cache gc [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-cache-gc--cd\"><a href=\"#prek-cache-gc--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-cache-gc--color\"><a href=\"#prek-cache-gc--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-cache-gc--config\"><a href=\"#prek-cache-gc--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-cache-gc--dry-run\"><a href=\"#prek-cache-gc--dry-run\"><code>--dry-run</code></a></dt><dd><p>Print what would be removed, but do not delete anything</p>\n</dd><dt id=\"prek-cache-gc--help\"><a href=\"#prek-cache-gc--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-cache-gc--log-file\"><a href=\"#prek-cache-gc--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-cache-gc--no-progress\"><a href=\"#prek-cache-gc--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-cache-gc--quiet\"><a href=\"#prek-cache-gc--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-cache-gc--refresh\"><a href=\"#prek-cache-gc--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-cache-gc--verbose\"><a href=\"#prek-cache-gc--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-cache-gc--version\"><a href=\"#prek-cache-gc--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n### prek cache clean\n\nRemove all prek cached data\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek cache clean [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-cache-clean--cd\"><a href=\"#prek-cache-clean--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-cache-clean--color\"><a href=\"#prek-cache-clean--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-cache-clean--config\"><a href=\"#prek-cache-clean--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-cache-clean--help\"><a href=\"#prek-cache-clean--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-cache-clean--log-file\"><a href=\"#prek-cache-clean--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-cache-clean--no-progress\"><a href=\"#prek-cache-clean--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-cache-clean--quiet\"><a href=\"#prek-cache-clean--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-cache-clean--refresh\"><a href=\"#prek-cache-clean--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-cache-clean--verbose\"><a href=\"#prek-cache-clean--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-cache-clean--version\"><a href=\"#prek-cache-clean--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n### prek cache size\n\nShow the size of the prek cache\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek cache size [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-cache-size--cd\"><a href=\"#prek-cache-size--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-cache-size--color\"><a href=\"#prek-cache-size--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-cache-size--config\"><a href=\"#prek-cache-size--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-cache-size--help\"><a href=\"#prek-cache-size--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-cache-size--human\"><a href=\"#prek-cache-size--human\"><code>--human</code></a>, <code>--human-readable</code>, <code>-H</code></dt><dd><p>Display the cache size in human-readable format (e.g., <code>1.2 GiB</code> instead of raw bytes)</p>\n</dd><dt id=\"prek-cache-size--log-file\"><a href=\"#prek-cache-size--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-cache-size--no-progress\"><a href=\"#prek-cache-size--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-cache-size--quiet\"><a href=\"#prek-cache-size--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-cache-size--refresh\"><a href=\"#prek-cache-size--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-cache-size--verbose\"><a href=\"#prek-cache-size--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-cache-size--version\"><a href=\"#prek-cache-size--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek try-repo\n\nTry the pre-commit hooks in the current repo\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek try-repo [OPTIONS] <REPO> [HOOK|PROJECT]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-try-repo--repo\"><a href=\"#prek-try-repo--repo\"><code>REPO</code></a></dt><dd><p>Repository to source hooks from</p>\n</dd><dt id=\"prek-try-repo--includes\"><a href=\"#prek-try-repo--includes\"><code>HOOK|PROJECT</code></a></dt><dd><p>Include the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Run all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Run all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Run only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times to select multiple hooks/projects.</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-try-repo--all-files\"><a href=\"#prek-try-repo--all-files\"><code>--all-files</code></a>, <code>-a</code></dt><dd><p>Run on all files in the repo</p>\n</dd><dt id=\"prek-try-repo--cd\"><a href=\"#prek-try-repo--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-try-repo--color\"><a href=\"#prek-try-repo--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-try-repo--config\"><a href=\"#prek-try-repo--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-try-repo--directory\"><a href=\"#prek-try-repo--directory\"><code>--directory</code></a>, <code>-d</code> <i>dir</i></dt><dd><p>Run hooks on all files in the specified directories.</p>\n<p>You can specify multiple directories. It can be used in conjunction with <code>--files</code>.</p>\n</dd><dt id=\"prek-try-repo--dry-run\"><a href=\"#prek-try-repo--dry-run\"><code>--dry-run</code></a></dt><dd><p>Do not run the hooks, but print the hooks that would have been run</p>\n</dd><dt id=\"prek-try-repo--fail-fast\"><a href=\"#prek-try-repo--fail-fast\"><code>--fail-fast</code></a></dt><dd><p>Stop running hooks after the first failure</p>\n</dd><dt id=\"prek-try-repo--files\"><a href=\"#prek-try-repo--files\"><code>--files</code></a> <i>files</i></dt><dd><p>Specific filenames to run hooks on</p>\n</dd><dt id=\"prek-try-repo--from-ref\"><a href=\"#prek-try-repo--from-ref\"><code>--from-ref</code></a>, <code>--source</code>, <code>-s</code> <i>from-ref</i></dt><dd><p>The original ref in a <code>&lt;from_ref&gt;...&lt;to_ref&gt;</code> diff expression. Files changed in this diff will be run through the hooks</p>\n</dd><dt id=\"prek-try-repo--help\"><a href=\"#prek-try-repo--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-try-repo--last-commit\"><a href=\"#prek-try-repo--last-commit\"><code>--last-commit</code></a></dt><dd><p>Run hooks against the last commit. Equivalent to <code>--from-ref HEAD~1 --to-ref HEAD</code></p>\n</dd><dt id=\"prek-try-repo--log-file\"><a href=\"#prek-try-repo--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-try-repo--no-progress\"><a href=\"#prek-try-repo--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-try-repo--quiet\"><a href=\"#prek-try-repo--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-try-repo--refresh\"><a href=\"#prek-try-repo--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-try-repo--rev\"><a href=\"#prek-try-repo--rev\"><code>--rev</code></a>, <code>--ref</code> <i>rev</i></dt><dd><p>Manually select a rev to run against, otherwise the <code>HEAD</code> revision will be used</p>\n</dd><dt id=\"prek-try-repo--show-diff-on-failure\"><a href=\"#prek-try-repo--show-diff-on-failure\"><code>--show-diff-on-failure</code></a></dt><dd><p>When hooks fail, run <code>git diff</code> directly afterward</p>\n</dd><dt id=\"prek-try-repo--skip\"><a href=\"#prek-try-repo--skip\"><code>--skip</code></a> <i>hook|project</i></dt><dd><p>Skip the specified hooks or projects.</p>\n<p>Supports flexible selector syntax:</p>\n<ul>\n<li>\n<p><code>hook-id</code>: Skip all hooks with the specified ID across all projects</p>\n</li>\n<li>\n<p><code>project-path/</code>: Skip all hooks from the specified project</p>\n</li>\n<li>\n<p><code>project-path:hook-id</code>: Skip only the specified hook from the specified project</p>\n</li>\n</ul>\n<p>Can be specified multiple times. Also accepts <code>PREK_SKIP</code> or <code>SKIP</code> environment variables (comma-delimited).</p>\n</dd><dt id=\"prek-try-repo--stage\"><a href=\"#prek-try-repo--stage\"><code>--stage</code></a>, <code>--hook-stage</code> <i>stage</i></dt><dd><p>The stage during which the hook is fired.</p>\n<p>When specified, only hooks configured for that stage (for example <code>manual</code>, <code>pre-commit</code>, or <code>pre-push</code>) will run. Defaults to <code>pre-commit</code> if not specified. For hooks specified directly in the command line, fallback to <code>manual</code> stage if no hooks found for <code>pre-commit</code> stage.</p>\n<p>Possible values:</p>\n<ul>\n<li><code>manual</code></li>\n<li><code>commit-msg</code></li>\n<li><code>post-checkout</code></li>\n<li><code>post-commit</code></li>\n<li><code>post-merge</code></li>\n<li><code>post-rewrite</code></li>\n<li><code>pre-commit</code></li>\n<li><code>pre-merge-commit</code></li>\n<li><code>pre-push</code></li>\n<li><code>pre-rebase</code></li>\n<li><code>prepare-commit-msg</code></li>\n</ul></dd><dt id=\"prek-try-repo--to-ref\"><a href=\"#prek-try-repo--to-ref\"><code>--to-ref</code></a>, <code>--origin</code>, <code>-o</code> <i>to-ref</i></dt><dd><p>The destination ref in a <code>from_ref...to_ref</code> diff expression. Defaults to <code>HEAD</code> if <code>from_ref</code> is specified</p>\n</dd><dt id=\"prek-try-repo--verbose\"><a href=\"#prek-try-repo--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-try-repo--version\"><a href=\"#prek-try-repo--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek util\n\nUtility commands\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek util [OPTIONS] <COMMAND>\n```\n\n<h3 class=\"cli-reference\">Commands</h3>\n\n<dl class=\"cli-reference\"><dt><a href=\"#prek-util-identify\"><code>prek util identify</code></a></dt><dd><p>Show file identification tags</p></dd>\n<dt><a href=\"#prek-util-list-builtins\"><code>prek util list-builtins</code></a></dt><dd><p>List all built-in hooks bundled with prek</p></dd>\n<dt><a href=\"#prek-util-init-template-dir\"><code>prek util init-template-dir</code></a></dt><dd><p>Install Git shims in a directory intended for use with <code>git config init.templateDir</code></p></dd>\n<dt><a href=\"#prek-util-yaml-to-toml\"><code>prek util yaml-to-toml</code></a></dt><dd><p>Convert a YAML configuration file to prek.toml</p></dd>\n</dl>\n\n### prek util identify\n\nShow file identification tags\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek util identify [OPTIONS] [PATH]...\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-identify--paths\"><a href=\"#prek-util-identify--paths\"><code>PATH</code></a></dt><dd><p>The path(s) to the file(s) to identify</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-identify--cd\"><a href=\"#prek-util-identify--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-util-identify--color\"><a href=\"#prek-util-identify--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-util-identify--config\"><a href=\"#prek-util-identify--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-util-identify--help\"><a href=\"#prek-util-identify--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-util-identify--log-file\"><a href=\"#prek-util-identify--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-util-identify--no-progress\"><a href=\"#prek-util-identify--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-util-identify--output-format\"><a href=\"#prek-util-identify--output-format\"><code>--output-format</code></a> <i>output-format</i></dt><dd><p>The output format</p>\n<p>[default: text]</p><p>Possible values:</p>\n<ul>\n<li><code>text</code></li>\n<li><code>json</code></li>\n</ul></dd><dt id=\"prek-util-identify--quiet\"><a href=\"#prek-util-identify--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-util-identify--refresh\"><a href=\"#prek-util-identify--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-util-identify--verbose\"><a href=\"#prek-util-identify--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-util-identify--version\"><a href=\"#prek-util-identify--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n### prek util list-builtins\n\nList all built-in hooks bundled with prek\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek util list-builtins [OPTIONS]\n```\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-list-builtins--cd\"><a href=\"#prek-util-list-builtins--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-util-list-builtins--color\"><a href=\"#prek-util-list-builtins--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-util-list-builtins--config\"><a href=\"#prek-util-list-builtins--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-util-list-builtins--help\"><a href=\"#prek-util-list-builtins--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-util-list-builtins--log-file\"><a href=\"#prek-util-list-builtins--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-util-list-builtins--no-progress\"><a href=\"#prek-util-list-builtins--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-util-list-builtins--output-format\"><a href=\"#prek-util-list-builtins--output-format\"><code>--output-format</code></a> <i>output-format</i></dt><dd><p>The output format</p>\n<p>[default: text]</p><p>Possible values:</p>\n<ul>\n<li><code>text</code></li>\n<li><code>json</code></li>\n</ul></dd><dt id=\"prek-util-list-builtins--quiet\"><a href=\"#prek-util-list-builtins--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-util-list-builtins--refresh\"><a href=\"#prek-util-list-builtins--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-util-list-builtins--verbose\"><a href=\"#prek-util-list-builtins--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-util-list-builtins--version\"><a href=\"#prek-util-list-builtins--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n### prek util init-template-dir\n\nInstall Git shims in a directory intended for use with `git config init.templateDir`\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek util init-template-dir [OPTIONS] <DIRECTORY>\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-init-template-dir--directory\"><a href=\"#prek-util-init-template-dir--directory\"><code>DIRECTORY</code></a></dt><dd><p>The directory in which to write the Git shim</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-init-template-dir--cd\"><a href=\"#prek-util-init-template-dir--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-util-init-template-dir--color\"><a href=\"#prek-util-init-template-dir--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-util-init-template-dir--config\"><a href=\"#prek-util-init-template-dir--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-util-init-template-dir--help\"><a href=\"#prek-util-init-template-dir--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-util-init-template-dir--hook-type\"><a href=\"#prek-util-init-template-dir--hook-type\"><code>--hook-type</code></a>, <code>-t</code> <i>hook-type</i></dt><dd><p>Which Git shim(s) to install.</p>\n<p>Specifies which Git hook type(s) you want to install shims for. Can be specified multiple times to install shims for multiple hook types.</p>\n<p>If not specified, uses <code>default_install_hook_types</code> from the config file, or defaults to <code>pre-commit</code> if that is also not set.</p>\n<p>Possible values:</p>\n<ul>\n<li><code>commit-msg</code></li>\n<li><code>post-checkout</code></li>\n<li><code>post-commit</code></li>\n<li><code>post-merge</code></li>\n<li><code>post-rewrite</code></li>\n<li><code>pre-commit</code></li>\n<li><code>pre-merge-commit</code></li>\n<li><code>pre-push</code></li>\n<li><code>pre-rebase</code></li>\n<li><code>prepare-commit-msg</code></li>\n</ul></dd><dt id=\"prek-util-init-template-dir--log-file\"><a href=\"#prek-util-init-template-dir--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-util-init-template-dir--no-allow-missing-config\"><a href=\"#prek-util-init-template-dir--no-allow-missing-config\"><code>--no-allow-missing-config</code></a></dt><dd><p>Assume cloned repos should have a <code>pre-commit</code> config</p>\n</dd><dt id=\"prek-util-init-template-dir--no-progress\"><a href=\"#prek-util-init-template-dir--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-util-init-template-dir--quiet\"><a href=\"#prek-util-init-template-dir--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-util-init-template-dir--refresh\"><a href=\"#prek-util-init-template-dir--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-util-init-template-dir--verbose\"><a href=\"#prek-util-init-template-dir--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-util-init-template-dir--version\"><a href=\"#prek-util-init-template-dir--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n### prek util yaml-to-toml\n\nConvert a YAML configuration file to prek.toml\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek util yaml-to-toml [OPTIONS] [CONFIG]\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-yaml-to-toml--input\"><a href=\"#prek-util-yaml-to-toml--input\"><code>CONFIG</code></a></dt><dd><p>The YAML configuration file to convert. If omitted, discovers <code>.pre-commit-config.yaml</code> or <code>.pre-commit-config.yml</code> in the current directory</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-util-yaml-to-toml--cd\"><a href=\"#prek-util-yaml-to-toml--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-util-yaml-to-toml--color\"><a href=\"#prek-util-yaml-to-toml--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-util-yaml-to-toml--config\"><a href=\"#prek-util-yaml-to-toml--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-util-yaml-to-toml--force\"><a href=\"#prek-util-yaml-to-toml--force\"><code>--force</code></a></dt><dd><p>Overwrite the output file if it already exists</p>\n</dd><dt id=\"prek-util-yaml-to-toml--help\"><a href=\"#prek-util-yaml-to-toml--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-util-yaml-to-toml--log-file\"><a href=\"#prek-util-yaml-to-toml--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-util-yaml-to-toml--no-progress\"><a href=\"#prek-util-yaml-to-toml--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-util-yaml-to-toml--output\"><a href=\"#prek-util-yaml-to-toml--output\"><code>--output</code></a>, <code>-o</code> <i>output</i></dt><dd><p>Path to write the generated prek.toml file. Defaults to <code>prek.toml</code> in the same directory as the input file</p>\n</dd><dt id=\"prek-util-yaml-to-toml--quiet\"><a href=\"#prek-util-yaml-to-toml--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-util-yaml-to-toml--refresh\"><a href=\"#prek-util-yaml-to-toml--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-util-yaml-to-toml--verbose\"><a href=\"#prek-util-yaml-to-toml--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-util-yaml-to-toml--version\"><a href=\"#prek-util-yaml-to-toml--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n\n## prek self\n\n`prek` self management\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek self [OPTIONS] <COMMAND>\n```\n\n<h3 class=\"cli-reference\">Commands</h3>\n\n<dl class=\"cli-reference\"><dt><a href=\"#prek-self-update\"><code>prek self update</code></a></dt><dd><p>Update prek</p></dd>\n</dl>\n\n### prek self update\n\nUpdate prek\n\n<h3 class=\"cli-reference\">Usage</h3>\n\n```\nprek self update [OPTIONS] [TARGET_VERSION]\n```\n\n<h3 class=\"cli-reference\">Arguments</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-self-update--target_version\"><a href=\"#prek-self-update--target_version\"><code>TARGET_VERSION</code></a></dt><dd><p>Update to the specified version. If not provided, prek will update to the latest version</p>\n</dd></dl>\n\n<h3 class=\"cli-reference\">Options</h3>\n\n<dl class=\"cli-reference\"><dt id=\"prek-self-update--cd\"><a href=\"#prek-self-update--cd\"><code>--cd</code></a>, <code>-C</code> <i>dir</i></dt><dd><p>Change to directory before running</p>\n</dd><dt id=\"prek-self-update--color\"><a href=\"#prek-self-update--color\"><code>--color</code></a> <i>color</i></dt><dd><p>Whether to use color in output</p>\n<p>May also be set with the <code>PREK_COLOR</code> environment variable.</p><p>[default: auto]</p><p>Possible values:</p>\n<ul>\n<li><code>auto</code>:  Enables colored output only when the output is going to a terminal or TTY with support</li>\n<li><code>always</code>:  Enables colored output regardless of the detected environment</li>\n<li><code>never</code>:  Disables colored output</li>\n</ul></dd><dt id=\"prek-self-update--config\"><a href=\"#prek-self-update--config\"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>\n</dd><dt id=\"prek-self-update--help\"><a href=\"#prek-self-update--help\"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>\n</dd><dt id=\"prek-self-update--log-file\"><a href=\"#prek-self-update--log-file\"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>\n</dd><dt id=\"prek-self-update--no-progress\"><a href=\"#prek-self-update--no-progress\"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>\n<p>For example, spinners or progress bars.</p>\n</dd><dt id=\"prek-self-update--quiet\"><a href=\"#prek-self-update--quiet\"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>\n<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>\n<p>May also be set with the <code>PREK_QUIET</code> environment variable.</p></dd><dt id=\"prek-self-update--refresh\"><a href=\"#prek-self-update--refresh\"><code>--refresh</code></a></dt><dd><p>Refresh all cached data</p>\n</dd><dt id=\"prek-self-update--token\"><a href=\"#prek-self-update--token\"><code>--token</code></a> <i>token</i></dt><dd><p>A GitHub token for authentication. A token is not required but can be used to reduce the chance of encountering rate limits</p>\n<p>May also be set with the <code>GITHUB_TOKEN</code> environment variable.</p></dd><dt id=\"prek-self-update--verbose\"><a href=\"#prek-self-update--verbose\"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output</p>\n</dd><dt id=\"prek-self-update--version\"><a href=\"#prek-self-update--version\"><code>--version</code></a>, <code>-V</code></dt><dd><p>Display the prek version</p>\n</dd></dl>\n"
  },
  {
    "path": "docs/compatibility.md",
    "content": "# Compatibility with pre-commit\n\n`prek` is designed to be a practical drop-in replacement for `pre-commit`.\n\n- Existing `.pre-commit-config.yaml` and `.pre-commit-config.yml` files work unchanged. See [Configuration](configuration.md).\n- Most day-to-day `pre-commit` commands work unchanged in `prek`.\n\n## Command and flag differences\n\nOnly the commands and flags below differ from the preferred `prek` spelling. The compatibility forms are still accepted so existing scripts do not break.\n\n- `prek install-hooks` still works, but `prek prepare-hooks` is the preferred spelling.\n- `prek install --install-hooks` still works, but `prek install --prepare-hooks` is the preferred flag spelling.\n- `prek autoupdate` still works, but `prek auto-update` is the preferred spelling.\n- `prek gc` still works as a hidden compatibility command, but `prek cache gc` is preferred.\n- `prek clean` still works as a hidden compatibility command, but `prek cache clean` is preferred.\n- `prek init-templatedir` and `prek init-template-dir` still work as hidden compatibility commands, but `prek util init-template-dir` is preferred.\n- `pre-commit hazmat` is not implemented in `prek`.\n- `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`.\n\nIf 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).\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "# Configuration\n\n`prek` reads **one configuration file per project**. You only need to choose **one** format:\n\n- **prek.toml** (TOML) — recommended for new users\n- **.pre-commit-config.yaml** (YAML) — best if you already use pre-commit or rely on tool/editor support\n\nBoth 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.\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"https://github.com/pre-commit/pre-commit-hooks\"\n    hooks = [{ id = \"trailing-whitespace\" }]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: https://github.com/pre-commit/pre-commit-hooks\n        hooks:\n          - id: trailing-whitespace\n    ```\n\n## Pre-commit compatibility\n\n`prek` is **fully compatible** with [`pre-commit`](https://pre-commit.com/) YAML configs, so your existing `.pre-commit-config.yaml` files work unchanged.\n\nIf you use **`prek.toml`**, there’s nothing to worry about from a `pre-commit` perspective: upstream `pre-commit` does not read TOML.\n\nIf you use the same `.pre-commit-config.yaml` with both tools, keep in mind:\n\n- `prek` supports several extensions beyond upstream `pre-commit`.\n- Upstream `pre-commit` may warn about unknown keys or error out on unsupported features.\n- To stay maximally portable, avoid the extensions listed below (or keep separate configs).\n\nNotable differences (when using YAML):\n\n- **Workspace mode** is a `prek` feature that can discover multiple projects; upstream `pre-commit` is single-project.\n- `files` / `exclude` can be written as **glob mappings** in `prek` (in addition to regex), which is not supported by upstream `pre-commit`.\n- `repo: builtin` adds fast built-in hooks in `prek`.\n- Upstream `pre-commit` uses `minimum_pre_commit_version`, while `prek` uses `minimum_prek_version` and intentionally ignores `minimum_pre_commit_version`.\n\n### Prek-only extensions\n\nThese entries are implemented by `prek` and are not part of the documented upstream `pre-commit` configuration surface.\nThey work in both YAML and TOML, but they only matter for compatibility if you share a YAML config with upstream `pre-commit`.\n\n- Top-level:\n    - [`minimum_prek_version`](#prek-only-minimum-prek-version-config)\n    - [`orphan`](#prek-only-orphan)\n- Repo type:\n    - [`repo: builtin`](#prek-only-repo-builtin)\n- Hook-level:\n    - [`env`](#prek-only-env)\n    - [`priority`](#prek-only-priority)\n    - [`minimum_prek_version`](#prek-only-minimum-prek-version-hook)\n\n## Configuration file\n\n### Location (discovery)\n\nBy default, `prek` looks for a configuration file starting from your current working directory and moving upward.\nIt stops when it finds a config file, or when it hits the git repository boundary.\n\nIf you run **without** `--config`, `prek` then enables **workspace mode**:\n\n- The first config found while traversing upward becomes the workspace root.\n- From that root, `prek` searches for additional config files in subdirectories (nested projects).\n\nWorkspace discovery respects `.gitignore`, and also supports `.prekignore` for excluding directories from discovery.\nFor the full behavior and examples, see [Workspace Mode](workspace.md).\n\n!!! tip\n\n    After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up.\n\nIf you pass `--config` / `-c`, workspace discovery is disabled and only that single config file is used.\n\n### File name\n\n`prek` recognizes the following configuration filenames:\n\n- `prek.toml` (TOML)\n- `.pre-commit-config.yaml` (YAML, preferred for pre-commit compatibility)\n- `.pre-commit-config.yml` (YAML, alternate)\n\nIn workspace mode, each project uses one of these filenames in its own directory.\n\n!!! note \"One format per repo\"\n\n    We recommend using a **single format** across the whole repository to avoid confusion.\n\n    If multiple configuration files exist in the same directory, `prek` uses only one and ignores the rest.\n    The precedence order is:\n\n    1. `prek.toml`\n    2. `.pre-commit-config.yaml`\n    3. `.pre-commit-config.yml`\n\n### File format\n\nBoth `prek.toml` and `.pre-commit-config.yaml` map to the same configuration model (repositories under `repos`, then `hooks` under each repo).\n\nThis section focuses on format-specific authoring notes and examples.\n\n#### TOML (`prek.toml`)\n\nPractical notes:\n\n- Structure is explicit and less indentation-sensitive.\n- Inline tables are common for hooks (e.g. `{ id = \"ruff\" }`).\n\nTOML supports both **inline tables** and **array-of-tables**, so you can choose between a compact or expanded hook style.\n\nInline tables (best for small/simple hook configs):\n\n```toml\n[[repos]]\nrepo = \"https://github.com/pre-commit/pre-commit-hooks\"\nrev = \"v6.0.0\"\nhooks = [\n  { id = \"end-of-file-fixer\", args = [\"--fix\"] },\n]\n```\n\nArray-of-tables (more readable for larger hook configs):\n\n```toml\n[[repos]]\nrepo = \"https://github.com/pre-commit/pre-commit-hooks\"\nrev = \"v6.0.0\"\n\n[[repos.hooks]]\nid = \"trailing-whitespace\"\n\n[[repos.hooks]]\nid = \"check-json\"\n```\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    default_language_version.python = \"3.12\"\n\n    [[repos]]\n    repo = \"local\"\n    hooks = [\n      {\n        id = \"ruff\",\n        name = \"ruff\",\n        language = \"system\",\n        entry = \"python3 -m ruff check\",\n        files = \"\\\\.py$\",\n      },\n    ]\n    ```\n\nThe previous example uses multiline inline tables, a feature that was introduced in\n[TOML 1.1](https://toml.io/en/v1.1.0), not all parsers have support for it yet.\nYou may want to use the longer form if your editor/IDE complains about it.\n\n=== \"prek.toml\"\n\n    ```toml\n    default_language_version.python = \"3.12\"\n\n    [[repos]]\n    repo = \"local\"\n\n    [[repos.hooks]]\n    id = \"ruff\"\n    name = \"ruff\"\n    language = \"system\"\n    entry = \"python3 -m ruff check\"\n    files = \"\\\\.py$\"\n    ```\n\n#### YAML (`.pre-commit-config.yaml` / `.yml`)\n\nPractical notes:\n\n- Regular expressions are provided as YAML strings.\n  If your regex contains backslashes, quote it (e.g. `files: '\\\\.rs$'`).\n- YAML anchors/aliases and merge keys are supported, so you can de-duplicate repeated blocks.\n\nExample:\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    default_language_version:\n      python: \"3.12\"\n\n    repos:\n      - repo: local\n        hooks:\n          - id: ruff\n            name: ruff\n            language: system\n            entry: python3 -m ruff check\n            files: \"\\\\.py$\"\n    ```\n\n#### Choosing a format\n\n**`prek.toml`**\n\n- Clearer structure and less error-prone syntax.\n- Recommended for new users or new projects.\n\n**`.pre-commit-config.yaml`**\n\n- Long-established in the ecosystem with broad tool/editor support.\n- Fully compatible with upstream `pre-commit`.\n\n**Recommendation**\n\n- If you already use `.pre-commit-config.yaml`, keep it.\n- If you want a cleaner, more robust authoring experience, prefer `prek.toml`.\n\n!!! tip\n\n    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`.\n    YAML comments are not preserved during conversion.\n\n### Scope (per-project)\n\nEach configuration file (`prek.toml`, `.pre-commit-config.yaml`, or `.pre-commit-config.yml`) is scoped to the **project directory it lives in**.\n\nIn workspace mode, `prek` treats every discovered configuration file as a **distinct project**:\n\n- A project’s config only controls hook selection and filtering (for example `files` / `exclude`) for that project.\n- A project may contain nested subprojects (subdirectories with their own config). Those subprojects run using *their own* configs.\n\nPractical implication: filters in the parent project do not “turn off” a subproject.\n\nExample layout (monorepo with a nested project):\n\n- `foo/.pre-commit-config.yaml` (project `foo`)\n- `foo/bar/.pre-commit-config.yaml` (project `foo/bar`, nested subproject)\n\nIf project `foo` config contains an `exclude` that matches `bar/**`, then hooks for project `foo` will not run on files under `foo/bar`:\n\n=== \"prek.toml\"\n\n    ```toml\n    # foo/prek.toml\n    exclude = { glob = \"bar/**\" }\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    # foo/.pre-commit-config.yaml\n    exclude:\n      glob: \"bar/**\"\n    ```\n\nBut 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`**.\n\n!!! note \"Excluding a nested project\"\n\n    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).\n\n    Like `.gitignore`, `.prekignore` files can be placed anywhere in the workspace and apply to their directory and all subdirectories.\n\n!!! tip\n\n    After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up.\n\n### Validation\n\nUse [`prek validate-config`](cli.md#prek-validate-config) to validate one or more config files.\n\nIf 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).\n\nAnd the schema is also submitted to the [JSON Schema Store](https://www.schemastore.org/prek.json), so some editors may pick it up automatically.\n\nThat schema tracks what `prek` accepts today, but `prek` also intentionally tolerates unknown keys for forward compatibility.\n\n## Configuration reference\n\nThis section documents the configuration keys that `prek` understands.\n\n### Top-level keys\n\n#### `repos` (required)\n\nA list of hook repositories.\n\nEach entry is one of:\n\n- a remote repository (typically a git URL)\n- `repo: local` for hooks defined directly in your repository\n- `repo: meta` for built-in meta hooks\n- `repo: builtin` for `prek`'s built-in fast hooks\n\nSee [Repo entries](#repo-entries).\n\n<a id=\"top-level-files\"></a>\n\n#### `files`\n\nGlobal *include* regex applied before hook-level filtering.\n\n- Type: regex string (default, pre-commit compatible) **or** a prek-only glob pattern mapping\n- Default: no global include filter\n\nThis is usually used to narrow down the universe of files in large repositories.\n\n!!! note \"What path is matched? (workspace + nested projects)\"\n\n    `files` (and `exclude`) are matched against the file path **relative to the project root** — i.e. the directory containing the configuration file.\n\n    - For the root project, this is the workspace root.\n    - For a nested project, this is the nested project directory.\n\n    Example (workspace mode):\n\n    - Root project config: `./.pre-commit-config.yaml`\n    - Nested project config: `./nested/.pre-commit-config.yaml`\n\n    For a file at `nested/excluded_by_project`:\n\n    - Root project sees the path as `nested/excluded_by_project`\n    - Nested project sees the path as `excluded_by_project`\n\n    This matters most for anchored patterns like `^...$`.\n\n!!! tip \"Regex matching\"\n\n    When `files` / `exclude` are regex strings, they are matched with *search* semantics (the pattern can match anywhere in the path).\n    Use `^` to anchor at the beginning and `$` at the end.\n\n    `prek` uses the Rust [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) engine.\n    Most typical patterns are portable to upstream `pre-commit`, but very advanced regex features may differ from Python’s `re`.\n\n!!! note \"prek-only globs\"\n\n    In addition to regex strings, `prek` supports glob patterns via:\n\n    - `files: { glob: \"...\" }` (single glob)\n    - `files: { glob: [\"...\", \"...\"] }` (glob list)\n\n    This is a `prek` extension. Upstream `pre-commit` expects regex strings here.\n\n    For more information on the glob syntax, refer to the [globset documentation](https://docs.rs/globset/latest/globset/#syntax).\n\nExamples:\n\n=== \"prek.toml\"\n\n    ```toml\n    # Regex (portable to pre-commit)\n    files = \"\\\\.rs$\"\n\n    # Glob (prek-only)\n    files = { glob = \"src/**/*.rs\" }\n\n    # Glob list (prek-only; matches if any glob matches)\n    files = { glob = [\"src/**/*.rs\", \"crates/**/src/**/*.rs\"] }\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    # Regex (portable to pre-commit)\n    files: \"\\\\.rs$\"\n\n    # Glob (prek-only)\n    files:\n      glob: \"src/**/*.rs\"\n\n    # Glob list (prek-only; matches if any glob matches)\n    files:\n      glob:\n        - \"src/**/*.rs\"\n        - \"crates/**/src/**/*.rs\"\n    ```\n\n<a id=\"top-level-exclude\"></a>\n\n#### `exclude`\n\nGlobal *exclude* regex applied before hook-level filtering.\n\n- Type: regex string (default, pre-commit compatible) **or** a prek-only glob pattern mapping\n- Default: no global exclude filter\n\n`exclude` is useful for generated folders, vendored code, or build outputs.\n\n!!! note \"What path is matched?\"\n\n    Same as [`files`](#top-level-files): the pattern is evaluated against the file path **relative to the project root** (the directory containing the config).\n\n!!! note \"prek-only globs\"\n\n    Like `files`, `exclude` supports `glob` (single glob or glob list) as a `prek` extension.\n    For glob syntax details, see the [globset documentation](https://docs.rs/globset/latest/globset/#syntax).\n\nExamples:\n\n=== \"prek.toml\"\n\n    ```toml\n    # Regex (portable to pre-commit)\n    exclude = \"^target/\"\n\n    # Glob (prek-only)\n    exclude = { glob = \"target/**\" }\n\n    # Glob list (prek-only)\n    exclude = { glob = [\"target/**\", \"dist/**\"] }\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    # Regex (portable to pre-commit)\n    exclude: \"^target/\"\n\n    # Glob (prek-only)\n    exclude:\n      glob: \"target/**\"\n\n    # Glob list (prek-only)\n    exclude:\n      glob:\n        - \"target/**\"\n        - \"dist/**\"\n    ```\n\nVerbose regex example (useful for long allow/deny lists):\n\n=== \"prek.toml\"\n\n    ```toml\n    # `(?x)` enables \"verbose\" regex mode (whitespace and newlines are ignored).\n    exclude = \"\"\"(?x)^(\n      docs/|\n      vendor/|\n      target/\n    )\"\"\"\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    # `(?x)` enables \"verbose\" regex mode (whitespace and newlines are ignored).\n    exclude: |\n      (?x)^(\n        docs/|\n        vendor/|\n        target/\n      )\n    ```\n\n#### `fail_fast`\n\nStop the run after the first failing hook.\n\n- Type: boolean\n- Default: `false`\n\nThis is a global default; individual hooks can also set `fail_fast`.\n\n#### `default_language_version`\n\nMap a language name to the default `language_version` used by hooks of that language.\n\n- Type: map\n- Default: none (hooks fall back to `language_version: default`)\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    default_language_version.python = \"3.12\"\n    default_language_version.node = \"20\"\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    default_language_version:\n      python: \"3.12\"\n      node: \"20\"\n    ```\n\n`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).\n\n#### `default_stages`\n\nDefault `stages` used when a hook does not specify its own.\n\n- Type: list of stage names\n- Default: all stages\n\nAllowed values:\n\n- `manual`\n- `commit-msg`\n- `post-checkout`\n- `post-commit`\n- `post-merge`\n- `post-rewrite`\n- `pre-commit`\n- `pre-merge-commit`\n- `pre-push`\n- `pre-rebase`\n- `prepare-commit-msg`\n\n#### `default_install_hook_types`\n\nDefault Git shim name(s) installed by `prek install` when you don’t pass `--hook-type`.\n\n- Type: list of `--hook-type` values\n- Default: `[pre-commit]`\n\nThis controls which Git shims are installed (for example `pre-commit` vs `pre-push`).\nIt is separate from a hook’s `stages`, which controls when a particular hook is eligible to run.\n\nAllowed values:\n\n- `pre-commit`\n- `pre-push`\n- `commit-msg`\n- `prepare-commit-msg`\n- `post-checkout`\n- `post-commit`\n- `post-merge`\n- `post-rewrite`\n- `pre-merge-commit`\n- `pre-rebase`\n\n#### `minimum_prek_version`\n\n<a id=\"prek-only-minimum-prek-version-config\"></a>\n\n!!! note \"prek-only\"\n\n    This key is a `prek` extension. Upstream `pre-commit` uses `minimum_pre_commit_version`, which `prek` intentionally ignores.\n\nRequire a minimum `prek` version for this config.\n\n- Type: string (version)\n- Default: unset\n\nIf the installed `prek` is older than the configured minimum, `prek` exits with an error.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    minimum_prek_version = \"0.2.0\"\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    minimum_prek_version: \"0.2.0\"\n    ```\n\n#### `orphan`\n\n<a id=\"prek-only-orphan\"></a>\n\n!!! note \"prek-only\"\n\n    `orphan` is a `prek` workspace-mode feature and is not recognized by upstream `pre-commit`.\n\nWorkspace-mode setting to isolate a nested project from parent configs.\n\n- Type: boolean\n- Default: `false`\n\nWhen `orphan: true`, files under this project directory are handled only by this project’s config and are not “seen” by parent projects.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    orphan = true\n\n    [[repos]]\n    repo = \"https://github.com/astral-sh/ruff-pre-commit\"\n    rev = \"v0.8.4\"\n    hooks = [{ id = \"ruff\" }]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    orphan: true\n    repos:\n      - repo: https://github.com/astral-sh/ruff-pre-commit\n        rev: v0.8.4\n        hooks:\n          - id: ruff\n    ```\n\nSee [Workspace Mode - File Processing Behavior](workspace.md#file-processing-behavior) for details.\n\n### Repo entries\n\nEach item under `repos:` is a mapping that always contains a `repo:` key.\n\n#### Remote repository\n\nUse this for hooks distributed in a separate repository.\n\nRequired keys:\n\n- `repo`: repository location (commonly an https git URL)\n- `rev`: version to use (tag, branch, or commit SHA)\n- `hooks`: list of hook selections\n\nRemote hook definitions live inside the hook repository itself in the\n`.pre-commit-hooks.yaml` manifest (at the repo root). Your config only selects\nhooks by `id` and optionally overrides options. See [Authoring Hooks](authoring-hooks.md)\nif you maintain a hook repository.\n\n##### `repo`\n\nWhere to fetch hooks from.\n\nIn most configs this is a git URL.\n`prek` also recognizes special values documented separately: `local`, `meta`, and `builtin`.\n\n##### `rev`\n\nThe revision to use for the remote repository.\n\nUse a tag or commit SHA for repeatable results.\nIf you use a moving target (like a branch name), runs may change over time.\n\n##### `hooks`\n\nThe list of hooks to enable from that repository.\n\nEach item must at least specify `id`.\nYou can also add hook-level options (filters, args, stages, etc.) to customize behavior.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"https://github.com/astral-sh/ruff-pre-commit\"\n    rev = \"v0.8.4\"\n    hooks = [{ id = \"ruff\", args = [\"--fix\"] }]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: https://github.com/astral-sh/ruff-pre-commit\n        rev: v0.8.4\n        hooks:\n          - id: ruff\n            args: [--fix]\n    ```\n\nNotes:\n\n- For reproducibility, prefer immutable pins (tags or commit SHAs).\n- `prek auto-update` can help update `rev` values.\n\n#### `repo: local`\n\nDefine hooks inline inside your repository.\n\nKeys:\n\n- `repo`: must be `local`\n- `hooks`: list of **local hook definitions** (see [Local hook definition](#local-hook-definition))\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"local\"\n    hooks = [\n      {\n        id = \"cargo-fmt\",\n        name = \"cargo fmt\",\n        language = \"system\",\n        entry = \"cargo fmt\",\n        files = \"\\\\.rs$\",\n      },\n    ]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: local\n        hooks:\n          - id: cargo-fmt\n            name: cargo fmt\n            language: system\n            entry: cargo fmt\n            files: \"\\\\.rs$\"\n    ```\n\n#### `repo: meta`\n\nUse `pre-commit`-style meta hooks that validate and debug your configuration.\n\n`prek` supports the following meta hook ids:\n\n- `check-hooks-apply`\n- `check-useless-excludes`\n- `identity`\n\nRestrictions:\n\n- `id` is required.\n- `entry` is not allowed.\n- `language` (if set) must be `system`.\n\nYou may still configure normal hook options such as `files`, `exclude`, `stages`, etc.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"meta\"\n    hooks = [{ id = \"check-useless-excludes\" }]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: meta\n        hooks:\n          - id: check-useless-excludes\n    ```\n\n#### `repo: builtin`\n\n<a id=\"prek-only-repo-builtin\"></a>\n\n!!! note \"prek-only\"\n\n    `repo: builtin` is specific to `prek` and is not compatible with upstream `pre-commit`.\n\nUse `prek`’s built-in fast hooks (offline, zero setup).\n\nRestrictions:\n\n- `id` is required.\n- `entry` is not allowed.\n- `language` (if set) must be `system`.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"builtin\"\n    hooks = [\n      { id = \"trailing-whitespace\" },\n      { id = \"check-yaml\" },\n    ]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: builtin\n        hooks:\n          - id: trailing-whitespace\n          - id: check-yaml\n    ```\n\nFor the list of available built-in hooks and the “automatic fast path” behavior, see [Built-in Fast Hooks](builtin.md).\n\n### Hook entries\n\nHook items under `repos[*].hooks` have slightly different shapes depending on the repo type.\n\n#### Remote hook selection\n\nFor a remote repo, the hook entry must include:\n\n- `id` (required): selects the hook from the repository\n\nAll other hook keys are optional overrides (for example `args`, `files`, `exclude`, `stages`, …).\n\n!!! note \"Advanced overrides\"\n\n    `prek` also supports overriding `name`, `entry`, and `language` for remote hooks.\n    This can be useful for experimentation, but it may reduce portability to the original `pre-commit`.\n\n#### Local hook definition\n\nFor `repo: local`, the hook entry is a full definition and must include:\n\n- `id` (required): stable identifier used by `prek run <id>` and selectors\n- `name` (required): label shown in output\n- `entry` (required): command to execute\n- `language` (required): how `prek` sets up and runs the hook\n\n#### Builtin/meta hook selection\n\nFor `repo: builtin` and `repo: meta`, the hook entry must include `id`.\nYou can optionally provide `name` and normal hook options (filters, stages, etc), but not `entry`.\n\n### Common hook options\n\nThese keys can appear on hooks (remote/local/builtin/meta), subject to the restrictions above.\n\n#### `id`\n\nThe stable identifier of the hook.\n\n- For remote hooks, this must match a hook id defined by the remote repository.\n- For local hooks, you choose it.\n\n`id` is also used for CLI selection (for example `prek run <id>` and `PREK_SKIP`).\n\n!!! note \"Hook ids containing `:`\"\n\n    If your hook id contains `:` (for example `id: lint:ruff`), `prek run lint:ruff`\n    will not select that hook. `prek` interprets `lint:ruff` as the selector\n    `<project-path>:<hook-id>`, with project `lint` and hook `ruff`.\n    To select the hook id `lint:ruff`, add a leading `:` and run\n    `prek run :lint:ruff`.\n\n#### `name`\n\nHuman-friendly label shown in output.\n\n- Required for `repo: local` hooks.\n- Optional as an override for remote/meta/builtin hooks.\n\n#### `entry`\n\nThe command line to execute for the hook.\n\n- Required for `repo: local` hooks.\n- Optional override for remote hooks.\n- Not allowed for `repo: meta` and `repo: builtin`.\n\nIf `pass_filenames: true`, `prek` appends matching filenames to this command when running.\n\n#### `language`\n\nHow `prek` should run the hook (and whether it should create a managed environment).\n\n- Required for `repo: local` hooks.\n- Optional override for remote hooks.\n- Not allowed (except as `system`) for `repo: meta` and `repo: builtin`.\n\nCommon values include `system`, `python`, `node`, `rust`, `golang`, `ruby`, and `docker`.\n\nSee [Language Support](languages.md) for per-language behavior, supported values, and `language_version` details.\n\n!!! note \"Language name aliases\"\n\n    For compatibility with upstream `pre-commit`, the following legacy language names are also accepted:\n\n    - `unsupported` is treated as `system`\n    - `unsupported_script` is treated as `script`\n\n#### `alias`\n\nAn alternate identifier for selecting the hook from the CLI.\n\nIf set, you can run the hook via either `prek run <id>` or `prek run <alias>`.\n\n#### `args`\n\nExtra arguments appended to the hook’s `entry`.\n\n- Type: list of strings\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    hooks = [{ id = \"ruff\", args = [\"--fix\"] }]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    hooks:\n      - id: ruff\n        args: [--fix]\n    ```\n\n#### `env`\n\n<a id=\"prek-only-env\"></a>\n\n!!! note \"prek-only\"\n\n    `env` is a `prek` extension and may not be recognized by upstream `pre-commit`.\n\nExtra environment variables for the hook process.\n\n- Type: map of string to string\n\nValues override the existing process environment (including variables such as `PATH`).\n\nFor `docker` / `docker_image` hooks, these variables are passed into the container rather than being applied to the container runtime command.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"local\"\n    hooks = [\n      {\n        id = \"cargo-doc\",\n        name = \"cargo doc\",\n        language = \"system\",\n        entry = \"cargo doc --all-features --workspace --no-deps\",\n        env = { RUSTDOCFLAGS = \"-Dwarnings\" },\n        pass_filenames = false,\n      },\n    ]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: local\n        hooks:\n          - id: cargo-doc\n            name: cargo doc\n            language: system\n            entry: cargo doc --all-features --workspace --no-deps\n            env:\n              RUSTDOCFLAGS: -Dwarnings\n            pass_filenames: false\n    ```\n\n#### `files` / `exclude`\n\nFilters applied to candidate filenames.\n\n- `files` selects which files are eligible for the hook.\n- `exclude` removes files matched by `files`.\n\nIf you use both global and hook-level filters, the effective behavior is “global filter first, then hook filter”.\n\nBy default (and for compatibility with upstream `pre-commit`), these are regex strings.\nAs a `prek` extension, you can also specify globs using `glob` or a glob list.\n\nSee [Top-level `files`](#top-level-files) and [Top-level `exclude`](#top-level-exclude) for syntax notes and examples.\n\n#### `types` / `types_or` / `exclude_types`\n\nFile-type filters based on [`identify`](https://pre-commit.com/#filtering-files-with-types) tags.\n\n!!! tip\n\n    Use [`prek util identify <path>`](cli.md#prek-util-identify) to see how prek tags a file when you’re troubleshooting `types` filters.\n\nCompared to regex-only filtering (`files` / `exclude`), tag-based filtering is often easier and more robust:\n\n- tags can match by **file extension** *and* by **shebang** (for extensionless scripts)\n- you can easily exclude things like **symlinks** or **binary files**\n\nCommon tags include:\n\n- `file`, `text`, `binary`, `symlink`, `executable`\n\n- language-ish tags such as `python`, `rust`, `javascript`, `yaml`, `toml`, ...\n\n- `types`: all listed tags must match (logical AND)\n\n- `types_or`: at least one listed tag must match (logical OR)\n\n- `exclude_types`: tags that disqualify a file\n\nHow these combine:\n\n- `files` / `exclude`, `types`, and `types_or` are combined with **AND**.\n- Tags within `types` are combined with **AND**.\n- Tags within `types_or` are combined with **OR**.\n\nDefaults:\n\n- `types`: `[file]` (matches all files)\n- `types_or`: `[]`\n- `exclude_types`: `[]`\n\nThese filters are applied in addition to regex filtering.\n\nExamples:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"local\"\n    hooks = [\n      # AND: must be under `src/` AND have the `python` tag\n      {\n        id = \"lint-py\",\n        name = \"Lint (py)\",\n        language = \"system\",\n        entry = \"python -m ruff check\",\n        files = \"^src/\",\n        types = [\"python\"],\n        exclude_types = [\"symlink\"]\n      },\n\n      # OR: match any of the listed tags under `web/`\n      {\n        id = \"lint-web\",\n        name = \"Lint (web)\",\n        language = \"system\",\n        entry = \"npm run lint\",\n        files = \"^web/\",\n        types_or = [\"javascript\", \"jsx\", \"ts\", \"tsx\"]\n      },\n    ]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: local\n        hooks:\n          - id: lint-py\n            name: Lint (py)\n            language: system\n            entry: python -m ruff check\n            files: ^src/\n            types: [python]\n            exclude_types: [symlink]\n\n          - id: lint-web\n            name: Lint (web)\n            language: system\n            entry: npm run lint\n            files: ^web/\n            types_or: [javascript, jsx, ts, tsx]\n    ```\n\nIf 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`:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"meta\"\n    hooks = [\n      {\n        id = \"check-hooks-apply\",\n        types = [\"file\"],\n        files = \"\\\\.(yaml|yml|myext)$\"\n      },\n    ]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: meta\n        hooks:\n          - id: check-hooks-apply\n            types: [file]\n            files: \\.(yaml|yml|myext)$\n    ```\n\n#### `always_run`\n\nRun the hook even when no files match.\n\n- Type: boolean\n- Default: `false`\n\nThis is commonly used for hooks that check repository-wide state (for example, running a test suite) rather than operating on specific files.\n\n#### `pass_filenames`\n\nControls whether `prek` appends the matching filenames to the command line.\n\n- Type: boolean or positive integer\n- Default: `true` which passes all matching filenames\n\nSet `pass_filenames: false` for hooks that don’t accept file arguments (or that discover files themselves).\n\nSet `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.\n\nPrek will automatically limit the number of filenames to ensure command lines don’t exceed the OS limit, even when `pass_filenames: true`.\n\n#### `stages`\n\nDeclare which stages a hook is eligible to run in.\n\n- Type: list of stage names\n- Default: all stages\n\nAllowed values:\n\n- `manual`\n- `commit-msg`\n- `post-checkout`\n- `post-commit`\n- `post-merge`\n- `post-rewrite`\n- `pre-commit`\n- `pre-merge-commit`\n- `pre-push`\n- `pre-rebase`\n- `prepare-commit-msg`\n\nWhen you run `prek run --hook-stage <stage>`, only hooks configured for that stage are considered.\n\n#### `require_serial`\n\nForce a hook to run without parallel invocations (one in-flight process for that hook at a time).\n\n- Type: boolean\n- Default: `false`\n\nThis is useful for tools that use global caches/locks or otherwise can’t handle concurrent execution.\n\n#### `priority`\n\n<a id=\"prek-only-priority\"></a>\n\n!!! note \"prek-only\"\n\n    `priority` controls `prek`'s scheduler and does not exist in upstream `pre-commit`.\n\nEach hook can set an explicit `priority` (a non-negative integer) that controls when it runs and with which hooks it may execute in parallel.\n\nScope:\n\n- `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.\n- `priority` does **not** coordinate across different config files. In workspace mode, each project’s config file is scheduled independently.\n\nHooks 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.\n\nWhen `priority` is omitted, `prek` assigns an implicit value based on hook order to preserve sequential behavior.\n\nExample:\n\n=== \"prek.toml\"\n\n    ```toml\n    [[repos]]\n    repo = \"local\"\n    hooks = [\n      {\n        id = \"format\",\n        name = \"Format\",\n        language = \"system\",\n        entry = \"python3 -m ruff format\",\n        always_run = true,\n        priority = 0,\n      },\n      {\n        id = \"lint\",\n        name = \"Lint\",\n        language = \"system\",\n        entry = \"python3 -m ruff check\",\n        always_run = true,\n        priority = 10,\n      },\n      {\n        id = \"tests\",\n        name = \"Tests\",\n        language = \"system\",\n        entry = \"just test\",\n        always_run = true,\n        priority = 20,\n      },\n    ]\n    ```\n\n=== \".pre-commit-config.yaml\"\n\n    ```yaml\n    repos:\n      - repo: local\n        hooks:\n          - id: format\n            name: Format\n            language: system\n            entry: python3 -m ruff format\n            always_run: true\n            priority: 0\n\n          - id: lint\n            name: Lint\n            language: system\n            entry: python3 -m ruff check\n            always_run: true\n            priority: 10\n\n          - id: tests\n            name: Tests\n            language: system\n            entry: just test\n            always_run: true\n            priority: 20\n    ```\n\n!!! danger \"Parallel hooks modifying files\"\n\n    If two hooks run in the same priority group and both mutate the same files (or depend on shared state), results are undefined.\n    Use separate priorities to avoid overlap.\n\n!!! note \"`require_serial` is different\"\n\n    `require_serial: true` prevents concurrent invocations of the *same hook*.\n    It does not prevent other hooks from running alongside it; use a unique `priority` if you need exclusivity.\n\n#### `fail_fast`\n\nHook-level fail-fast behavior.\n\n- Type: boolean\n- Default: `false`\n\nIf `true`, a failure in this hook stops the run immediately.\n\n#### `verbose`\n\nPrint hook output even when the hook succeeds.\n\n- Type: boolean\n- Default: `false`\n\n#### `log_file`\n\nWrite hook output to a file when the hook fails (and also when `verbose: true`).\n\n- Type: string path\n\n#### `description`\n\nFree-form description shown in listings / metadata.\n\n- Type: string\n\n#### `language_version`\n\nChoose the language/toolchain version request for this hook.\n\n- Type: string\n- Default: `default`\n\nIf not set, `prek` may use `default_language_version` for the hook’s language.\n\n!!! note \"prek-only\"\n\n    `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`).\n\n    Special values:\n\n    - `default`: use the language’s default resolution logic.\n    - `system`: require a system-installed toolchain (no downloads).\n\n    Language-specific behavior:\n\n    - Python: passed to the Python resolver (for example `python3`, `python3.12`, or a specific interpreter name). May trigger toolchain download.\n    - Node: passed to the Node resolver (for example `20`, `18.19.0`). May trigger toolchain download.\n    - Go: uses Go version strings such as `1.22.1` (downloaded if missing).\n    - Rust: supports rustup toolchains such as `stable`, `beta`, `nightly`, or versioned toolchains.\n    - Other languages: parsed as a semver request and matched against the installed toolchain version.\n\n    Examples:\n\n    === \"prek.toml\"\n\n        ```toml\n        hooks = [\n          { id = \"ruff\", language = \"python\", language_version = \"3.12\" },\n          { id = \"eslint\", language = \"node\", language_version = \"20\" },\n          { id = \"cargo-fmt\", language = \"rust\", language_version = \"stable\" },\n          { id = \"my-tool\", language = \"system\", language_version = \"system\" },\n        ]\n        ```\n\n    === \".pre-commit-config.yaml\"\n\n        ```yaml\n        hooks:\n          - id: ruff\n            language: python\n            language_version: \"3.12\"\n\n          - id: eslint\n            language: node\n            language_version: \"20\"\n\n          - id: cargo-fmt\n            language: rust\n            language_version: stable\n\n          - id: my-tool\n            language: system\n            language_version: system\n        ```\n\n#### `additional_dependencies`\n\nExtra dependencies for hooks that run inside a managed environment (for example Python or Node hooks).\n\n- Type: list of strings\n\nIf you set this for a language that doesn’t support dependency installation, `prek` fails with a configuration error.\n\n#### `minimum_prek_version`\n\n<a id=\"prek-only-minimum-prek-version-hook\"></a>\n\n!!! note \"prek-only\"\n\n    This is a `prek`-specific requirement gate. Upstream `pre-commit` does not have a hook-level minimum version key.\n\nRequire a minimum `prek` version for this specific hook.\n\n- Type: string (version)\n- Default: unset\n\n## Environment variables\n\nprek supports the following environment variables:\n\n- `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.\n\n- `PREK_COLOR` — Control colored output: auto (default), always, or never.\n\n- `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).\n\n- `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.\n\n- `PREK_ALLOW_NO_CONFIG` — Allow running without a configuration file (useful for ad‑hoc runs).\n\n- `PREK_NO_CONCURRENCY` — Disable parallelism for installs and runs (If set, force concurrency to 1).\n\n- `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.\n\n- `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.\n\n- `PREK_UV_SOURCE` — Control how uv (Python package installer) is installed. Options:\n\n    - `github` (download from GitHub releases)\n    - `pypi` (install from PyPI)\n    - `tuna` (use Tsinghua University mirror)\n    - `aliyun` (use Alibaba Cloud mirror)\n    - `tencent` (use Tencent Cloud mirror)\n    - `pip` (install via pip)\n    - a custom PyPI mirror URL\n\n    If not set, prek automatically selects the best available source.\n\n- `PREK_NATIVE_TLS` — Use the system trusted store instead of the bundled `webpki-roots` crate.\n\n- `PREK_CONTAINER_RUNTIME` — Specify the container runtime to use for container-based hooks (e.g., `docker`, `docker_image`). Options:\n\n    - `auto` (default, auto-detect available runtime)\n\n    - `docker`\n\n    - `podman`\n\n    - `container` (Apple's Container runtime on macOS, see [container](https://github.com/apple/container))\n\n- `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.\n\n- `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.\n\nCompatibility fallbacks:\n\n- `PRE_COMMIT_ALLOW_NO_CONFIG` — Fallback for `PREK_ALLOW_NO_CONFIG`.\n- `PRE_COMMIT_NO_CONCURRENCY` — Fallback for `PREK_NO_CONCURRENCY`.\n- `SKIP` — Fallback for `PREK_SKIP`.\n"
  },
  {
    "path": "docs/debugging.md",
    "content": "# Debugging\n\nTo enable verbose tracing output, use the `-vvv` flag when running prek:\n\n```bash\nprek run -vvv\n```\n\nAdditionally, 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.\n"
  },
  {
    "path": "docs/diff.md",
    "content": "# Differences from pre-commit\n\n## General differences\n\n- `prek` supports both `.pre-commit-config.yaml` and `.pre-commit-config.yml` configuration files.\n- `prek` implements some common hooks from `pre-commit-hooks` in Rust for better performance.\n- `prek` supports `repo: builtin` for offline, zero-setup hooks.\n- `prek` uses `~/.cache/prek` as the default cache directory for repos, environments and toolchains.\n- `prek` decoupled hook environment from their repositories, allowing shared toolchains and environments across hooks.\n- `prek` supports `language_version` as a semver specifier and automatically installs the required toolchains.\n- `prek` supports `files` and `exclude` as glob lists (in addition to regex) via `glob` mappings. See [Configuration](configuration.md#top-level-files).\n\n## Workspace mode\n\n`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.\n\nSee [Workspace Mode](./workspace.md) for more information.\n\n## Language support\n\nSee the dedicated [Language Support](languages.md) page for a complete list of supported languages, prek-specific behavior, and unsupported languages.\n\n## Command line interface\n\nFor a compatibility-focused command mapping, see [Compatibility with pre-commit](compatibility.md).\n\n### `prek run`\n\n- `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.\n- `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.\n- `prek` provides dynamic completions of hook id.\n- `prek run --last-commit` to run hooks on files changed by the last commit.\n- `prek run --directory <DIR>` to run hooks on a specified directory.\n\n### `prek list`\n\n`prek list` command lists all available hooks, their ids, and descriptions. This provides a better overview of the configured hooks.\n\n### `prek auto-update`\n\n- `prek auto-update` updates all projects in the workspace to their latest revisions.\n- `prek auto-update` checks updates for the same repository only once, speeding up the process in workspace mode.\n- `prek auto-update` supports `--dry-run` option to preview the updates without applying them.\n- `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).\n\n### `prek sample-config`\n\n- `prek sample-config` command has a `--file` option to write the sample configuration to a specific file.\n\n### `prek cache`\n\n- `prek cache clean` to remove all cached data.\n- `prek cache gc` to remove unused cached repositories, environments and toolchains.\n- `prek cache dir` to show the cache directory.\n- `prek cache size` to show the total size of the cache.\n\n## Not implemented\n\nThe `pre-commit hazmat` subcommand introduced in pre-commit\n[v4.5.0](https://github.com/pre-commit/pre-commit/releases/tag/v4.5.0) is not\nimplemented. This command is niche and unlikely to be widely used.\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# FAQ\n\n## How is `prek` pronounced?\n\nLike \"wreck\", but with a \"p\" sound instead of the \"w\" at the beginning.\n\n## I updated `.prekignore`, why didn't discovery change?\n\nWorkspace 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:\n\n```bash\nprek run --refresh\n```\n\n## What does `prek install --prepare-hooks` do?\n\nIn 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.\n\nIt's a little confusing because it refers to two different kinds of hooks:\n\n1. **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`.\n2. **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).\n\nRunning `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.\n\nAdding `--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.\n\n## How do I use hooks from private repositories?\n\nprek supports cloning hooks from private repositories that require authentication.\nprek first clones with interactive terminal prompts disabled so non-interactive runs do\nnot hang. If a clone fails with an authentication error and prek is not running in CI,\nit retries with terminal prompts enabled so Git can ask for credentials. In CI,\ninteractive prompts remain disabled, so you still need to configure credentials via\ncredential helpers, environment variables, or SSH.\n\n### Option 1: Credential helpers (recommended)\n\nIf you use GitHub CLI, Git Credential Manager, macOS Keychain, or similar tools,\nauthentication often works automatically with no extra configuration:\n\n```shell\n# GitHub CLI users: configure git to use gh for credentials\ngh auth setup-git\n\n# Now HTTPS URLs work automatically\nprek install\n```\n\nOther credential helpers that work out of the box:\n\n- **macOS**: Keychain (`credential.helper=osxkeychain`)\n- **Windows**: Git Credential Manager (`credential.helper=manager`)\n- **Linux**: GNOME Keyring, KWallet, or `credential.helper=store`\n\nYou can also use `GIT_ASKPASS` to point to a custom credential program:\n\n```shell\nexport GIT_ASKPASS=/path/to/credential-script\n```\n\n### Option 2: SSH URLs\n\nUse SSH URLs in your `.pre-commit-config.yaml` instead of HTTPS:\n\n```yaml\nrepos:\n  - repo: git@github.com:myorg/private-hooks.git\n    rev: v1.0.0\n    hooks:\n      - id: my-hook\n```\n\nThis works automatically if you have SSH keys configured with an agent.\n\n### Option 3: URL rewriting with tokens (for CI)\n\nIn CI environments without credential helpers, use environment variables to\nrewrite HTTPS URLs to include credentials:\n\n```shell\n# GitHub Actions example\nexport GIT_CONFIG_COUNT=1\nexport GIT_CONFIG_KEY_0=\"url.https://oauth2:${GITHUB_TOKEN}@github.com/.insteadOf\"\nexport GIT_CONFIG_VALUE_0=\"https://github.com/\"\n\n# Or using GIT_CONFIG_PARAMETERS (more compact)\nexport GIT_CONFIG_PARAMETERS=\"'url.https://oauth2:${GITHUB_TOKEN}@github.com/.insteadOf=https://github.com/'\"\n```\n\n> **Security note:** Be careful with tokens in environment variables. Ensure your\n> CI system masks secrets in logs.\n"
  },
  {
    "path": "docs/index.md",
    "content": "# prek\n\n<div align=\"center\">\n  <img width=\"220\" alt=\"prek\" src=\"/assets/logo.webp\" />\n</div>\n\n--8<-- \"README.md:description\"\n\n!!! note\n\n    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!\n\n    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.\n\n<!-- \"--8<--\" is used for includes, see https://facelessuser.github.io/pymdown-extensions/extensions/snippets/#snippets-notation -->\n\n--8<-- \"README.md:features\"\n\n--8<-- \"README.md:why\"\n\n## Badges\n\nShow that your project uses prek with a badge in your README:\n\n[![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)\n\n=== \"Markdown\"\n\n    ```markdown\n    [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)\n    ```\n\n=== \"HTML\"\n\n    ```html\n    <a href=\"https://github.com/j178/prek\">\n      <img src=\"https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json\" alt=\"prek\">\n    </a>\n    ```\n\n=== \"reStructuredText (RST)\"\n\n    ```rst\n    .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json\n       :target: https://github.com/j178/prek\n       :alt: prek\n    ```\n"
  },
  {
    "path": "docs/installation.md",
    "content": "# Installation\n\nprek provides multiple installation methods to suit different needs and environments.\n\n## Standalone Installer\n\nThe standalone installer automatically downloads and installs the correct binary for your platform:\n\n=== \"macOS and Linux\"\n\n    Use `curl` to download the script and execute it with `sh`:\n\n    --8<-- \"README.md:linux-standalone-install\"\n\n=== \"Windows\"\n\n    Use `irm` to download the script and execute it with `iex`:\n\n    --8<-- \"README.md:windows-standalone-install\"\n\n    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.\n\n!!! tip\n\n    The installation script may be inspected before use. Alternatively, binaries can be downloaded directly from [GitHub Releases](#github-releases).\n\n## Package Managers\n\n### PyPI\n\n--8<-- \"README.md:pypi-install\"\n\n### Homebrew (macOS/Linux)\n\n--8<-- \"README.md:homebrew-install\"\n\n### mise\n\n--8<-- \"README.md:mise-install\"\n\n### npm\n\nprek is published as a [Node.js package](https://www.npmjs.com/package/@j178/prek)\nand can be installed with any npm-compatible package manager:\n\n```bash\n# npm\nnpm install -g @j178/prek\n\n# pnpm\npnpm add -g @j178/prek\n\n# bun\nbun install -g @j178/prek\n```\n\nOr as a project dependency:\n\n```bash\nnpm add -D @j178/prek\n```\n\n### Nix\n\n--8<-- \"README.md:nix-install\"\n\n### Conda\n\n--8<-- \"README.md:conda-forge-install\"\n\n### Scoop (Windows)\n\n--8<-- \"README.md:scoop-install\"\n\n### Winget (Windows)\n\n--8<-- \"README.md:winget-install\"\n\n### MacPorts\n\n--8<-- \"README.md:macports-install\"\n\n### cargo-binstall\n\n--8<-- \"README.md:cargo-binstall\"\n\n## Docker\n\nprek provides a Docker image at\n[`ghcr.io/j178/prek`](https://github.com/j178/prek/pkgs/container/prek).\n\nSee the guide on [using prek in Docker](integrations.md#docker) for more details.\n\n## GitHub Releases\n\n--8<-- \"README.md:pre-built-binaries\"\n\n## Build from Source\n\n--8<-- \"README.md:cargo-install\"\n\n## Updating\n\n--8<-- \"README.md:self-update\"\n\nFor other installation methods, follow the same installation steps again.\n\n## Shell Completion\n\n!!! tip\n\n    Run `echo $SHELL` to determine your shell.\n\nTo enable shell autocompletion for prek commands, run one of the following:\n\n=== \"Bash\"\n\n    ```bash\n    echo 'eval \"$(COMPLETE=bash prek)\"' >> ~/.bashrc\n    ```\n\n=== \"Zsh\"\n\n    ```bash\n    echo 'eval \"$(COMPLETE=zsh prek)\"' >> ~/.zshrc\n    ```\n\n=== \"Fish\"\n\n    ```bash\n    echo 'COMPLETE=fish prek | source' >> ~/.config/fish/config.fish\n    ```\n\n=== \"PowerShell\"\n\n    ```powershell\n    Add-Content -Path $PROFILE -Value '$env:COMPLETE = \"powershell\"; prek | Out-String | Invoke-Expression; Remove-Item Env:\\COMPLETE'\n    ```\n\nThen restart your shell or source the config file.\n\n## Artifact Verification\n\nRelease artifacts are signed with\n[GitHub Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations)\nto provide cryptographic proof of their origin. Verify downloads using the\n[GitHub CLI](https://cli.github.com/):\n\n```console\n$ gh attestation verify prek-x86_64-unknown-linux-gnu.tar.gz --repo j178/prek\nLoaded digest sha256:xxxx... for file://prek-x86_64-unknown-linux-gnu.tar.gz\nLoaded 1 attestation from GitHub API\n✓ Verification succeeded!\n\n- Attestation #1\n  - Build repo:..... j178/prek\n  - Build workflow:. .github/workflows/release.yml@refs/tags/vX.Y.Z\n```\n\nThis confirms the artifact was built by the official release workflow.\n"
  },
  {
    "path": "docs/integrations.md",
    "content": "# Integrations\n\nThis page documents common ways to integrate prek into CI and container workflows.\n\n## Docker\n\nprek is published as a distroless container image at:\n\n- `ghcr.io/j178/prek`\n\nThe image is based on `scratch` (no shell, no package manager). It contains the prek binary at `/prek`.\n\nA common pattern is to copy the binary into your own image:\n\n```dockerfile\nFROM debian:bookworm-slim\nCOPY --from=ghcr.io/j178/prek:v0.3.6 /prek /usr/local/bin/prek\n```\n\nIf you prefer, you can also run the distroless image directly:\n\n```bash\ndocker run --rm ghcr.io/j178/prek:v0.3.6 --version\n```\n\n### Verifying Images\n\nDocker images are signed with\n[GitHub Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations)\nto verify they were built by official prek workflows. Verify using the\n[GitHub CLI](https://cli.github.com/):\n\n```console\n$ gh attestation verify --owner j178 oci://ghcr.io/j178/prek:latest\nLoaded digest sha256:xxxx... for oci://ghcr.io/j178/prek:latest\nLoaded 1 attestation from GitHub API\n✓ Verification succeeded!\n\n- Attestation #1\n  - Build repo:..... j178/prek\n  - Build workflow:. .github/workflows/build-docker.yml@refs/tags/vX.Y.Z\n```\n\n!!! tip\n\n    Use a specific version tag (e.g., `ghcr.io/j178/prek:v0.3.6`) or image\n    digest rather than `latest` for verification.\n\n## GitHub Actions\n\n--8<-- \"README.md:github-actions\"\n"
  },
  {
    "path": "docs/languages.md",
    "content": "# Language support\n\n## What “language” means in prek\n\nEach hook has a `language` that tells prek how to install and run it. The language determines:\n\n- Whether prek creates a managed environment for the hook\n- How dependencies are installed (`additional_dependencies`)\n- How toolchain versions are selected (`language_version`)\n- How `entry` is executed\n\nFor `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.\n\n## Toolchain management and `language_version`\n\nprek resolves toolchains in two steps:\n\n1. **Discover system toolchains** (PATH and common version manager locations).\n2. **Download a toolchain** when the language supports it and the request cannot be satisfied locally.\n\nIf `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).\n\n!!! note \"prek-only\"\n\n    `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.\n\nLanguages with managed toolchain downloads in prek today:\n\n- [Python](#python)\n- [Node](#node)\n- [Bun](#bun)\n- [Deno](#deno)\n- [Golang](#golang)\n- [Rust](#rust)\n- [Ruby](#ruby)\n\nOther supported languages rely on system installations and will fail if a matching toolchain is not available.\n\n## Language details\n\nBelow is how prek handles each language (with notes when it differs from pre-commit).\n\n### bun\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\nBun hooks run without needing a pre-installed Bun runtime when toolchain download is available.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- `bun`, `bun@latest`\n- `bun@1`, `1`\n- `bun@1.1`, `1.1`\n- `bun@1.1.0`, `1.1.0`\n- Semver ranges like `>=1.0, <2.0`\n- Absolute path to a Bun executable\n\n!!! note \"prek-only\"\n\n    Bun language support is a prek extension. pre-commit does not have native `bun` support.\n\n### conda\n\n**Status in prek:** Not supported yet.\n\nTracking: [#52](https://github.com/j178/prek/issues/52)\n\n### coursier\n\n**Status in prek:** Not supported yet.\n\nTracking: [#53](https://github.com/j178/prek/issues/53)\n\n### dart\n\n**Status in prek:** Not supported yet.\n\nTracking: [#51](https://github.com/j178/prek/issues/51)\n\n### docker\n\n**Status in prek:** ✅ Supported.\n\nprek 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).\n\nRuntime behavior:\n\n- Requires a working container engine on the host (Docker, Podman, or Container).\n- The repository is bind-mounted into the container at `/src` and the working directory is set to `/src`.\n- The container is run with `--entrypoint` set to the hook `entry`, so the image’s default command is not used when filenames are passed.\n- Environment variables configured via `env` are passed using `-e`.\n- On Linux, prek tries to run as a non-root user and handles rootless Podman with `--userns=keep-id`.\n\nUse `docker` when you need a language runtime that isn’t otherwise supported; the container provides the execution environment.\n\n!!! note \"prek-only\"\n\n    prek auto-detects the container runtime (Docker, Podman, or [Container](https://github.com/apple/container)) and can be overridden with `PREK_CONTAINER_RUNTIME`.\n    See [Configuration](configuration.md#environment-variables) for details.\n\n### docker_image\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\nRuntime behavior:\n\n- Uses the same bind-mount and `/src` working directory as `docker` hooks.\n- Environment variables configured via `env` are passed using `-e`.\n\nIf the image already defines an `ENTRYPOINT`, you can omit `--entrypoint` in `entry`. Otherwise, specify it explicitly in `entry`.\n\n!!! note \"prek-only\"\n\n    prek uses the same runtime auto-detection as `docker` hooks.\n\n### dotnet\n\n**Status in prek:** Not supported yet.\n\nTracking: [#48](https://github.com/j178/prek/issues/48)\n\n### fail\n\n**Status in prek:** ✅ Supported.\n\n`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.\n\n### golang\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- `go1.22`, `1.22`\n- `go1.22.1`, `1.22.1`\n- Semver ranges like `>=1.20, <1.23`\n- Absolute path to a `go` executable\n\nPre-release strings (for example `go1.22rc1`) are not supported yet.\n\n### haskell\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\n#### `language_version`\n\n`language_version` is not supported for Haskell hooks yet. It uses the system `cabal` and `ghc` installations.\n\nThe hook `entry` should point at an executable installed by `cabal`.\n\n### julia\n\n**Status in prek:** ✅ Supported.\n\nprek installs Julia hooks into an isolated environment using Julia's built-in package manager (`Pkg`).\n\nThe 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.\n\n`additional_dependencies` are supported and will be added to the environment via `Pkg.add`.\n\n#### `language_version`\n\n`language_version` is not supported for Julia hooks yet. It uses the system `julia` installation.\n\nThe 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=<env_path> --startup-file=no`.\n\n### lua\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\n#### `language_version`\n\nLua does not support `language_version` today. It uses the system `lua` / `luarocks` installation.\n\nThe hook entry should point at an executable installed by LuaRocks.\n\n### node\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\nNode hooks run without needing a pre-installed Node runtime when toolchain download is available.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- `node18`, `18`, `18.19`, `18.19.1`\n- Semver ranges like `^18.12` or `>=18, <20`\n- LTS selectors: `lts` or `lts/<codename>`\n- Absolute path to a Node executable\n\n### perl\n\n**Status in prek:** Not supported yet.\n\nTracking: [#1447](https://github.com/j178/prek/issues/1447)\n\n### python\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\nPython hooks run without requiring a system Python when toolchain download is available.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- `python`, `python3`, `python3.12`, `python3.12.1`\n- `3`, `3.12`, `3.12.1`\n- Wheel-style short forms like `312` or `python312`\n- Semver ranges like `>=3.9, <3.13`\n- Absolute path to a Python executable\n\n!!! note \"prek-only\"\n\n    prek uses `uv` for virtual environments and dependency installs, and can auto-install Python toolchains based on `language_version`.\n\n#### Dependency management with `uv`\n\nprek uses `uv` for creating virtual environments and installing dependencies:\n\n- First tries to find `uv` in the system PATH\n- If not found, automatically installs `uv` from the best available source (GitHub releases, PyPI, or mirrors)\n- Automatically installs the required Python version if it's not already available\n\n!!! warning \"Environment variables\"\n\n    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.\n\n    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.\n\n#### PEP 723 inline script metadata support\n\nFor Python hooks **without** `additional_dependencies`, prek can read PEP 723 inline metadata from the script specified in the `entry` field.\n\n**Example:**\n\n`.pre-commit-config.yaml`:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: echo\n        name: echo\n        language: python\n        entry: ./echo.py\n```\n\n`echo.py`:\n\n```python\n# /// script\n# requires-python = \">=3.13\"\n# dependencies = [\n#     \"pyecho-cli\",\n# ]\n# ///\n\nfrom pyecho import main\nmain()\n```\n\n**Important notes:**\n\n- The first part of the `entry` field must be a path to a local Python script\n- If `additional_dependencies` is specified in `.pre-commit-config.yaml`, script metadata will be ignored\n- When both `language_version` (in config) and `requires-python` (in script) are set, `language_version` takes precedence\n- Only `dependencies` and `requires-python` fields are supported; other metadata like `tool.uv` is ignored\n\n### r\n\n**Status in prek:** Not supported yet.\n\nTracking: [#42](https://github.com/j178/prek/issues/42)\n\n### ruby\n\n**Status in prek:** ✅ Supported.\n\nprek installs gems from a `*.gemspec` and runs executables declared in the gemspec. `additional_dependencies` are installed into the same isolated gemset.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- `3`, `3.3`, `3.3.6`\n- `ruby-3`, `ruby-3.3`, `ruby-3.3.6`\n- Semver ranges like `>=3.2, <4.0`\n- Absolute path to a Ruby executable\n\n!!! note \"prek-only\"\n\n    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.\n\n    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.\n\n    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.\n\nGems specified in hook gemspec files and `additional_dependencies` are installed into an isolated gemset shared across hooks with the same Ruby version and dependencies.\n\n### rust\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\n!!! note \"Using `--locked` flag\"\n\n    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.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- Channels: `stable`, `beta`, `nightly`\n- `1`, `1.70`, `1.70.0`\n- Semver ranges like `>=1.70, <1.72`\n\n!!! note \"prek-only\"\n\n    - prek supports installing packages from virtual workspaces. See [#1180](https://github.com/j178/prek/pull/1180).\n    - `additional_dependencies` supports:\n        - Library dependencies using `name` or `name:version` (applied via `cargo add`).\n        - CLI dependencies using `cli:`.\n            - There are two forms:\n                - crates.io: `cli:<crate>[:<version>]`\n                - git: `cli:<url>[:<tag>[:<package>]]`\n            - For git dependencies:\n                - `<url>` is the git repository URL.\n                - `<tag>` is optional and selects a specific git tag.\n                - `<package>` is optional and selects which Cargo package to install binaries from.\n                - Use `<package>` when the git repository is a workspace or multi-crate repository and Cargo needs you to choose one package.\n                - This matches the package argument in `cargo install --git <url> <package>`.\n            - Examples:\n                - crates.io package: `cli:rg`\n                - crates.io package with version: `cli:rg:13.0.0`\n                - git repository default ref: `cli:https://github.com/fish-shell/fish-shell`\n                - git repository with tag: `cli:https://github.com/fish-shell/fish-shell:v4.5.0`\n                - git repository with package but no tag: `cli:https://github.com/fish-shell/fish-shell::fish`\n                - git repository with tag and package: `cli:https://github.com/fish-shell/fish-shell:v4.5.0:fish`\n            - Invalid forms:\n                - empty package is invalid, for example `...:v4.5.0:` or `...::`.\n\n### swift\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\nRuntime behavior:\n\n- Uses the system Swift installation (no automatic toolchain management)\n- Builds Swift packages with `swift build -c release`\n- Build artifacts are stored in the hook environment's `.build/release/` directory\n- The `entry` command runs with built binaries available on PATH\n\n#### `language_version`\n\nSwift does not support `language_version` today. It uses the system `swift` installation.\n\n### pygrep\n\n**Status in prek:** ✅ Supported.\n\nprek provides a Python-based grep implementation for file content matching. The `entry` is a Python regex. Supported args:\n\n- `-i` / `--ignore-case`\n- `--multiline`\n- `--negate` (require all files to match)\n\nRegex matching uses Python’s `re` semantics for compatibility with pre-commit.\n\n### system\n\n**Status in prek:** ✅ Supported.\n\n`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.\n\nUse `system` for tools with special environment requirements that cannot run in isolated environments.\n\n!!! note\n\n    `unsupported` is accepted as an alias for `system`.\n\n### script\n\n**Status in prek:** ✅ Supported.\n\n`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.\n\nUse `script` for simple repository scripts that only need file paths and no managed environment.\n\n!!! note\n\n    `unsupported_script` is accepted as an alias for `script`.\n\n### deno\n\n**Status in prek:** ✅ Supported.\n\nprek 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.\n\nDeno hooks run without needing a pre-installed Deno runtime when toolchain download is available.\n\n#### Rules\n\n- `additional_dependencies` are treated as executable installs. Each item should be something `deno install --global` can install, such as an `npm:` or `jsr:` specifier.\n- `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.\n- To override the executable name for an additional dependency, append `:name` to the dependency string. For example: `npm:semver@7:semver-tool`.\n\nFor 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`.\n\n#### `language_version`\n\nSupported formats:\n\n- `default` or `system`\n- `deno`, `deno@latest`\n- `deno@x`, `x` (major version)\n- `deno@x.y`, `x.y` (major.minor version)\n- `deno@x.y.z`, `x.y.z` (exact version)\n- Semver ranges like `>=x.y, <x+1.0`\n\n#### Using npm packages\n\nDeno supports npm packages via the `npm:` prefix. For hooks that use npm packages, specify the entry using `deno run npm:package`:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: eslint\n        name: ESLint\n        language: deno\n        entry: deno run -A npm:eslint\n        types: [ts, tsx, js, jsx]\n```\n\nFor JSR packages, use the `jsr:` prefix in a `deno run` entry:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: biome\n        name: Biome\n        language: deno\n        entry: deno run -A jsr:@biomejs/biome\n        types: [ts, tsx, js, jsx]\n```\n\nFor executable-style additional dependencies, use the package specifier directly:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: semver-version\n        name: semver version\n        language: deno\n        entry: semver-tool 1.2.3\n        additional_dependencies:\n          - npm:semver@7:semver-tool\n        pass_filenames: false\n```\n\nYou can also install a local file as an executable additional dependency:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: local-tool\n        name: local tool\n        language: deno\n        entry: echo-tool\n        additional_dependencies:\n          - ./tool.ts:echo-tool\n        pass_filenames: false\n```\n\n#### Built-in commands\n\nDeno's built-in commands (`deno fmt`, `deno lint`, `deno check`) work directly:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: deno-fmt\n        name: Deno Format\n        language: deno\n        entry: deno fmt\n        types: [ts, tsx, js, jsx, json, md]\n      - id: deno-lint\n        name: Deno Lint\n        language: deno\n        entry: deno lint\n        types: [ts, tsx, js, jsx]\n```\n\n!!! note \"prek-only\"\n\n    Deno language support is a prek extension. pre-commit does not have native `deno` support.\n\nIf you want to help add support for the missing languages, check open issues or start a discussion in the repo.\n"
  },
  {
    "path": "docs/proposals/concurrency.md",
    "content": "# Priority-based parallel hook execution\n\nThis document outlines the design for parallel hook execution using explicit priority levels.\n\n## Motivation\n\nBy default, `prek` executes hooks sequentially. While safe, this is inefficient for independent tasks (e.g., linting different languages). This proposal introduces per-hook priorities to allow safe, parallel execution of hooks.\n\n## Configuration\n\n### Hook Configuration: `priority`\n\nA new optional field `priority` is added to the hook configuration.\n\n```yaml\n- id: cargo-fmt\n  priority: 10\n```\n\n- **Type**: `u32`\n- **Default**: `None` (auto-populated by hook index)\n\nWhen `priority` is omitted, the scheduler assigns the hook a priority equal to its index in the configuration file, starting at `0`. This preserves the current sequential behavior by giving each hook a unique, increasing priority by default.\n\n## Execution Model\n\nExecution is driven purely by priority numbers:\n\n### Scope\n\n`priority` is **global within a single configuration file**. That is, priorities are compared across **all hooks in the same `.pre-commit-config.yaml`**, even if the hooks live under different `repos:` entries.\n\n`priority` does **not** apply across *different* `.pre-commit-config.yaml` files (or separate `prek` runs with different configs). Each config file is scheduled independently.\n\n1. **Ordering**: Hooks run from the lowest priority value to the highest.\n2. **Concurrency**: Hooks that share the same priority execute concurrently, subject to the global concurrency limit (default: number of CPUs).\n3. **Defaults**: Without explicit priorities, each hook receives a unique priority derived from its position, so execution remains sequential and backwards-compatible.\n4. **Conflicts**: If two hooks intentionally share a priority, they will be run in parallel. Users are responsible for assigning priorities that match their desired grouping.\n\n## `require_serial` Clarification\n\nThe existing `require_serial` configuration key often causes confusion. In this design, its meaning is strictly scoped:\n\n- **`require_serial: true`**: Controls **invocation concurrency for that hook**. When running a hook against files, `prek` limits that hook to a single in-flight invocation at a time. This effectively disables running multiple batches of the *same hook* concurrently.\n    - `prek` will still try to pass all files in one invocation, but may split into multiple invocations if the OS command-line length limit would be exceeded.\n- **It does NOT imply exclusive execution**. A hook with `require_serial: true` can still run in parallel with other hooks that share its `priority`.\n- If a hook *must* run alone (e.g., it modifies global state), it should be assigned a unique priority value that no other hook uses.\n\n## Design Considerations\n\n### Mixing Explicit and Implicit Priorities\n\nImplicit priorities are always derived from the hook's position in the configuration (0-based), regardless of any explicitly configured priorities on other hooks.\n\nPositions are taken from the **fully flattened hook list for the current `.pre-commit-config.yaml`**, in the order hooks appear as `repos:` and `hooks:` are read. In other words, implicit priorities are assigned across the whole file, not per-repo.\n\nExample:\n\n- Hook at index `0` with no `priority` gets implicit priority `0`.\n- Hook at index `1` with `priority: 10` keeps priority `10`.\n- Hook at index `2` with no `priority` gets implicit priority `2`.\n\nThis means a later hook with an implicit priority can run before an earlier hook that was assigned a larger explicit priority.\n\nIf you want to avoid surprises when introducing explicit priorities, prefer setting `priority` on all hooks (or at least on every hook whose relative order matters).\n\n### Grouped Output\n\nIf files are modified during a *parallel priority group*, `prek` can only tell that **one or more hooks in the group** made changes (not which one). In this case, `prek` prints a grouped tree for the whole priority group and marks the group as failed.\n\nExample:\n\n```\n  Files were modified by following hooks...................................Failed\n    ┌ Modifies File........................................................Passed\n    │ Prints Output........................................................Passed\n    └ No Output............................................................Passed\n  Later Hook...............................................................Passed\n```\n\n### Fail Fast\n\nIf `fail_fast` is enabled:\n\n- If a hook fails, `prek` should wait for currently running hooks with the *current priority* to finish, but **abort** the execution of higher-priority groups.\n\n### Example Configuration\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: cargo-fmt\n        name: Format Rust\n        entry: cargo fmt\n        language: system\n        priority: 0  # Runs first\n\n  # These hooks are in different repos, but share the same priority,\n  # so they can run concurrently.\n  - repo: local\n    hooks:\n      - id: ruff\n        name: Lint Python\n        entry: ruff check\n        language: system\n        priority: 10\n\n  - repo: local\n    hooks:\n      - id: shellcheck\n        name: Lint Shell\n        entry: shellcheck\n        language: system\n        priority: 10\n\n  - repo: local\n    hooks:\n      - id: integration-tests\n        name: Integration Tests\n        entry: just test\n        language: system\n        priority: 20 # Starts after priority=10 group completes\n```\n"
  },
  {
    "path": "docs/quickstart.md",
    "content": "# Quickstart\n\nThis page helps you get productive with **prek** in minutes, whether you are migrating from [pre-commit](https://pre-commit.com/) or starting from scratch.\n\nFirst follow the [installation guide](./installation.md) to install prek on your system.\n\n[I already use pre-commit](#already-using-pre-commit){ .md-button .md-button--primary }\n[I'm new to pre-commit-style tools](#new-to-pre-commit-style-workflows){ .md-button }\n{: style=\"display:flex; flex-wrap:wrap; gap:1rem; justify-content:center; margin:1.5rem 0;\" }\n\n## Already using pre-commit?\n\nGreat news - prek is designed as a drop-in replacement, you only need two tweaks:\n\n1. Replace every `pre-commit` command in your scripts or documentation with `prek`. Your existing `.pre-commit-config.yaml` continues to work unchanged.\n\n    ```console\n    $ prek run\n    trim trailing whitespace.................................................Passed\n    fix end of files.........................................................Passed\n    typos....................................................................Passed\n    cargo fmt................................................................Passed\n    cargo clippy.............................................................Passed\n    ```\n\n2. Reinstall the Git shims once via `prek install -f` (run this if you previously executed `pre-commit install`).\n\nFrom here you can explore what prek adds on top of pre-commit:\n\n- [Key differences and new features](./diff.md)\n- [Built-in Rust-native hooks](./builtin.md)\n- [Workspace mode for monorepos](./workspace.md)\n\n## New to pre-commit-style workflows?\n\nFollow this short example to experience how prek automates linting and formatting tasks.\n\n### 1. Create a configuration\n\nIn the root of your repository, add a `prek.toml`:\n\n```toml\n[[repos]]\nrepo = \"https://github.com/pre-commit/pre-commit-hooks\"\nrev = \"v6.0.0\"\nhooks = [\n  { id = \"check-yaml\" },\n  { id = \"end-of-file-fixer\" },\n]\n```\n\nThis configuration uses the `pre-commit-hooks` repository and enables two hooks: `check-yaml` validates YAML files, and `end-of-file-fixer` ensures every file ends with a newline.\n\n!!! note\n\n    `prek.toml` is the native configuration file for **prek**. If you already have a `.pre-commit-config.yaml`, prek can still read it today.\n\nOnce you’re happy with your setup, you can stage the config file with `git add prek.toml`.\n\n### 2. Run hooks on demand\n\nUse `prek run` to execute all configured hooks on the files in your current git staging area:\n\n```bash\nprek run\n```\n\nNeed to run a single hook? Pass its ID, for example `prek run check-yaml`. You can also target specific files with `--files`, or run against the entire repository with `--all-files`.\n\n### 3. Wire hooks into git automatically\n\nTo run the hooks every time you commit, install prek’s Git shim integration:\n\n```bash\nprek install\n```\n\nNow every `git commit` will invoke `prek run` for the files included in the commit. If you ever want to undo this, run `prek uninstall`.\n\n### 4. Go further\n\n- Explore richer configuration options in the official [pre-commit documentation](https://pre-commit.com/). Every example there works with prek.\n- Check the [configuration reference](./configuration.md) for prek-specific settings.\n- Browse the [built-in hooks](./builtin.md) and the [difference guide](./diff.md) to see what else you can leverage.\n\nThat’s it! You now have automated checks running locally with minimal setup. When you’re ready to dive deeper, the rest of the docs cover advanced workflows, language-specific installers, and more.\n"
  },
  {
    "path": "docs/requirements.in",
    "content": "llmstxt-standalone==0.2.0\nzensical==0.0.24\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "# This file was autogenerated by uv via the following command:\n#    uv pip compile docs/requirements.in --universal -o docs/requirements.txt --python 3.14\nannotated-types==0.7.0\n    # via pydantic\nbeautifulsoup4==4.14.3\n    # via\n    #   llmstxt-standalone\n    #   markdownify\nclick==8.3.1\n    # via\n    #   typer\n    #   zensical\ncolorama==0.4.6 ; sys_platform == 'win32'\n    # via click\ndeepmerge==2.0\n    # via zensical\nllmstxt-standalone==0.2.0\n    # via -r docs/requirements.in\nmarkdown==3.10\n    # via\n    #   pymdown-extensions\n    #   zensical\nmarkdown-it-py==3.0.0\n    # via\n    #   mdformat\n    #   rich\nmarkdownify==1.2.2\n    # via llmstxt-standalone\nmdformat==0.7.22\n    # via\n    #   llmstxt-standalone\n    #   mdformat-tables\nmdformat-tables==1.0.0\n    # via llmstxt-standalone\nmdurl==0.1.2\n    # via markdown-it-py\npydantic==2.12.5\n    # via llmstxt-standalone\npydantic-core==2.41.5\n    # via pydantic\npygments==2.19.2\n    # via\n    #   rich\n    #   zensical\npymdown-extensions==10.17.2\n    # via zensical\npyyaml==6.0.3\n    # via\n    #   llmstxt-standalone\n    #   pymdown-extensions\n    #   zensical\nrich==14.3.2\n    # via typer\nruamel-yaml==0.19.1\n    # via llmstxt-standalone\nshellingham==1.5.4\n    # via typer\nsix==1.17.0\n    # via markdownify\nsoupsieve==2.8.3\n    # via beautifulsoup4\ntyper==0.21.1\n    # via llmstxt-standalone\ntyping-extensions==4.15.0\n    # via\n    #   beautifulsoup4\n    #   pydantic\n    #   pydantic-core\n    #   typer\n    #   typing-inspection\ntyping-inspection==0.4.2\n    # via pydantic\nwcwidth==0.5.3\n    # via mdformat-tables\nzensical==0.0.24\n    # via -r docs/requirements.in\n"
  },
  {
    "path": "docs/workspace.md",
    "content": "# Workspace Mode\n\n`prek` supports a powerful workspace mode that allows you to manage multiple projects with their own pre-commit configurations within a single repository. This is particularly useful for monorepos or projects with complex directory structures.\n\n## Overview\n\nA **workspace** is a directory structure that contains:\n\n- A root `.pre-commit-config.yaml` file\n- Zero or more nested `.pre-commit-config.yaml` files in subdirectories\n\nEach directory containing a `.pre-commit-config.yaml` file is considered a **project**. Projects can be nested infinitely deep.\n\n## Discovery\n\nWhen you run `prek run` without the `--config` option, `prek` automatically discovers the workspace:\n\n1. **Find workspace root**: Starting from the current working directory, `prek` walks up the directory tree until it finds a `.pre-commit-config.yaml` file. This becomes the workspace root.\n\n2. **Discover all projects**: From the workspace root, `prek` recursively searches all subdirectories for additional `.pre-commit-config.yaml` files. Each one becomes a separate project.\n\n3. **Git repository boundary**: The search stops at the git repository root (`.git` directory) to avoid including unrelated projects.\n\n!!! note\n\n    **Workspace root**\n\n    - The workspace root is not necessarily the same as the git repository root, a workspace can exist within a subdirectory of a git repository.\n    - The current working directory determines the workspace root discovery. `prek` starts searching from your current location and stops at the first `.pre-commit-config.yaml` file found while traversing up the directory tree. Running from different directories may discover different workspace roots. Use `prek -C <dir>` to change the working directory before execution.\n\n    **Discovery exclusions**\n\n    - Directories beginning with a dot (e.g. `.hidden`) are ignored during project discovery.\n    - Cookiecutter template directories (names like `{{cookiecutter.project_slug}}`) are ignored during project discovery.\n\n    **Ignore rules**\n\n    - 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`.\n    - 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.\n\n!!! tip\n\n    After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up.\n\n## Project Organization\n\n### Example Structure\n\n```text\nmy-monorepo/\n├── .pre-commit-config.yaml          # Workspace root config\n├── .git/\n├── docs/\n│   └── .pre-commit-config.yaml      # Nested project\n├── src/\n│   ├── .pre-commit-config.yaml      # Nested project\n│   └── backend/\n│       └── .pre-commit-config.yaml  # Deeply nested project\n└── frontend/\n    └── .pre-commit-config.yaml      # Nested project\n```\n\nIn this example:\n\n- `my-monorepo/` is the workspace root\n- `docs/`, `src/`, `src/backend/`, and `frontend/` are individual projects\n- Each project has its own `.pre-commit-config.yaml` file\n\n## Execution Model\n\n### File Collection\n\nWhen running in workspace mode:\n\n1. **Collect all files**: `prek` collects all files within the workspace root directory\n2. **Apply global filters**: Files are filtered based on include/exclude patterns from the workspace root config\n3. **Distribute to projects**: Each project receives a subset of files based on its location\n\n#### File Visibility Constraints\n\n**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.\n\nA 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).\n\n### Hook Execution\n\nFor each project:\n\n1. **Scope to project directory**: Hooks run within their project's root directory\n2. **Filter files**: Only files within the project's directory tree are passed to its hooks\n3. **Independent execution**: Each project's hooks run independently with their own environment\n\n### Execution Order\n\nProjects are executed from **deepest to shallowest**:\n\n1. `src/backend/` (deepest)\n2. `src/`\n3. `docs/`\n4. `frontend/`\n5. `./` (root, last)\n\nThis ensures that more specific configurations (deeper projects) take precedence over general ones.\n\n### File Processing Behavior\n\n**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.\n\n**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:\n\n```yaml\n# src/backend/.pre-commit-config.yaml\norphan: true\n\nrepos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.8.4\n    hooks:\n      - id: ruff\n```\n\nWith this option:\n\n- Files in `src/backend/` are processed **only** by hooks in `src/backend/`\n- Files in `src/` (but not in `src/backend/`) are processed by hooks in `src/` and the workspace root\n- Files in the root (but not in subdirectories with configs) are processed by hooks in the root\n\nThis can be useful to avoid redundant processing in monorepos with nested project structures or to completely isolate a subproject from parent configurations.\n\n### Example Output\n\nWhen running `prek run` on the example structure above, you might see output like this:\n\n```console\n$ prek run\nRunning hooks for `src/backend`:\ncheck python ast.........................................................Passed\ncheck for merge conflicts................................................Passed\nblack....................................................................Passed\nisort....................................................................Passed\n\nRunning hooks for `docs`:\nMarkdownlint.........................................(unimplemented yet)Skipped\n\nRunning hooks for `frontend`:\nprettier.................................................................Passed\n\nRunning hooks for `src`:\nisort....................................................................Passed\nmypy.....................................................................Passed\ncheck python ast.........................................................Passed\ncheck docstring is first.................................................Passed\n\nRunning hooks for `.`:\nfix end of files.........................................................Passed\ncheck yaml...............................................................Passed\ncheck for added large files..............................................Passed\ntrim trailing whitespace.................................................Passed\ncheck for merge conflicts................................................Passed\n```\n\nNotice how:\n\n- Files in `src/backend/` are processed by both the `src/backend/` project and the `src/` project\n- Each project runs in its own working directory\n- The workspace root processes all files in the entire workspace\n- Projects are executed from deepest to shallowest as described in the execution order\n\n#### Orphan Projects and Selectors\n\nWhen 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.\n\n## Command Line Usage\n\n```bash\n# Run from current directory, auto-discover workspace\nprek run\n\n# Run specific hook across all projects\nprek run black\n\n# Run from specific directory\ncd src/backend && prek run\n\n# Use -C option to change directory automatically\nprek run -C src/backend\n```\n\nThe `-C <dir>` or `--cd <dir>` option automatically changes to the specified directory before running, allowing you to target specific projects from any location in the workspace.\n\n!!! note\n\n    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.\n\n## Project and Hook Selection\n\nIn workspace mode, you can selectively run hooks from specific projects or skip certain projects/hooks using flexible selector syntax.\n\n### Selector Syntax\n\nThe selector syntax has three different forms:\n\n1. **`<hook-id>`**: Matches all hooks with the given ID across all projects.\n2. **`<project-path>/`**: Matches all hooks from the specified project and its subprojects.\n3. **`<project-path>:<hook-id>`**: Matches only the specified hook from the specified project.\n\nSelectors can be used to select specific hooks or projects, and combined with `--skip` to exclude certain hooks or projects.\n\n!!! note\n\n    `<project-path>` can be a relative path, which is then resolved relative to the current working directory.\n    The trailing slash `/` in a `<project-path>` is important: if a selector does not contain a slash, it is interpreted as a hook ID.\n\n!!! note \"Hook ids containing `:`\"\n\n    If your hook id contains `:` (for example `id: lint:ruff`), `prek run lint:ruff`\n    will not select that hook. `prek` interprets `lint:ruff` as the selector\n    `<project-path>:<hook-id>`, with project `lint` and hook `ruff`.\n    To select the hook id `lint:ruff`, add a leading `:` and run\n    `prek run :lint:ruff`.\n\n### Running Specific Hooks or Projects\n\n```bash\n# Run all hooks with a specific ID across all projects\nprek run <hook-id>\n\n# Run only hooks from a specific project\nprek run <project-path>/\n\n# Run only hooks with a specific ID from a specific project\nprek run <project-path>:<hook-id>\n```\n\n**Examples:**\n\n```bash\n# Run all 'black' hooks across all projects\nprek run black\n\n# Run all hooks from the 'frontend' project\nprek run frontend/\n\n# Run only the 'lint' hook from the 'frontend' project\nprek run frontend:lint\n\n# Run the 'lint' from 'frontend' and 'black' from 'src/backend'\nprek run frontend:lint src/backend:black\n```\n\n### Skipping Projects or Hooks\n\nYou can skip specific projects or hooks using the `--skip` option, with the same syntax as for selecting projects or hooks.\n\n**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`.\n\n!!! tip\n\n    After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up.\n\n```bash\n# Skip all hooks from a specific project\nprek run --skip <project-path>/\n\n# Skip specific hooks within a selected project\nprek run <project-path>/ --skip <subproject-path>/\n\n# Skip all hooks with a specific ID across all projects\nprek run --skip <hook-id>\n```\n\n**Examples:**\n\n```bash\n# Run all hooks except those from the 'frontend' project\nprek run --skip frontend/\n\n# Run hooks from 'frontend' but skip 'frontend/docs'\nprek run frontend/ --skip frontend/docs\n\n# Run hooks from 'frontend' but skip 'frontend/docs' and 'frontend:lint'\nprek run frontend/ --skip frontend/docs --skip frontend:lint\n\n# Run all hooks except 'black' and 'markdownlint' hooks\nprek run --skip black --skip markdownlint\n```\n\n!!! note\n\n    Selecting a project includes all its subprojects unless explicitly skipped. Skipping a project also skips all its subprojects.\n\n!!! note\n\n    The `PREK_SKIP` or `SKIP` environment variable can be used as an alternative to `--skip`. Multiple values should be comma-delimited:\n\n```bash\n# Skip 'frontend' and 'tests' projects\nPREK_SKIP=frontend/,tests prek run\n\n# Skip 'frontend/docs' project and 'src/backend:lint' hook\nSKIP=frontend/docs,src/backend:lint prek run\n```\n\nPrecedence rules for `--skip` command line options and environment variables are: `--skip` > `PREK_SKIP` > `SKIP`.\n\n### Advanced Examples\n\n```bash\n# Run 'lint' hooks from all projects except 'tests'\nprek run lint --skip tests\n\n# Run all hooks from 'src' and 'docs' but skip 'src/legacy'\nprek run src/ docs/ --skip src/legacy\n\n# Run 'format' hooks only from Python projects\nprek run python:format\n```\n\n## Single Config Mode\n\nWhen 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.\n\nIn single config mode:\n\n- **No workspace discovery**: Only the explicitly specified configuration file is used\n- **Single execution context**: All hooks run from the git repository root directory\n- **Global file scope**: All files in the git repository are passed to all hooks\n- **No project isolation**: Hooks don't have access to project-specific working directories\n\n### Usage Examples\n\n```bash\n# Disable workspace mode, use specific config\nprek run --config .pre-commit-config.yaml\n\n# Use config from a subdirectory\nprek run --config src/.pre-commit-config.yaml\n\n# Short form using -c\nprek run -c docs/.pre-commit-config.yaml\n```\n\n### Key Differences: Workspace vs Single Config\n\n| Feature | Workspace Mode | Single Config Mode |\n| -- | -- | -- |\n| **Discovery** | Auto-discovers all `.pre-commit-config.yaml` files | Uses single specified config file |\n| **Working Directory** | Uses workspace root | Uses git repository root |\n| **File Scope** | All files in workspace | All files in git repo |\n| **Hook Scope** | Project-specific file filtering | All files pass to all hooks |\n| **Execution Context** | Each project runs in its own directory | All hooks run from git root |\n| **Configuration** | Multiple configs | Single config file only |\n\n### Migration from Single Config\n\nTo migrate an existing single-config setup to workspace mode:\n\n1. **Create workspace root**: Move existing `.pre-commit-config.yaml` to repository root\n2. **Add project configs**: Create `.pre-commit-config.yaml` in subdirectories as needed\n3. **Update file patterns**: Adjust `files`/`exclude` patterns to be project-relative\n4. **Test execution**: Verify hooks run in correct directories with correct file sets\n\n## Workspace Cache\n\nTo 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.\n\n- The cache is automatically used by default. You don't need to do anything for it to work.\n- 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.\n- 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.\n\n```bash\nprek run --refresh\n```\n\nThis will clear and rebuild the workspace cache before running hooks.\n\n## Behavior Changes in Workspace Mode\n\nWhen running in workspace mode, there are a few changes to the output format and behavior compared to single-config mode:\n\n1. Hook output is grouped by project, with a header indicating which project is currently running.\n2. Skipped hooks are not shown at all in the output, previously they were listed as \"Skipped\".\n\nThe workspace mode provides powerful organization capabilities while maintaining backward compatibility with existing single-config workflows.\n"
  },
  {
    "path": "licenses/LICENSE.identify.txt",
    "content": "Copyright (c) 2017 Chris Kuehl, Anthony Sottile\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "licenses/LICENSE.pre-commit.txt",
    "content": "Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "mise.toml",
    "content": "[settings]\ncargo.binstall = true\n\n[tools]\n# We need cargo-binstall so that Mise would download \"cargo:\" tools instead of building them.\ncargo-binstall = \"latest\"\n# For snapshot testing\ncargo-insta = \"latest\"\n\"cargo:cargo-nextest\" = \"latest\"\nprek = \"latest\"\nuv = \"latest\"\n\n[tasks.lint]\ndescription = \"Run formatting and linting\"\nrun = [\n  \"cargo fmt\",\n  \"cargo clippy --all-targets --all-features --workspace -- -D warnings\",\n]\n\n[tasks.test-unit]\ndescription = \"Run unit tests with insta review\"\nrun = \"cargo insta test --review --bin prek -- {{arg(name='filter')}}\"\n\n[tasks.test-all-unit]\ndescription = \"Run all unit tests with insta review\"\nrun = \"cargo insta test --review --workspace --lib --bins\"\n\n[tasks.test-integration]\ndescription = \"Run specific integration test with insta review\"\nrun = \"cargo insta test --review --test {{arg(name='test')}} -- {{arg(name='filter')}}\"\n\n[tasks.test-all-integration]\ndescription = \"Run all integration tests with insta review\"\nrun = \"cargo insta test --review --test '*'\"\n\n[tasks.test]\ndescription = \"Run all tests\"\nrun = \"cargo test --all-targets --all-features --workspace\"\n\n[tasks.generate-cli-reference]\ndescription = \"Generate CLI reference\"\nrun = \"cargo test --bin prek cli::_gen::generate_cli_reference -- --exact\"\nenv = { PREK_GENERATE = \"1\" }\n\n[tasks.generate-json-schema]\ndescription = \"Generate JSON schema\"\nrun = \"cargo test --bin prek --features schemars schema::_gen::generate_json_schema -- --exact\"\nenv = { PREK_GENERATE = \"1\" }\n\n[tasks.generate]\ndescription = \"Generate CLI reference and JSON schema\"\nrun = [{ task = \"generate-cli-reference\" }, { task = \"generate-json-schema\" }]\n\n[tasks.preview-docs]\ndescription = \"Serve documentation locally\"\nrun = \"uvx --with-requirements docs/requirements.txt zensical serve\"\n\n[tasks.build-docs]\ndescription = \"Build documentation\"\nrun = [\n  \"uvx --with-requirements docs/requirements.txt zensical build\",\n  \"uvx --with-requirements docs/requirements.txt llmstxt-standalone build\",\n]\n\n[tasks.compile-docs-deps]\n# Python version should match PYTHON_VERSION in .github/workflows/publish-docs.yml\ndescription = \"Compile documentation dependencies\"\nrun = \"uv pip compile docs/requirements.in --universal -o docs/requirements.txt --python 3.14\"\n\n[tasks.update-macports]\ndescription = \"Update MacPorts portfile\"\nrun = \"uv run scripts/update-macports-portfile.py\"\n\n[tasks.release]\ndescription = \"Prepare for a release\"\nrun = \"\"\"\ngit checkout -b bump\nuvx --from 'rooster @ git+https://github.com/j178/rooster@747d16f' --python 3.13 -- rooster release\n\"\"\"\n"
  },
  {
    "path": "mkdocs.yml",
    "content": "site_name: prek\nsite_description: Better `pre-commit` alternative, re-engineered in Rust\nsite_author: j178\nsite_url: https://prek.j178.dev/\n\nrepo_name: j178/prek\nrepo_url: https://github.com/j178/prek\n\ncopyright: Copyright &copy; 2025 j178\n\ntheme:\n  name: material\n  logo: assets/logo.webp\n  favicon: assets/favicon.ico\n  palette:\n    - media: \"(prefers-color-scheme)\"\n      toggle:\n        icon: material/brightness-auto\n        name: Switch to light mode\n    - media: \"(prefers-color-scheme: light)\"\n      scheme: default\n      primary: indigo\n      accent: blue\n      toggle:\n        icon: material/brightness-7\n        name: Switch to dark mode\n    - media: \"(prefers-color-scheme: dark)\"\n      scheme: slate\n      primary: indigo\n      accent: blue\n      toggle:\n        icon: material/brightness-4\n        name: Switch to system preference\n  features:\n    - navigation.side\n    - navigation.sections\n    - navigation.expand\n    - navigation.path\n    - navigation.indexes\n    - navigation.instant\n    - navigation.instant.prefetch\n    - navigation.instant.progress\n    - navigation.tracking\n    - navigation.footer\n    - navigation.top\n    - content.code.copy\n    - content.code.annotate\n    - content.tabs.link\n    - search.suggest\n    - search.highlight\n    - search.share\n\nplugins:\n  - search\n  - minify:\n      minify_html: true\n  - include-markdown\n  - llmstxt:\n      markdown_description: |\n        prek is a drop-in replacement for pre-commit, fully compatible with existing\n        `.pre-commit-config.yaml` files. It runs the same hooks faster, with better\n        toolchain management.\n\n        Key differences from pre-commit:\n        - Single binary, no Python runtime required\n        - Parallel hook execution by priority\n        - Built-in workspace/monorepo support\n        - Automatic toolchain installation (Python, Node, Go, Rust, Ruby)\n        - Integration with uv for Python environments\n\n        When fetching documentation, use explicit `index.md` paths for directories, e.g.,\n        `https://prek.j178.dev/configuration/index.md`. This returns clean markdown\n        instead of rendered HTML.\n      full_output: llms-full.txt\n      sections:\n        Getting Started:\n          - index.md\n          - installation.md\n          - quickstart.md\n        Usage:\n          - configuration.md\n          - languages.md\n          - cli.md\n          - builtin.md\n          - workspace.md\n          - integrations.md\n          - authoring-hooks.md\n        Help:\n          - debugging.md\n          - faq.md\n        About:\n          - compatibility.md\n          - diff.md\n          - benchmark.md\n          - changelog.md\n\nnav:\n  - Getting Started:\n    - Introduction: index.md\n    - Installation: installation.md\n    - Quickstart: quickstart.md\n  - Usage:\n    - Configuration: configuration.md\n    - Language Support: languages.md\n    - Commands: cli.md\n    - Built-in Hooks: builtin.md\n    - Workspace Mode: workspace.md\n    - Integrations: integrations.md\n    - Authoring Hooks: authoring-hooks.md\n  - Help:\n    - Debugging: debugging.md\n    - FAQ: faq.md\n  - About:\n    - Compatibility: compatibility.md\n    - Differences: diff.md\n    - Benchmark: benchmark.md\n    - Changelog: changelog.md\n\nmarkdown_extensions:\n  - pymdownx.highlight:\n      anchor_linenums: true\n      line_spans: __span\n      pygments_lang_class: true\n  - pymdownx.inlinehilite\n  - pymdownx.snippets\n  - pymdownx.superfences\n  - pymdownx.tabbed:\n      alternate_style: true\n      combine_header_slug: true\n      slugify: !!python/object/apply:pymdownx.slugs.slugify\n        kwds:\n          case: lower\n  - pymdownx.details\n  - pymdownx.emoji:\n      emoji_index: !!python/name:material.extensions.emoji.twemoji\n      emoji_generator: !!python/name:material.extensions.emoji.to_svg\n  - admonition\n  - attr_list\n  - footnotes\n  - md_in_html\n  - meta\n  - tables\n  - toc:\n      permalink: true\n"
  },
  {
    "path": "prek.schema.json",
    "content": "{\n  \"$id\": \"https://www.schemastore.org/prek.json\",\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"prek.toml\",\n  \"description\": \"The configuration file for prek, a git hook manager written in Rust.\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"repos\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Repo\"\n      }\n    },\n    \"default_install_hook_types\": {\n      \"description\": \"A list of `--hook-types` which will be used by default when running `prek install`.\\nDefault is `[pre-commit]`.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/HookType\"\n      }\n    },\n    \"default_language_version\": {\n      \"description\": \"A mapping from language to the default `language_version`.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"bun\": {\n          \"type\": \"string\"\n        },\n        \"conda\": {\n          \"type\": \"string\"\n        },\n        \"coursier\": {\n          \"type\": \"string\"\n        },\n        \"dart\": {\n          \"type\": \"string\"\n        },\n        \"deno\": {\n          \"type\": \"string\"\n        },\n        \"docker\": {\n          \"type\": \"string\"\n        },\n        \"docker_image\": {\n          \"type\": \"string\"\n        },\n        \"dotnet\": {\n          \"type\": \"string\"\n        },\n        \"fail\": {\n          \"type\": \"string\"\n        },\n        \"golang\": {\n          \"type\": \"string\"\n        },\n        \"haskell\": {\n          \"type\": \"string\"\n        },\n        \"julia\": {\n          \"type\": \"string\"\n        },\n        \"lua\": {\n          \"type\": \"string\"\n        },\n        \"node\": {\n          \"type\": \"string\"\n        },\n        \"perl\": {\n          \"type\": \"string\"\n        },\n        \"pygrep\": {\n          \"type\": \"string\"\n        },\n        \"python\": {\n          \"type\": \"string\"\n        },\n        \"r\": {\n          \"type\": \"string\"\n        },\n        \"ruby\": {\n          \"type\": \"string\"\n        },\n        \"rust\": {\n          \"type\": \"string\"\n        },\n        \"script\": {\n          \"type\": \"string\"\n        },\n        \"swift\": {\n          \"type\": \"string\"\n        },\n        \"system\": {\n          \"type\": \"string\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"default_stages\": {\n      \"description\": \"A configuration-wide default for the stages property of hooks.\\nDefault to all stages.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Stage\"\n      },\n      \"uniqueItems\": true\n    },\n    \"files\": {\n      \"description\": \"Global file include pattern.\",\n      \"$ref\": \"#/definitions/FilePattern\"\n    },\n    \"exclude\": {\n      \"description\": \"Global file exclude pattern.\",\n      \"$ref\": \"#/definitions/FilePattern\"\n    },\n    \"fail_fast\": {\n      \"description\": \"Set to true to have prek stop running hooks after the first failure.\\nDefault is false.\",\n      \"type\": \"boolean\"\n    },\n    \"minimum_prek_version\": {\n      \"description\": \"The minimum version of prek required to run this configuration.\",\n      \"type\": \"string\"\n    },\n    \"orphan\": {\n      \"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.\",\n      \"type\": \"boolean\"\n    }\n  },\n  \"required\": [\n    \"repos\"\n  ],\n  \"additionalProperties\": true,\n  \"x-tombi-toml-version\": \"v1.1.0\",\n  \"definitions\": {\n    \"Repo\": {\n      \"description\": \"A repository of hooks, which can be remote, local, meta, or builtin.\",\n      \"type\": \"object\",\n      \"oneOf\": [\n        {\n          \"$ref\": \"#/definitions/RemoteRepo\"\n        },\n        {\n          \"$ref\": \"#/definitions/LocalRepo\"\n        },\n        {\n          \"$ref\": \"#/definitions/MetaRepo\"\n        },\n        {\n          \"$ref\": \"#/definitions/BuiltinRepo\"\n        }\n      ],\n      \"additionalProperties\": true\n    },\n    \"RemoteRepo\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"repo\": {\n          \"description\": \"Remote repository location. Must not be `local`, `meta`, or `builtin`.\",\n          \"type\": \"string\",\n          \"not\": {\n            \"enum\": [\n              \"local\",\n              \"meta\",\n              \"builtin\"\n            ]\n          }\n        },\n        \"rev\": {\n          \"type\": \"string\"\n        },\n        \"hooks\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/RemoteHook\"\n          },\n          \"writeOnly\": true\n        }\n      },\n      \"required\": [\n        \"repo\",\n        \"rev\",\n        \"hooks\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"RemoteHook\": {\n      \"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.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"id\": {\n          \"description\": \"The id of the hook.\",\n          \"type\": \"string\"\n        },\n        \"name\": {\n          \"description\": \"Override the name of the hook.\",\n          \"type\": \"string\"\n        },\n        \"entry\": {\n          \"description\": \"Override the entrypoint. Not documented in the official docs but works.\",\n          \"type\": \"string\"\n        },\n        \"language\": {\n          \"description\": \"Override the language. Not documented in the official docs but works.\",\n          \"$ref\": \"#/definitions/Language\"\n        },\n        \"priority\": {\n          \"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`).\",\n          \"type\": \"integer\",\n          \"minimum\": 0\n        },\n        \"alias\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"description\": \"The pattern of files to run on.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"exclude\": {\n          \"description\": \"Exclude files that were matched by `files`.\\nDefault is `$^`, which matches nothing.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"types\": {\n          \"description\": \"List of file types to run on (AND).\\nDefault is `[file]`, which matches all files.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"types_or\": {\n          \"description\": \"List of file types to run on (OR).\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"exclude_types\": {\n          \"description\": \"List of file types to exclude.\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"additional_dependencies\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"args\": {\n          \"description\": \"Additional arguments to pass to the hook.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"description\": \"Environment variables to set for the hook.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"always_run\": {\n          \"description\": \"This hook will run even if there are no matching files.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"fail_fast\": {\n          \"description\": \"If this hook fails, don't run any more hooks.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"pass_filenames\": {\n          \"description\": \"Append filenames that would be checked to the hook entry as arguments.\\nDefault is true.\",\n          \"$ref\": \"#/definitions/PassFilenames\"\n        },\n        \"description\": {\n          \"description\": \"A description of the hook. For metadata only.\",\n          \"type\": \"string\"\n        },\n        \"language_version\": {\n          \"description\": \"Run the hook on a specific version of the language.\\nDefault is `default`.\\nSee <https://pre-commit.com/#overriding-language-version>.\",\n          \"type\": \"string\"\n        },\n        \"log_file\": {\n          \"description\": \"Write the output of the hook to a file when the hook fails or verbose is enabled.\",\n          \"type\": \"string\"\n        },\n        \"require_serial\": {\n          \"description\": \"This hook will execute using a single process instead of in parallel.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"stages\": {\n          \"description\": \"Select which Git hook stages this hook runs for.\\nDefault all stages are selected.\\nSee <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Stage\"\n          },\n          \"uniqueItems\": true\n        },\n        \"verbose\": {\n          \"description\": \"Print the output of the hook even if it passes.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"minimum_prek_version\": {\n          \"description\": \"The minimum version of prek required to run this hook.\",\n          \"type\": \"string\"\n        }\n      },\n      \"required\": [\n        \"id\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"Language\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"bun\",\n        \"conda\",\n        \"coursier\",\n        \"dart\",\n        \"deno\",\n        \"docker\",\n        \"docker_image\",\n        \"dotnet\",\n        \"fail\",\n        \"golang\",\n        \"haskell\",\n        \"julia\",\n        \"lua\",\n        \"node\",\n        \"perl\",\n        \"pygrep\",\n        \"python\",\n        \"r\",\n        \"ruby\",\n        \"rust\",\n        \"script\",\n        \"swift\",\n        \"system\"\n      ]\n    },\n    \"FilePattern\": {\n      \"description\": \"A file pattern, either a regex or glob pattern(s).\",\n      \"oneOf\": [\n        {\n          \"description\": \"A regular expression pattern.\",\n          \"type\": \"string\"\n        },\n        {\n          \"type\": \"object\",\n          \"properties\": {\n            \"glob\": {\n              \"oneOf\": [\n                {\n                  \"description\": \"A glob pattern.\",\n                  \"type\": \"string\"\n                },\n                {\n                  \"description\": \"A list of glob patterns.\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            }\n          },\n          \"required\": [\n            \"glob\"\n          ]\n        }\n      ]\n    },\n    \"PassFilenames\": {\n      \"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.\",\n      \"oneOf\": [\n        {\n          \"type\": \"boolean\"\n        },\n        {\n          \"type\": \"integer\",\n          \"exclusiveMinimum\": 0\n        }\n      ]\n    },\n    \"Stage\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"manual\",\n        \"commit-msg\",\n        \"post-checkout\",\n        \"post-commit\",\n        \"post-merge\",\n        \"post-rewrite\",\n        \"pre-commit\",\n        \"pre-merge-commit\",\n        \"pre-push\",\n        \"pre-rebase\",\n        \"prepare-commit-msg\"\n      ]\n    },\n    \"LocalRepo\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"repo\": {\n          \"description\": \"Must be `local`.\",\n          \"type\": \"string\",\n          \"const\": \"local\"\n        },\n        \"hooks\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/LocalHook\"\n          }\n        }\n      },\n      \"required\": [\n        \"repo\",\n        \"hooks\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"LocalHook\": {\n      \"description\": \"A local hook in the configuration file.\\n\\nThis is similar to `ManifestHook`, but includes config-only fields (like `priority`).\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"id\": {\n          \"description\": \"The id of the hook.\",\n          \"type\": \"string\"\n        },\n        \"name\": {\n          \"description\": \"The name of the hook.\",\n          \"type\": \"string\"\n        },\n        \"entry\": {\n          \"description\": \"The command to run. It can contain arguments that will not be overridden.\",\n          \"type\": \"string\"\n        },\n        \"language\": {\n          \"description\": \"The language of the hook. Tells prek how to install and run the hook.\",\n          \"$ref\": \"#/definitions/Language\"\n        },\n        \"priority\": {\n          \"description\": \"Priority used by the scheduler to determine ordering and concurrency.\\nHooks with the same priority can run in parallel.\",\n          \"type\": \"integer\",\n          \"minimum\": 0\n        },\n        \"alias\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"description\": \"The pattern of files to run on.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"exclude\": {\n          \"description\": \"Exclude files that were matched by `files`.\\nDefault is `$^`, which matches nothing.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"types\": {\n          \"description\": \"List of file types to run on (AND).\\nDefault is `[file]`, which matches all files.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"types_or\": {\n          \"description\": \"List of file types to run on (OR).\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"exclude_types\": {\n          \"description\": \"List of file types to exclude.\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"additional_dependencies\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"args\": {\n          \"description\": \"Additional arguments to pass to the hook.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"description\": \"Environment variables to set for the hook.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"always_run\": {\n          \"description\": \"This hook will run even if there are no matching files.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"fail_fast\": {\n          \"description\": \"If this hook fails, don't run any more hooks.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"pass_filenames\": {\n          \"description\": \"Append filenames that would be checked to the hook entry as arguments.\\nDefault is true.\",\n          \"$ref\": \"#/definitions/PassFilenames\"\n        },\n        \"description\": {\n          \"description\": \"A description of the hook. For metadata only.\",\n          \"type\": \"string\"\n        },\n        \"language_version\": {\n          \"description\": \"Run the hook on a specific version of the language.\\nDefault is `default`.\\nSee <https://pre-commit.com/#overriding-language-version>.\",\n          \"type\": \"string\"\n        },\n        \"log_file\": {\n          \"description\": \"Write the output of the hook to a file when the hook fails or verbose is enabled.\",\n          \"type\": \"string\"\n        },\n        \"require_serial\": {\n          \"description\": \"This hook will execute using a single process instead of in parallel.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"stages\": {\n          \"description\": \"Select which Git hook stages this hook runs for.\\nDefault all stages are selected.\\nSee <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Stage\"\n          },\n          \"uniqueItems\": true\n        },\n        \"verbose\": {\n          \"description\": \"Print the output of the hook even if it passes.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"minimum_prek_version\": {\n          \"description\": \"The minimum version of prek required to run this hook.\",\n          \"type\": \"string\"\n        }\n      },\n      \"required\": [\n        \"id\",\n        \"name\",\n        \"entry\",\n        \"language\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"MetaRepo\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"repo\": {\n          \"description\": \"Must be `meta`.\",\n          \"type\": \"string\",\n          \"const\": \"meta\"\n        },\n        \"hooks\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/MetaHook\"\n          }\n        }\n      },\n      \"required\": [\n        \"repo\",\n        \"hooks\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"MetaHook\": {\n      \"description\": \"A meta hook predefined in prek.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"id\": {\n          \"$ref\": \"#/definitions/MetaHooks\"\n        },\n        \"name\": {\n          \"description\": \"Override the name of the hook.\",\n          \"type\": \"string\"\n        },\n        \"entry\": {\n          \"description\": \"Entry is not allowed for predefined hooks.\",\n          \"const\": false\n        },\n        \"language\": {\n          \"description\": \"Language must be `system` for predefined hooks (or omitted).\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"system\"\n          ]\n        },\n        \"priority\": {\n          \"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`).\",\n          \"type\": \"integer\",\n          \"minimum\": 0\n        },\n        \"alias\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"description\": \"The pattern of files to run on.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"exclude\": {\n          \"description\": \"Exclude files that were matched by `files`.\\nDefault is `$^`, which matches nothing.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"types\": {\n          \"description\": \"List of file types to run on (AND).\\nDefault is `[file]`, which matches all files.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"types_or\": {\n          \"description\": \"List of file types to run on (OR).\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"exclude_types\": {\n          \"description\": \"List of file types to exclude.\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"additional_dependencies\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"args\": {\n          \"description\": \"Additional arguments to pass to the hook.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"description\": \"Environment variables to set for the hook.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"always_run\": {\n          \"description\": \"This hook will run even if there are no matching files.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"fail_fast\": {\n          \"description\": \"If this hook fails, don't run any more hooks.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"pass_filenames\": {\n          \"description\": \"Append filenames that would be checked to the hook entry as arguments.\\nDefault is true.\",\n          \"$ref\": \"#/definitions/PassFilenames\"\n        },\n        \"description\": {\n          \"description\": \"A description of the hook. For metadata only.\",\n          \"type\": \"string\"\n        },\n        \"language_version\": {\n          \"description\": \"Run the hook on a specific version of the language.\\nDefault is `default`.\\nSee <https://pre-commit.com/#overriding-language-version>.\",\n          \"type\": \"string\"\n        },\n        \"log_file\": {\n          \"description\": \"Write the output of the hook to a file when the hook fails or verbose is enabled.\",\n          \"type\": \"string\"\n        },\n        \"require_serial\": {\n          \"description\": \"This hook will execute using a single process instead of in parallel.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"stages\": {\n          \"description\": \"Select which Git hook stages this hook runs for.\\nDefault all stages are selected.\\nSee <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Stage\"\n          },\n          \"uniqueItems\": true\n        },\n        \"verbose\": {\n          \"description\": \"Print the output of the hook even if it passes.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"minimum_prek_version\": {\n          \"description\": \"The minimum version of prek required to run this hook.\",\n          \"type\": \"string\"\n        }\n      },\n      \"required\": [\n        \"id\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"MetaHooks\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"check-hooks-apply\",\n        \"check-useless-excludes\",\n        \"identity\"\n      ]\n    },\n    \"BuiltinRepo\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"repo\": {\n          \"description\": \"Must be `builtin`.\",\n          \"type\": \"string\",\n          \"const\": \"builtin\"\n        },\n        \"hooks\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/BuiltinHook\"\n          }\n        }\n      },\n      \"required\": [\n        \"repo\",\n        \"hooks\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"BuiltinHook\": {\n      \"description\": \"A builtin hook predefined in prek.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"id\": {\n          \"$ref\": \"#/definitions/BuiltinHooks\"\n        },\n        \"name\": {\n          \"description\": \"Override the name of the hook.\",\n          \"type\": \"string\"\n        },\n        \"entry\": {\n          \"description\": \"Entry is not allowed for predefined hooks.\",\n          \"const\": false\n        },\n        \"language\": {\n          \"description\": \"Language must be `system` for predefined hooks (or omitted).\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"system\"\n          ]\n        },\n        \"priority\": {\n          \"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`).\",\n          \"type\": \"integer\",\n          \"minimum\": 0\n        },\n        \"alias\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"description\": \"The pattern of files to run on.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"exclude\": {\n          \"description\": \"Exclude files that were matched by `files`.\\nDefault is `$^`, which matches nothing.\",\n          \"$ref\": \"#/definitions/FilePattern\"\n        },\n        \"types\": {\n          \"description\": \"List of file types to run on (AND).\\nDefault is `[file]`, which matches all files.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"types_or\": {\n          \"description\": \"List of file types to run on (OR).\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"exclude_types\": {\n          \"description\": \"List of file types to exclude.\\nDefault is `[]`.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"uniqueItems\": true\n        },\n        \"additional_dependencies\": {\n          \"description\": \"Not documented in the official docs.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"args\": {\n          \"description\": \"Additional arguments to pass to the hook.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"description\": \"Environment variables to set for the hook.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"always_run\": {\n          \"description\": \"This hook will run even if there are no matching files.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"fail_fast\": {\n          \"description\": \"If this hook fails, don't run any more hooks.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"pass_filenames\": {\n          \"description\": \"Append filenames that would be checked to the hook entry as arguments.\\nDefault is true.\",\n          \"$ref\": \"#/definitions/PassFilenames\"\n        },\n        \"description\": {\n          \"description\": \"A description of the hook. For metadata only.\",\n          \"type\": \"string\"\n        },\n        \"language_version\": {\n          \"description\": \"Run the hook on a specific version of the language.\\nDefault is `default`.\\nSee <https://pre-commit.com/#overriding-language-version>.\",\n          \"type\": \"string\"\n        },\n        \"log_file\": {\n          \"description\": \"Write the output of the hook to a file when the hook fails or verbose is enabled.\",\n          \"type\": \"string\"\n        },\n        \"require_serial\": {\n          \"description\": \"This hook will execute using a single process instead of in parallel.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"stages\": {\n          \"description\": \"Select which Git hook stages this hook runs for.\\nDefault all stages are selected.\\nSee <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Stage\"\n          },\n          \"uniqueItems\": true\n        },\n        \"verbose\": {\n          \"description\": \"Print the output of the hook even if it passes.\\nDefault is false.\",\n          \"type\": \"boolean\"\n        },\n        \"minimum_prek_version\": {\n          \"description\": \"The minimum version of prek required to run this hook.\",\n          \"type\": \"string\"\n        }\n      },\n      \"required\": [\n        \"id\"\n      ],\n      \"additionalProperties\": true\n    },\n    \"BuiltinHooks\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"check-added-large-files\",\n        \"check-case-conflict\",\n        \"check-executables-have-shebangs\",\n        \"check-json\",\n        \"check-json5\",\n        \"check-merge-conflict\",\n        \"check-symlinks\",\n        \"check-toml\",\n        \"check-xml\",\n        \"check-yaml\",\n        \"detect-private-key\",\n        \"end-of-file-fixer\",\n        \"fix-byte-order-marker\",\n        \"mixed-line-ending\",\n        \"no-commit-to-branch\",\n        \"trailing-whitespace\"\n      ]\n    },\n    \"HookType\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"commit-msg\",\n        \"post-checkout\",\n        \"post-commit\",\n        \"post-merge\",\n        \"post-rewrite\",\n        \"pre-commit\",\n        \"pre-merge-commit\",\n        \"pre-push\",\n        \"pre-rebase\",\n        \"prepare-commit-msg\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"prek\"\nversion = \"0.3.6\"\ndescription = \"Better `pre-commit`, re-engineered in Rust\"\nauthors = [{ name = \"j178\", email = \"hi@j178.dev\" }]\nrequires-python = \">=3.8\"\nkeywords = [\"pre-commit\", \"git\", \"hooks\"]\nreadme = \"README.md\"\nlicense = { file = \"LICENSE\" }\nclassifiers = [\n  \"Development Status :: 4 - Beta\",\n  \"Environment :: Console\",\n  \"Intended Audience :: Developers\",\n  \"Operating System :: OS Independent\",\n  \"License :: OSI Approved :: MIT License\",\n  \"Programming Language :: Rust\",\n  \"Topic :: Software Development :: Quality Assurance\",\n]\n\n[project.urls]\nRepository = \"https://github.com/j178/prek\"\nChangelog = \"https://github.com/j178/prek/blob/master/CHANGELOG.md\"\nReleases = \"https://github.com/j178/prek/releases\"\nHomepage = \"https://prek.j178.dev/\"\n\n[build-system]\nrequires = [\"maturin>=1.0,<2.0\"]\nbuild-backend = \"maturin\"\n\n[tool.maturin]\nbindings = \"bin\"\nmanifest-path = \"crates/prek/Cargo.toml\"\nstrip = true\npython-source = \"python\"\ninclude = [{ path = \"licenses/*\", format = [\"wheel\", \"sdist\"] }]\n\n[tool.rooster]\nversion-format = \"cargo\"\nversion_tag_prefix = \"v\"\nmajor_labels = [] # We do not use the major version number yet\nminor_labels = [\"breaking\"]\nchangelog_ignore_labels = [\"internal\", \"ci\", \"testing\"]\nchangelog_sections.breaking = \"Breaking changes\"\nchangelog_sections.enhancement = \"Enhancements\"\nchangelog_sections.compatibility = \"Enhancements\"\nchangelog_sections.performance = \"Performance\"\nchangelog_sections.bug = \"Bug fixes\"\nchangelog_sections.documentation = \"Documentation\"\nchangelog_sections.__unknown__ = \"Other changes\"\nchangelog_contributors = true\n\nversion_files = [\n  \"pyproject.toml\",\n  # Replace the `workspace.package.version` field in the Cargo.toml\n  { path = \"Cargo.toml\", format = \"cargo\", field = \"workspace.package.version\" },\n  # Bump versions of dependent crates\n  { target = \"Cargo.toml\", match = \"^prek-\", version_format = \"cargo\" },\n  \"README.md\",\n  \"docs/installation.md\",\n  \"docs/integrations.md\",\n]\n\n[tool.uv]\nmanaged = false\n"
  },
  {
    "path": "python/prek/__init__.py",
    "content": ""
  },
  {
    "path": "python/prek/__main__.py",
    "content": "import os\nimport sys\n\nfrom ._find_prek import find_prek_bin\n\ndef _run() -> None:\n    prek = find_prek_bin()\n\n    if sys.platform == \"win32\":\n        import subprocess\n\n        # Avoid emitting a traceback on interrupt\n        try:\n            completed_process = subprocess.run([prek, *sys.argv[1:]])\n        except KeyboardInterrupt:\n            sys.exit(2)\n\n        sys.exit(completed_process.returncode)\n    else:\n        os.execvp(prek, [prek, *sys.argv[1:]])\n\n\nif __name__ == \"__main__\":\n    _run()\n"
  },
  {
    "path": "python/prek/_find_prek.py",
    "content": "# MIT License\n\n# Copyright (c) 2025 Astral Software Inc.\n\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nimport os\nimport sys\nimport sysconfig\n\n\nclass PrekNotFound(FileNotFoundError): ...\n\n\ndef find_prek_bin() -> str:\n    \"\"\"Return the prek binary path.\"\"\"\n\n    prek_exe = \"prek\" + sysconfig.get_config_var(\"EXE\")\n\n    targets = [\n        # The scripts directory for the current Python\n        sysconfig.get_path(\"scripts\"),\n        # The scripts directory for the base prefix\n        sysconfig.get_path(\"scripts\", vars={\"base\": sys.base_prefix}),\n        # Above the package root, e.g., from `pip install --prefix` or `uv run --with`\n        (\n            # On Windows, with module path `<prefix>/Lib/site-packages/prek`\n            _join(_matching_parents(_module_path(), \"Lib/site-packages/prek\"), \"Scripts\")\n            if sys.platform == \"win32\"\n            # On Unix,  with module path `<prefix>/lib/python3.13/site-packages/prek`\n            else _join(\n                _matching_parents(_module_path(), \"lib/python*/site-packages/prek\"), \"bin\"\n            )\n        ),\n        # Adjacent to the package root, e.g., from `pip install --target`\n        # with module path `<target>/prek`\n        _join(_matching_parents(_module_path(), \"prek\"), \"bin\"),\n        # The user scheme scripts directory, e.g., `~/.local/bin`\n        sysconfig.get_path(\"scripts\", scheme=_user_scheme()),\n    ]\n\n    seen = []\n    for target in targets:\n        if not target:\n            continue\n        if target in seen:\n            continue\n        seen.append(target)\n        path = os.path.join(target, prek_exe)\n        if os.path.isfile(path):\n            return path\n\n    locations = \"\\n\".join(f\" - {target}\" for target in seen)\n    raise PrekNotFound(\n        f\"Could not find the prek binary in any of the following locations:\\n{locations}\\n\"\n    )\n\n\ndef _module_path() -> str | None:\n    path = os.path.dirname(__file__)\n    return path\n\n\ndef _matching_parents(path: str | None, match: str) -> str | None:\n    \"\"\"\n    Return the parent directory of `path` after trimming a `match` from the end.\n    The match is expected to contain `/` as a path separator, while the `path`\n    is expected to use the platform's path separator (e.g., `os.sep`). The path\n    components are compared case-insensitively and a `*` wildcard can be used\n    in the `match`.\n    \"\"\"\n    from fnmatch import fnmatch\n\n    if not path:\n        return None\n    parts = path.split(os.sep)\n    match_parts = match.split(\"/\")\n    if len(parts) < len(match_parts):\n        return None\n\n    if not all(\n        fnmatch(part, match_part)\n        for part, match_part in zip(reversed(parts), reversed(match_parts))\n    ):\n        return None\n\n    return os.sep.join(parts[: -len(match_parts)])\n\n\ndef _join(path: str | None, *parts: str) -> str | None:\n    if not path:\n        return None\n    return os.path.join(path, *parts)\n\n\ndef _user_scheme() -> str:\n    if sys.version_info >= (3, 10):\n        user_scheme = sysconfig.get_preferred_scheme(\"user\")\n    elif os.name == \"nt\":\n        user_scheme = \"nt_user\"\n    elif sys.platform == \"darwin\" and sys._framework:  # ty: ignore[unresolved-attribute]\n        user_scheme = \"osx_framework_user\"\n    else:\n        user_scheme = \"posix_user\"\n    return user_scheme\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.94\"\n"
  },
  {
    "path": "scripts/hyperfine-run-benchmarks.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nTARGET_WORKSPACE=${HYPERFINE_BENCHMARK_WORKSPACE:?HYPERFINE_BENCHMARK_WORKSPACE is required}\nCOMMENT=${HYPERFINE_RESULTS_FILE:?HYPERFINE_RESULTS_FILE is required}\nHEAD_BINARY=${HYPERFINE_HEAD_BINARY:?HYPERFINE_HEAD_BINARY is required}\nBASE_BINARY=${HYPERFINE_BASE_BINARY:?HYPERFINE_BASE_BINARY is required}\nOUT_DIR=$(dirname \"$COMMENT\")\nMETA_WORKSPACE=\"${TARGET_WORKSPACE}-meta\"\n\nsection_open=false\nregression_count=0\nimprovement_count=0\n\nmkdir -p \"$OUT_DIR\"\nOUT_MD=\"$OUT_DIR/out.md\"\nOUT_JSON=\"$OUT_DIR/out.json\"\nREPORT_BODY=\"$OUT_DIR/report-body.md\"\n\n: > \"$REPORT_BODY\"\n\nCURRENT_PREK_VERSION=$(\n  \"$HEAD_BINARY\" --version | sed -n '1p'\n)\n\nwrite_line() {\n  printf '%s\\n' \"$1\" >> \"$REPORT_BODY\"\n}\n\nwrite_blank_line() {\n  printf '\\n' >> \"$REPORT_BODY\"\n}\n\nfinalize_report() {\n  : > \"$COMMENT\"\n  printf '### ⚡️ Hyperfine Benchmarks\\n\\n' >> \"$COMMENT\"\n  printf '**Summary:** %s regressions, %s improvements above the 10%% threshold.\\n' \"$regression_count\" \"$improvement_count\" >> \"$COMMENT\"\n  cat \"$REPORT_BODY\" >> \"$COMMENT\"\n}\n\nwrite_section() {\n  local title=\"$1\"\n  local description=\"${2:-}\"\n\n  close_section\n  write_blank_line\n  write_line \"<details>\"\n  write_line \"<summary>$title</summary>\"\n  write_blank_line\n  if [ -n \"$description\" ]; then\n    write_line \"$description\"\n    write_blank_line\n  fi\n  section_open=true\n}\n\nclose_section() {\n  if [ \"$section_open\" = true ]; then\n    write_blank_line\n    write_line \"</details>\"\n    section_open=false\n  fi\n}\n\n# Compare the two commands in out.json (reference vs current).\n# Hyperfine's JSON has results[0] = reference and results[1] = current.\n# A ratio > 1 means current is slower (regression), < 1 means faster (improvement).\ncheck_variance() {\n  local cmd=\"$1\"\n  local num_results\n  num_results=$(jq '.results | length' \"$OUT_JSON\")\n\n  if [ \"$num_results\" -lt 2 ]; then\n    return\n  fi\n\n  local ref_mean current_mean ratio pct\n  ref_mean=$(jq '.results[0].mean' \"$OUT_JSON\")\n  current_mean=$(jq '.results[1].mean' \"$OUT_JSON\")\n  ratio=$(echo \"scale=4; $current_mean / $ref_mean\" | bc)\n  pct=$(echo \"scale=2; ($ratio - 1) * 100\" | bc)\n\n  if (( $(echo \"${pct#-} > 10\" | bc -l) )); then\n    if (( $(echo \"$ratio < 1\" | bc -l) )); then\n      improvement_count=$((improvement_count + 1))\n      write_line \"✅  Performance improvement for \\`$cmd\\`: ${pct#-}% faster\"\n    else\n      regression_count=$((regression_count + 1))\n      write_line \"⚠️  Warning: Performance regression for \\`$cmd\\`: ${pct}% slower\"\n    fi\n  fi\n}\n\nbenchmark() {\n  local cmd=\"$1\"\n  local warmup=\"${2:-3}\"\n  local runs=\"${3:-30}\"\n  local setup=\"${4:-}\"\n  local prepare=\"${5:-}\"\n  local check_change=\"${6:-false}\"\n  local label_suffix=\"${7:-}\"\n  local label=\"prek $cmd\"\n  local -a hyperfine_args=(-i -N -w \"$warmup\" -r \"$runs\" --export-markdown \"$OUT_MD\" --export-json \"$OUT_JSON\")\n\n  if [ -n \"$label_suffix\" ]; then\n    label=\"$label $label_suffix\"\n  fi\n\n  if [ -n \"$setup\" ]; then\n    hyperfine_args+=(--setup \"$setup\")\n  fi\n\n  if [ -n \"$prepare\" ]; then\n    hyperfine_args+=(--prepare \"$prepare\")\n  fi\n\n  write_blank_line\n  write_line \"### \\`$label\\`\"\n  if ! hyperfine \"${hyperfine_args[@]}\" --reference \"$BASE_BINARY $cmd\" \"$HEAD_BINARY $cmd\"; then\n    write_line \"⚠️ Benchmark failed for: $cmd\"\n    return 1\n  fi\n  cat \"$OUT_MD\" >> \"$REPORT_BODY\"\n  write_blank_line\n  if [ \"$check_change\" = \"true\" ]; then\n    check_variance \"$cmd\"\n  fi\n}\n\ncreate_meta_workspace() {\n  rm -rf \"$META_WORKSPACE\"\n  mkdir -p \"$META_WORKSPACE\"\n  cd \"$META_WORKSPACE\"\n  git init || { echo \"Failed to init git for meta hooks\"; exit 1; }\n  git config user.name \"Benchmark\"\n  git config user.email \"bench@prek.dev\"\n\n  cp \"$TARGET_WORKSPACE\"/*.txt \"$TARGET_WORKSPACE\"/*.json . 2>/dev/null || true\n\n  cat > .pre-commit-config.yaml << 'EOF'\nrepos:\n  - repo: meta\n    hooks:\n      - id: check-hooks-apply\n      - id: check-useless-excludes\n      - id: identity\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\nEOF\n\n  git add -A\n  git commit -m \"Meta hooks test\" || { echo \"Failed to commit meta hooks test\"; exit 1; }\n  $HEAD_BINARY install-hooks\n}\n\n# Add environment metadata\nwrite_line \"<details>\"\nwrite_line \"<summary>Environment</summary>\"\nwrite_blank_line\nwrite_line \"- OS: $(uname -s) $(uname -r)\"\nwrite_line \"- CPU: $(nproc) cores\"\nwrite_line \"- prek version: $CURRENT_PREK_VERSION\"\nwrite_line \"- Rust version: $(rustc --version)\"\nwrite_line \"- Hyperfine version: $(hyperfine --version)\"\nwrite_blank_line\nwrite_line \"</details>\"\n\n# Benchmark in the main repo\nwrite_section \"CLI Commands\" \"Benchmarking basic commands in the main repo:\"\n\nCMDS=(\n  \"--version\"\n  \"list\"\n  \"validate-config .pre-commit-config.yaml\"\n  \"sample-config\"\n)\nfor cmd in \"${CMDS[@]}\"; do\n  if [[ \"$cmd\" == \"validate-config\"* ]] && [ ! -f \".pre-commit-config.yaml\" ]; then\n    write_line \"### \\`prek $cmd\\`\"\n    write_line \"⏭️  Skipped: .pre-commit-config.yaml not found\"\n    continue\n  fi\n\n  if [[ \"$cmd\" == \"--version\" ]] || [[ \"$cmd\" == \"list\" ]]; then\n    benchmark \"$cmd\" 5 100\n  else\n    benchmark \"$cmd\" 3 50\n  fi\n  check_variance \"$cmd\"\ndone\n\n# Benchmark builtin hooks in test directory\ncd \"$TARGET_WORKSPACE\"\n\n# Cold vs warm benchmarks before polluting cache\nwrite_section \"Cold vs Warm Runs\" \"Comparing first run (cold) vs subsequent runs (warm cache):\"\nbenchmark \"run --all-files\" 0 10 \"rm -rf ~/.cache/prek\" \"git checkout -- .\" false \"(cold - no cache)\"\nbenchmark \"run --all-files\" 3 20 \"\" \"git checkout -- .\" false \"(warm - with cache)\"\n\n# Full benchmark suite with cache warmed up\nwrite_section \"Full Hook Suite\" \"Running the builtin hook suite on the benchmark workspace:\"\nbenchmark \"run --all-files\" 3 50 \"\" \"git checkout -- .\" true \"(full builtin hook suite)\"\n\n# Individual hook performance\nwrite_section \"Individual Hook Performance\" \"Benchmarking each hook individually on the test repo:\"\n\nINDIVIDUAL_HOOKS=(\n  \"trailing-whitespace\"\n  \"end-of-file-fixer\"\n  \"check-json\"\n  \"check-yaml\"\n  \"check-toml\"\n  \"check-xml\"\n  \"detect-private-key\"\n  \"fix-byte-order-marker\"\n)\n\nfor hook in \"${INDIVIDUAL_HOOKS[@]}\"; do\n  benchmark \"run $hook --all-files\" 3 30 \"\" \"git checkout -- .\"\ndone\n\n# Installation performance\nwrite_section \"Installation Performance\" \"Benchmarking hook installation (fast path hooks skip Python setup):\"\nbenchmark \"install-hooks\" 1 5 \"rm -rf ~/.cache/prek/hooks ~/.cache/prek/repos\" \"\" false \"(cold - no cache)\"\nbenchmark \"install-hooks\" 1 5 \"\" \"\" false \"(warm - with cache)\"\n\n# File filtering/scoping performance\nwrite_section \"File Filtering/Scoping Performance\" \"Testing different file selection modes:\"\n\ngit add -A\nbenchmark \"run\" 3 20 \"\" \"sh -c 'git checkout -- . && git add -A'\" false \"(staged files only)\"\nbenchmark \"run --files '*.json'\" 3 20 \"\" \"\" false \"(specific file type)\"\n\n# Workspace discovery & initialization\nwrite_section \"Workspace Discovery & Initialization\" \"Benchmarking hook discovery and initialization overhead:\"\nbenchmark \"run --dry-run --all-files\" 3 20 \"\" \"\" false \"(measures init overhead)\"\n\n# Meta hooks performance\nwrite_section \"Meta Hooks Performance\" \"Benchmarking meta hooks separately:\"\ncreate_meta_workspace\n\nMETA_HOOKS=(\n  \"check-hooks-apply\"\n  \"check-useless-excludes\"\n  \"identity\"\n)\n\nfor hook in \"${META_HOOKS[@]}\"; do\n  benchmark \"run $hook --all-files\" 3 15 \"\" \"git checkout -- .\"\ndone\n\nclose_section\nfinalize_report\n"
  },
  {
    "path": "scripts/hyperfine-setup-test-env.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nTARGET_WORKSPACE=${HYPERFINE_BENCHMARK_WORKSPACE:?HYPERFINE_BENCHMARK_WORKSPACE is required}\n\n# Create a clean test directory with files to run builtin hooks against\nrm -rf \"$TARGET_WORKSPACE\"\nmkdir -p \"$TARGET_WORKSPACE\"\ncd \"$TARGET_WORKSPACE\"\ngit init || { echo \"Failed to init git\"; exit 1; }\ngit config user.name \"Benchmark\"\ngit config user.email \"bench@prek.dev\"\n\n# Files with trailing whitespace and no final newline\nfor i in {1..50}; do\n  printf \"line with trailing whitespace   \\nanother line  \" > \"file$i.txt\"\ndone\n\n# JSON files\nfor i in {1..30}; do\n  echo '{\"key\": \"value\", \"number\": '$i'}' > \"file$i.json\"\ndone\n\n# YAML files\nfor i in {1..30}; do\n  echo \"key: value\" > \"file$i.yaml\"\n  echo \"number: $i\" >> \"file$i.yaml\"\ndone\n\n# TOML files\nfor i in {1..30}; do\n  echo \"[section]\" > \"file$i.toml\"\n  echo \"key = \\\"value$i\\\"\" >> \"file$i.toml\"\ndone\n\n# XML files\nfor i in {1..30}; do\n  echo '<?xml version=\"1.0\"?><root><item id=\"'$i'\">value</item></root>' > \"file$i.xml\"\ndone\n\n# Files with mixed line endings\nfor i in {1..20}; do\n  printf \"line1\\r\\nline2\\nline3\\r\\n\" > \"mixed$i.txt\"\ndone\n\n# Files with UTF-8 BOM\nfor i in {1..20}; do\n  printf '\\xef\\xbb\\xbfContent with BOM' > \"bom$i.txt\"\ndone\n\n# Executable files (for shebang check)\nfor i in {1..10}; do\n  echo \"#!/bin/bash\" > \"script$i.sh\"\n  echo \"echo hello\" >> \"script$i.sh\"\n  chmod +x \"script$i.sh\"\ndone\n\n# Files that might contain private keys (but don't)\nfor i in {1..10}; do\n  echo \"# This is not a private key\" > \"config$i.txt\"\n  echo \"api_key = fake_key_$i\" >> \"config$i.txt\"\ndone\n\n# Create symlinks for check-symlinks\nfor i in {1..10}; do\n  ln -s \"file$i.txt\" \"link$i.txt\"\ndone\n\n# Create a config that uses all builtin hooks\ncat > .pre-commit-config.yaml << 'EOF'\nrepos:\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-json\n      - id: check-yaml\n      - id: check-toml\n      - id: check-xml\n      - id: mixed-line-ending\n      - id: fix-byte-order-marker\n      - id: check-executables-have-shebangs\n      - id: detect-private-key\n      - id: check-case-conflict\n      - id: check-merge-conflict\n      - id: check-symlinks\nEOF\n\ngit add -A\ngit commit -m \"Initial commit\" || { echo \"Failed to commit\"; exit 1; }\n"
  },
  {
    "path": "scripts/macports/Portfile",
    "content": "# -*- 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\n\nPortSystem          1.0\nPortGroup           cargo   1.0\nPortGroup           github  1.0\n\ngithub.setup        j178 prek 0.3.5 v\ngithub.tarball_from archive\nrevision            0\n\ndescription         Better `pre-commit`, re-engineered in Rust\nlong_description    {*}${description}\n\ncategories          devel\ninstalls_libs       no\nlicense             MIT\nmaintainers         {@j178 j178.dev:hi} openmaintainer\nhomepage            https://prek.j178.dev\n\nchecksums           ${distname}${extract.suffix} \\\n                    rmd160  953c5729f50d8e57a4011fca43978abcc2308849 \\\n                    sha256  3d0bf93af3591762b2fce97965fb88f8dc4b750164451162f57f866e26e4bb67 \\\n                    size    524673\n\npost-build {\n    # Generate shell completions for supported shells\n    set prek_bin ${worksrcpath}/target/[cargo.rust_platform]/release/${name}\n    foreach shell {zsh bash fish} {\n        system -W ${worksrcpath} \"COMPLETE=${shell} ${prek_bin} > ${name}.${shell}\"\n    }\n}\n\ndestroot {\n    set bindir ${worksrcpath}/target/[cargo.rust_platform]/release\n    xinstall -m 0755 ${bindir}/${name} ${destroot}${prefix}/bin/\n\n    set zsh_comp_path ${destroot}${prefix}/share/zsh/site-functions\n    xinstall -d ${zsh_comp_path}\n    xinstall -m 0644 ${worksrcpath}/${name}.zsh ${zsh_comp_path}/_${name}\n\n    set bash_comp_path ${destroot}${prefix}/share/bash-completion/completions\n    xinstall -d ${bash_comp_path}\n    xinstall -m 0644 ${worksrcpath}/${name}.bash ${bash_comp_path}/${name}\n\n    set fish_comp_path ${destroot}${prefix}/share/fish/vendor_completions.d\n    xinstall -d ${fish_comp_path}\n    xinstall -m 0644 ${worksrcpath}/${name}.fish ${fish_comp_path}\n}\n\nbuild.args-append   -p prek\n\ncargo.crates \\\n    addr2line                       0.25.1  1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b \\\n    adler2                           2.0.1  320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa \\\n    ahash                           0.8.12  5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75 \\\n    aho-corasick                     1.1.4  ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301 \\\n    aligned-vec                      0.6.4  dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b \\\n    annotate-snippets              0.12.12  c86cd1c51b95d71dde52bca69ed225008f6ff4c8cc825b08042aa1ef823e1980 \\\n    anstream                        0.6.21  43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a \\\n    anstyle                         1.0.13  5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78 \\\n    anstyle-parse                    0.2.7  4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2 \\\n    anstyle-query                    1.1.5  40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc \\\n    anstyle-wincon                  3.0.11  291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d \\\n    anyhow                         1.0.102  7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c \\\n    ar_archive_writer                0.5.1  7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b \\\n    arraydeque                       0.5.1  7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236 \\\n    arrayvec                         0.7.6  7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50 \\\n    assert_cmd                       2.1.2  9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514 \\\n    assert_fs                        1.1.3  a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9 \\\n    astral-tokio-tar                 0.5.6  ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5 \\\n    astral_async_zip                0.0.17  ab72a761e6085828cc8f0e05ed332b2554701368c5dc54de551bfaec466518ba \\\n    async-compression               0.4.41  d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1 \\\n    atomic-waker                     1.1.2  1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0 \\\n    autocfg                          1.5.0  c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8 \\\n    aws-lc-rs                       1.16.1  94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf \\\n    aws-lc-sys                      0.38.0  4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e \\\n    axoasset                         2.0.1  1be1b9c2739b635e04c7bbcde9e89dd5e874b9e86e28f1b41c44eb830635d83e \\\n    axoprocess                       0.2.1  8a4b4798a6c02e91378537c63cd6e91726900b595450daa5d487bc3c11e95e1b \\\n    axotag                           0.3.0  dc923121fbc4cc72e9008436b5650b98e56f94b5799df59a1b4f572b5c6a7e6b \\\n    axoupdater                      0.10.0  0ab66f118bab79524a27139b7341cdf1c4f839c6274ef89a6d8fb4365cb218cf \\\n    backtrace                       0.3.76  bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6 \\\n    base64                          0.22.1  72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6 \\\n    bit-set                          0.8.0  08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3 \\\n    bit-vec                          0.8.0  5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7 \\\n    bitflags                         1.3.2  bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a \\\n    bitflags                        2.11.0  843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af \\\n    block2                           0.6.2  cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5 \\\n    bstr                            1.12.1  63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab \\\n    bumpalo                         3.20.2  5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb \\\n    bytemuck                        1.25.0  c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec \\\n    byteorder-lite                   0.1.0  8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495 \\\n    bytes                           1.11.1  1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33 \\\n    camino                           1.2.2  e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48 \\\n    cargo-platform                   0.3.2  87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082 \\\n    cargo_metadata                  0.23.1  ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9 \\\n    cc                              1.2.56  aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2 \\\n    cesu8                            1.1.0  6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c \\\n    cfg-if                           1.0.4  9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801 \\\n    cfg_aliases                      0.2.1  613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724 \\\n    chacha20                        0.10.0  6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601 \\\n    clap                            4.5.60  2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a \\\n    clap_builder                    4.5.60  24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876 \\\n    clap_complete                   4.5.66  c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031 \\\n    clap_derive                     4.5.55  a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5 \\\n    clap_lex                         1.0.0  3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831 \\\n    cmake                           0.1.57  75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d \\\n    colorchoice                      1.0.4  b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75 \\\n    combine                          4.6.7  ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd \\\n    compression-codecs              0.4.37  eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7 \\\n    compression-core                0.4.31  75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d \\\n    console                        0.15.11  054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8 \\\n    console                         0.16.2  03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4 \\\n    core-foundation                  0.9.4  91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f \\\n    core-foundation                 0.10.1  b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6 \\\n    core-foundation-sys              0.8.7  773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b \\\n    cpp_demangle                     0.5.1  0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def \\\n    cpufeatures                      0.3.0  8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201 \\\n    crc32fast                        1.5.0  9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511 \\\n    crossbeam-deque                  0.8.6  9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51 \\\n    crossbeam-epoch                 0.9.18  5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e \\\n    crossbeam-utils                 0.8.21  d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28 \\\n    ctrlc                            3.5.2  e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162 \\\n    debugid                          0.8.0  bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d \\\n    diff                            0.1.13  56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8 \\\n    difflib                          0.4.0  6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8 \\\n    dispatch2                        0.3.1  1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38 \\\n    displaydoc                       0.2.5  97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0 \\\n    doc-comment                      0.3.4  780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9 \\\n    dunce                            1.0.5  92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813 \\\n    dyn-clone                       1.0.20  d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555 \\\n    either                          1.15.0  48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719 \\\n    encode_unicode                   1.0.0  34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0 \\\n    encoding_rs                     0.8.35  75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3 \\\n    encoding_rs_io                   0.1.7  1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83 \\\n    env_home                         0.1.0  c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe \\\n    equator                          0.4.2  4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc \\\n    equator-macro                    0.4.2  44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3 \\\n    equivalent                       1.0.2  877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f \\\n    errno                           0.3.14  39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb \\\n    etcetera                        0.11.0  de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96 \\\n    fancy-regex                     0.17.0  72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8 \\\n    fastrand                         2.3.0  37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be \\\n    filetime                        0.2.27  f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db \\\n    find-msvc-tools                  0.1.9  5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582 \\\n    findshlibs                      0.10.2  40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64 \\\n    flate2                           1.1.9  843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c \\\n    float-cmp                       0.10.0  b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8 \\\n    fnv                              1.0.7  3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1 \\\n    foldhash                         0.1.5  d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2 \\\n    foldhash                         0.2.0  77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb \\\n    form_urlencoded                  1.2.2  cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf \\\n    fs-err                           3.3.0  73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0 \\\n    fs_extra                         1.3.0  42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c \\\n    futures                         0.3.32  8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d \\\n    futures-channel                 0.3.32  07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d \\\n    futures-core                    0.3.32  7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d \\\n    futures-executor                0.3.32  baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d \\\n    futures-io                      0.3.32  cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718 \\\n    futures-lite                     2.6.1  f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad \\\n    futures-macro                   0.3.32  e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b \\\n    futures-sink                    0.3.32  c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893 \\\n    futures-task                    0.3.32  037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393 \\\n    futures-util                    0.3.32  389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6 \\\n    getrandom                       0.2.17  ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0 \\\n    getrandom                        0.3.4  899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd \\\n    getrandom                        0.4.2  0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555 \\\n    gimli                           0.32.3  e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7 \\\n    globset                         0.4.18  52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3 \\\n    globwalk                         0.9.1  0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757 \\\n    h2                              0.4.13  2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54 \\\n    hashbrown                       0.15.5  9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1 \\\n    hashbrown                       0.16.1  841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100 \\\n    heck                             0.5.0  2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea \\\n    hermit-abi                       0.5.2  fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c \\\n    hex                              0.4.3  7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70 \\\n    homedir                          0.3.6  68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527 \\\n    http                             1.4.0  e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a \\\n    http-body                        1.0.1  1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184 \\\n    http-body-util                   0.1.3  b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a \\\n    httparse                        1.10.1  6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87 \\\n    hyper                            1.8.1  2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11 \\\n    hyper-rustls                    0.27.7  e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58 \\\n    hyper-util                      0.1.20  96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0 \\\n    icu_collections                  2.1.1  4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43 \\\n    icu_locale_core                  2.1.1  edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6 \\\n    icu_normalizer                   2.1.1  5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599 \\\n    icu_normalizer_data              2.1.1  7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a \\\n    icu_properties                   2.1.2  020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec \\\n    icu_properties_data              2.1.2  616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af \\\n    icu_provider                     2.1.1  85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614 \\\n    id-arena                         2.3.0  3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954 \\\n    idna                             1.1.0  3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de \\\n    idna_adapter                     1.2.1  3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344 \\\n    ignore                          0.4.25  d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a \\\n    image                           0.25.9  e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a \\\n    indexmap                        2.13.0  7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017 \\\n    indicatif                       0.18.4  25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb \\\n    indoc                            2.0.7  79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706 \\\n    inferno                        0.11.21  232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88 \\\n    insta                           1.46.3  e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4 \\\n    insta-cmd                        0.6.0  ffeeefa927925cced49ccb01bf3e57c9d4cd132df21e576eb9415baeab2d3de6 \\\n    ipnet                           2.12.0  d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2 \\\n    iri-string                      0.7.10  c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a \\\n    is-terminal                     0.4.17  3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46 \\\n    is_executable                    1.0.5  baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4 \\\n    is_terminal_polyfill            1.70.2  a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695 \\\n    itertools                       0.14.0  2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285 \\\n    itoa                            1.0.17  92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2 \\\n    jni                             0.21.1  1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97 \\\n    jni-sys                          0.3.0  8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130 \\\n    jobserver                       0.1.34  9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33 \\\n    js-sys                          0.3.91  b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c \\\n    json5                            1.3.1  733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c \\\n    lazy-regex                       3.6.0  6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496 \\\n    lazy-regex-proc_macros           3.6.0  4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358 \\\n    lazy_static                      1.5.0  bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe \\\n    leb128fmt                        0.1.0  09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2 \\\n    levenshtein                      1.0.5  db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760 \\\n    libc                           0.2.182  6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112 \\\n    liblzma                          0.4.6  b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899 \\\n    liblzma-sys                      0.4.5  9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186 \\\n    libredox                        0.1.14  1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a \\\n    linux-raw-sys                   0.12.1  32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53 \\\n    litemap                          0.8.1  6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77 \\\n    lock_api                        0.4.14  224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965 \\\n    log                             0.4.29  5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897 \\\n    lru-slab                         0.1.2  112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154 \\\n    markdown                         1.0.0  a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb \\\n    matchers                         0.2.0  d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9 \\\n    mea                              0.6.3  6747f54621d156e1b47eb6b25f39a941b9fc347f98f67d25d8881ff99e8ed832 \\\n    memchr                           2.8.0  f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79 \\\n    memmap2                         0.9.10  714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3 \\\n    miette                           7.6.0  5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7 \\\n    miette-derive                    7.6.0  db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b \\\n    mime                            0.3.17  6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a \\\n    miniz_oxide                      0.8.9  1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316 \\\n    mio                              1.1.1  a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc \\\n    moxcms                          0.7.11  ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97 \\\n    nix                             0.26.4  598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b \\\n    nix                             0.30.1  74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6 \\\n    nix                             0.31.2  5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3 \\\n    nohash-hasher                    0.2.0  2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451 \\\n    normalize-line-endings           0.3.0  61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be \\\n    nu-ansi-term                    0.50.3  7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5 \\\n    num-format                       0.4.4  a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3 \\\n    num-traits                      0.2.19  071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841 \\\n    objc2                            0.6.4  3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f \\\n    objc2-encode                     4.1.0  ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33 \\\n    object                          0.37.3  ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe \\\n    once_cell                       1.21.3  42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d \\\n    once_cell_polyfill              1.70.2  384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe \\\n    openssl-probe                    0.2.1  7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe \\\n    owo-colors                       4.3.0  d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d \\\n    parking                          2.2.1  f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba \\\n    path-clean                       1.0.1  17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef \\\n    percent-encoding                 2.3.2  9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220 \\\n    phf                             0.13.1  c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf \\\n    phf_generator                   0.13.1  135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737 \\\n    phf_macros                      0.13.1  812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef \\\n    phf_shared                      0.13.1  e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266 \\\n    pin-project                     1.1.11  f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517 \\\n    pin-project-internal            1.1.11  d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6 \\\n    pin-project-lite                0.2.17  a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd \\\n    pin-utils                        0.1.0  8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184 \\\n    pkg-config                      0.3.32  7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c \\\n    plain                            0.2.3  b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6 \\\n    portable-atomic                 1.13.1  c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49 \\\n    potential_utf                    0.1.4  b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77 \\\n    pprof                           0.15.0  38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187 \\\n    ppv-lite86                      0.2.21  85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9 \\\n    predicates                       3.1.4  ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe \\\n    predicates-core                 1.0.10  cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144 \\\n    predicates-tree                 1.0.13  d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2 \\\n    pretty_assertions                1.4.1  3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d \\\n    prettyplease                    0.2.37  479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b \\\n    proc-macro2                    1.0.106  8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934 \\\n    psm                             0.1.30  3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8 \\\n    pxfm                            0.1.28  b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d \\\n    quick-xml                       0.26.0  7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd \\\n    quick-xml                       0.39.2  958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d \\\n    quinn                           0.11.9  b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20 \\\n    quinn-proto                    0.11.13  f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31 \\\n    quinn-udp                       0.5.14  addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd \\\n    quote                           1.0.45  41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924 \\\n    r-efi                            5.3.0  69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f \\\n    r-efi                            6.0.0  f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf \\\n    rand                             0.9.2  6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1 \\\n    rand                            0.10.0  bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8 \\\n    rand_chacha                      0.9.0  d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb \\\n    rand_core                        0.9.5  76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c \\\n    rand_core                       0.10.0  0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba \\\n    rayon                           1.11.0  368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f \\\n    rayon-core                      1.13.0  22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91 \\\n    redox_syscall                    0.7.3  6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16 \\\n    ref-cast                        1.0.25  f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d \\\n    ref-cast-impl                   1.0.25  b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da \\\n    regex                           1.12.3  e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276 \\\n    regex-automata                  0.4.14  6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f \\\n    regex-syntax                    0.8.10  dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a \\\n    reqwest                         0.13.2  ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801 \\\n    rgb                             0.8.53  47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4 \\\n    ring                           0.17.14  a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7 \\\n    rustc-demangle                  0.1.27  b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d \\\n    rustc-hash                       2.1.1  357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d \\\n    rustix                           1.1.4  b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190 \\\n    rustls                         0.23.37  758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4 \\\n    rustls-native-certs              0.8.3  612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63 \\\n    rustls-pki-types                1.14.0  be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd \\\n    rustls-platform-verifier         0.6.2  1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784 \\\n    rustls-platform-verifier-android     0.1.1  f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f \\\n    rustls-webpki                  0.103.9  d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53 \\\n    rustversion                     1.0.22  b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d \\\n    same-file                        1.0.6  93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502 \\\n    saphyr-parser-bw               0.0.608  d55ae5ea09894b6d5382621db78f586df37ef18ab581bf32c754e75076b124b1 \\\n    schannel                        0.1.28  891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1 \\\n    schemars                         1.2.1  a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc \\\n    schemars_derive                  1.2.1  7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f \\\n    scopeguard                       1.2.0  94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49 \\\n    security-framework               3.7.0  b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d \\\n    security-framework-sys          2.17.0  6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3 \\\n    self-replace                     1.5.0  03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7 \\\n    semver                          1.0.27  d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2 \\\n    serde                          1.0.228  9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e \\\n    serde-saphyr                    0.0.20  bfcaa44cda9e21eaf5fefc86175d544a359d4de9bcd1f3a90be7bbf77dfc3492 \\\n    serde_core                     1.0.228  41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad \\\n    serde_derive                   1.0.228  d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79 \\\n    serde_derive_internals          0.29.1  18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711 \\\n    serde_json                     1.0.149  83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86 \\\n    serde_spanned                    1.0.4  f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776 \\\n    serde_stacker                   0.1.14  d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a \\\n    sharded-slab                     0.1.7  f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6 \\\n    shlex                            1.3.0  0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64 \\\n    signal-hook-registry             1.4.8  c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b \\\n    simd-adler32                     0.3.8  e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2 \\\n    similar                          2.7.0  bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa \\\n    siphasher                        1.0.2  b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e \\\n    slab                            0.4.12  0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5 \\\n    smallvec                        1.15.1  67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03 \\\n    smawk                            0.3.2  b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c \\\n    socket2                          0.6.2  86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0 \\\n    spin                            0.10.0  d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591 \\\n    stable_deref_trait               1.2.1  6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596 \\\n    stacker                         0.1.23  08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013 \\\n    str_stack                        0.1.0  9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb \\\n    strsim                          0.11.1  7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f \\\n    strum                           0.28.0  9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd \\\n    strum_macros                    0.28.0  ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664 \\\n    subtle                           2.6.1  13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292 \\\n    symbolic-common                12.17.2  751a2823d606b5d0a7616499e4130a516ebd01a44f39811be2b9600936509c23 \\\n    symbolic-demangle              12.17.2  79b237cfbe320601dd24b4ac817a5b68bb28f5508e33f08d42be0682cadc8ac9 \\\n    syn                            2.0.117  e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99 \\\n    sync_wrapper                     1.0.2  0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263 \\\n    synstructure                    0.13.2  728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2 \\\n    system-configuration             0.7.0  a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b \\\n    system-configuration-sys         0.6.0  8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4 \\\n    target-lexicon                  0.13.5  adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca \\\n    tempfile                        3.26.0  82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0 \\\n    terminal_size                    0.4.3  60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0 \\\n    termtree                         0.5.1  8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683 \\\n    textwrap                        0.16.2  c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057 \\\n    thiserror                       1.0.69  b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52 \\\n    thiserror                       2.0.18  4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4 \\\n    thiserror-impl                  1.0.69  4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1 \\\n    thiserror-impl                  2.0.18  ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5 \\\n    thread_local                     1.1.9  f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185 \\\n    tinystr                          0.8.2  42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869 \\\n    tinyvec                         1.10.0  bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa \\\n    tinyvec_macros                   0.1.1  1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20 \\\n    tokio                           1.50.0  27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d \\\n    tokio-macros                     2.6.1  5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c \\\n    tokio-rustls                    0.26.4  1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61 \\\n    tokio-stream                    0.1.18  32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70 \\\n    tokio-util                      0.7.18  9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098 \\\n    toml                          1.0.3+spec-1.1.0  c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c \\\n    toml_datetime                 1.0.0+spec-1.1.0  32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e \\\n    toml_edit                     0.25.3+spec-1.1.0  a0a07913e63758bc95142d9863a5a45173b71515e68b690cad70cf99c3255ce1 \\\n    toml_parser                   1.0.9+spec-1.1.0  702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4 \\\n    toml_writer                   1.0.6+spec-1.1.0  ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607 \\\n    tower                            0.5.3  ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4 \\\n    tower-http                       0.6.8  d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8 \\\n    tower-layer                      0.3.3  121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e \\\n    tower-service                    0.3.3  8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3 \\\n    tracing                         0.1.44  63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100 \\\n    tracing-attributes              0.1.31  7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da \\\n    tracing-core                    0.1.36  db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a \\\n    tracing-log                      0.2.0  ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3 \\\n    tracing-subscriber              0.3.22  2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e \\\n    try-lock                         0.2.5  e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b \\\n    ucd-trie                         0.1.7  2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971 \\\n    unicode-id                       0.3.6  70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580 \\\n    unicode-ident                   1.0.24  e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75 \\\n    unicode-linebreak                0.1.5  3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f \\\n    unicode-width                   0.1.14  7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af \\\n    unicode-width                    0.2.2  b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254 \\\n    unicode-xid                      0.2.6  ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853 \\\n    unit-prefix                      0.5.2  81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3 \\\n    untrusted                        0.9.0  8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1 \\\n    url                              2.5.8  ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed \\\n    utf8_iter                        1.0.4  b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be \\\n    utf8parse                        0.2.2  06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821 \\\n    uuid                            1.21.0  b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb \\\n    valuable                         0.1.1  ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65 \\\n    version_check                    0.9.5  0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a \\\n    wait-timeout                     0.2.1  09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11 \\\n    walkdir                          2.5.0  29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b \\\n    want                             0.3.1  bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e \\\n    wasi                          0.11.1+wasi-snapshot-preview1  ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b \\\n    wasip2                        1.0.2+wasi-0.2.9  9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5 \\\n    wasip3                        0.4.0+wasi-0.3.0-rc-2026-01-06  5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5 \\\n    wasm-bindgen                   0.2.114  6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e \\\n    wasm-bindgen-futures            0.4.64  e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8 \\\n    wasm-bindgen-macro             0.2.114  18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6 \\\n    wasm-bindgen-macro-support     0.2.114  03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3 \\\n    wasm-bindgen-shared            0.2.114  75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16 \\\n    wasm-encoder                   0.244.0  990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319 \\\n    wasm-metadata                  0.244.0  bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909 \\\n    wasm-streams                     0.5.0  9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb \\\n    wasmparser                     0.244.0  47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe \\\n    web-sys                         0.3.91  854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9 \\\n    web-time                         1.1.0  5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb \\\n    webpki-root-certs                1.0.6  804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca \\\n    which                            8.0.0  d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d \\\n    widestring                       1.2.1  72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471 \\\n    winapi                           0.3.9  5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419 \\\n    winapi-i686-pc-windows-gnu       0.4.0  ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6 \\\n    winapi-util                     0.1.11  c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22 \\\n    winapi-x86_64-pc-windows-gnu     0.4.0  712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f \\\n    windows                         0.61.3  9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893 \\\n    windows-collections              0.2.0  3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8 \\\n    windows-core                    0.61.2  c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3 \\\n    windows-future                   0.2.1  fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e \\\n    windows-implement               0.60.2  053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf \\\n    windows-interface               0.59.3  3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358 \\\n    windows-link                     0.1.3  5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a \\\n    windows-link                     0.2.1  f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5 \\\n    windows-numerics                 0.2.0  9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1 \\\n    windows-registry                 0.6.1  02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720 \\\n    windows-result                   0.3.4  56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6 \\\n    windows-result                   0.4.1  7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5 \\\n    windows-strings                  0.4.2  56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57 \\\n    windows-strings                  0.5.1  7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091 \\\n    windows-sys                     0.45.0  75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0 \\\n    windows-sys                     0.52.0  282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d \\\n    windows-sys                     0.59.0  1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b \\\n    windows-sys                     0.60.2  f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb \\\n    windows-sys                     0.61.2  ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc \\\n    windows-targets                 0.42.2  8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071 \\\n    windows-targets                 0.52.6  9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973 \\\n    windows-targets                 0.53.5  4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3 \\\n    windows-threading                0.1.0  b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6 \\\n    windows_aarch64_gnullvm         0.42.2  597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8 \\\n    windows_aarch64_gnullvm         0.52.6  32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3 \\\n    windows_aarch64_gnullvm         0.53.1  a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53 \\\n    windows_aarch64_msvc            0.42.2  e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43 \\\n    windows_aarch64_msvc            0.52.6  09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469 \\\n    windows_aarch64_msvc            0.53.1  b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006 \\\n    windows_i686_gnu                0.42.2  c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f \\\n    windows_i686_gnu                0.52.6  8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b \\\n    windows_i686_gnu                0.53.1  960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3 \\\n    windows_i686_gnullvm            0.52.6  0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66 \\\n    windows_i686_gnullvm            0.53.1  fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c \\\n    windows_i686_msvc               0.42.2  44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060 \\\n    windows_i686_msvc               0.52.6  240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66 \\\n    windows_i686_msvc               0.53.1  1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2 \\\n    windows_x86_64_gnu              0.42.2  8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36 \\\n    windows_x86_64_gnu              0.52.6  147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78 \\\n    windows_x86_64_gnu              0.53.1  9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499 \\\n    windows_x86_64_gnullvm          0.42.2  26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3 \\\n    windows_x86_64_gnullvm          0.52.6  24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d \\\n    windows_x86_64_gnullvm          0.53.1  0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1 \\\n    windows_x86_64_msvc             0.42.2  9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0 \\\n    windows_x86_64_msvc             0.52.6  589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec \\\n    windows_x86_64_msvc             0.53.1  d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650 \\\n    winnow                          0.7.14  5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829 \\\n    winsafe                         0.0.19  d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904 \\\n    wit-bindgen                     0.51.0  d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5 \\\n    wit-bindgen-core                0.51.0  ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc \\\n    wit-bindgen-rust                0.51.0  b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21 \\\n    wit-bindgen-rust-macro          0.51.0  0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a \\\n    wit-component                  0.244.0  9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2 \\\n    wit-parser                     0.244.0  ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736 \\\n    writeable                        0.6.2  9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9 \\\n    xattr                            1.6.1  32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156 \\\n    yansi                            1.0.1  cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049 \\\n    yoke                             0.8.1  72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954 \\\n    yoke-derive                      0.8.1  b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d \\\n    zerocopy                        0.8.40  a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5 \\\n    zerocopy-derive                 0.8.40  f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953 \\\n    zerofrom                         0.1.6  50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5 \\\n    zerofrom-derive                  0.1.6  d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502 \\\n    zeroize                          1.8.2  b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0 \\\n    zerotrie                         0.2.3  2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851 \\\n    zerovec                         0.11.5  6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002 \\\n    zerovec-derive                  0.11.2  eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3 \\\n    zmij                            1.0.21  b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa\n"
  },
  {
    "path": "scripts/update-macports-portfile.py",
    "content": "# /// script\n# requires-python = \">=3.14\"\n# dependencies = [\n#     \"httpx>=0.28.1\",\n# ]\n# ///\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimport httpx\n\n\ndef run(cmd: list[str], *, capture: bool = False) -> str:\n    result = subprocess.run(\n        cmd,\n        check=True,\n        text=True,\n        capture_output=capture,\n    )\n    if capture:\n        return result.stdout.strip()\n    return \"\"\n\n\ndef repo_root() -> Path:\n    root = run([\"git\", \"rev-parse\", \"--show-toplevel\"], capture=True)\n    return Path(root)\n\n\ndef read_version(cargo_toml: Path) -> str:\n    content = cargo_toml.read_text(encoding=\"utf-8\")\n    match = re.search(r'^version\\s*=\\s*\"([^\"]+)\"', content, flags=re.MULTILINE)\n    if not match:\n        raise RuntimeError(f\"Failed to read version from {cargo_toml}\")\n    return match.group(1)\n\n\ndef replace_github_setup_version(portfile_text: str, version: str) -> str:\n    updated = re.sub(\n        r'^(github\\.setup\\s+\\S+\\s+\\S+\\s+)\\S+',\n        rf'\\g<1>{version}',\n        portfile_text,\n        count=1,\n        flags=re.MULTILINE,\n    )\n    if updated == portfile_text:\n        raise RuntimeError(\"Could not locate github.setup line in Portfile\")\n    return updated\n\n\ndef download_distfile(version: str) -> Path:\n    distfile = Path(f\"/tmp/prek-v{version}.tar.gz\")\n    url = f\"https://github.com/j178/prek/archive/v{version}.tar.gz\"\n    with httpx.Client(follow_redirects=True, timeout=60.0) as client:\n        response = client.get(url)\n        response.raise_for_status()\n        distfile.write_bytes(response.content)\n    return distfile\n\n\ndef openssl_digest(algorithm: str, file_path: Path) -> str:\n    out = run([\"openssl\", \"dgst\", f\"-{algorithm}\", str(file_path)], capture=True)\n    if \"= \" not in out:\n        raise RuntimeError(f\"Unexpected openssl output: {out}\")\n    return out.split(\"= \", 1)[1].strip()\n\n\ndef update_checksums_block(portfile_text: str, rmd160: str, sha256: str, size: int) -> str:\n    updated = re.sub(\n        r\"(^\\s*rmd160\\s+)\\S+(\\s*\\\\\\s*$)\",\n        rf\"\\g<1>{rmd160}\\g<2>\",\n        portfile_text,\n        count=1,\n        flags=re.MULTILINE,\n    )\n    updated = re.sub(\n        r\"(^\\s*sha256\\s+)\\S+(\\s*\\\\\\s*$)\",\n        rf\"\\g<1>{sha256}\\g<2>\",\n        updated,\n        count=1,\n        flags=re.MULTILINE,\n    )\n    updated = re.sub(\n        r\"(^\\s*size\\s+)\\d+(\\s*$)\",\n        rf\"\\g<1>{size}\\g<2>\",\n        updated,\n        count=1,\n        flags=re.MULTILINE,\n    )\n\n    if updated == portfile_text or \"rmd160\" not in updated or \"sha256\" not in updated:\n        raise RuntimeError(\"Could not locate checksum lines in Portfile\")\n    return updated\n\n\ndef ensure_cargo2port() -> None:\n    if shutil.which(\"cargo2port\"):\n        return\n    run(\n        [\n            \"cargo\",\n            \"install\",\n            \"--locked\",\n            \"--git\",\n            \"https://github.com/l2dy/cargo2port\",\n            \"cargo2port\",\n        ]\n    )\n\n\ndef generated_cargo_crates(cargo_lock: Path) -> str:\n    return run([\"cargo2port\", str(cargo_lock)], capture=True)\n\n\ndef replace_cargo_crates_block(portfile_text: str, crates_block: str) -> str:\n    marker = \"cargo.crates\"\n    idx = portfile_text.find(marker)\n    if idx == -1:\n        raise RuntimeError(\"Could not locate cargo.crates block in Portfile\")\n    prefix = portfile_text[:idx]\n    return prefix + crates_block.rstrip() + \"\\n\"\n\n\ndef main() -> None:\n    root = repo_root()\n    default_portfile = root / \"scripts\" / \"macports\" / \"Portfile\"\n    portfile = Path(os.environ.get(\"PORTFILE\", str(default_portfile)))\n\n    if not portfile.is_file():\n        raise RuntimeError(f\"Portfile not found at {portfile}\")\n\n    version = read_version(root / \"Cargo.toml\")\n\n    text = portfile.read_text(encoding=\"utf-8\")\n    text = replace_github_setup_version(text, version)\n\n    distfile = download_distfile(version)\n    rmd160 = openssl_digest(\"rmd160\", distfile)\n    sha256 = openssl_digest(\"sha256\", distfile)\n    size = distfile.stat().st_size\n\n    text = update_checksums_block(text, rmd160, sha256, size)\n\n    ensure_cargo2port()\n    crates_block = generated_cargo_crates(root / \"Cargo.lock\")\n    text = replace_cargo_crates_block(text, crates_block)\n\n    portfile.write_text(text, encoding=\"utf-8\")\n    print(f\"Updated {portfile} for version {version}\")\n    print(\"To open a PR with the updated Portfile, run:\")\n    print(f\"  git clone --depth=1 --branch=main https://github.com/macports/macports-ports.git /tmp/macports-ports\")\n    print(f\"  cp {portfile} /tmp/macports-ports/devel/prek/Portfile\")\n    print(f\"  cd /tmp/macports-ports\")\n    print(f\"  git add devel/prek/Portfile\")\n    print(f\"  git commit -m 'prek: update to {version}'\")\n    print(f\"  gh pr create --title 'prek: update to {version}'\")\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except Exception as exc:\n        print(f\"error: {exc}\", file=sys.stderr)\n        raise SystemExit(1)\n"
  }
]