├── .github ├── renovate.json5 └── workflows │ ├── build-binaries.yml │ ├── ci.yml │ ├── publish.yml │ ├── release.yml │ └── setup-dev-drive.ps1 ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── DIFF.md ├── LICENSE ├── README.md ├── build.rs ├── clippy.toml ├── dist-workspace.toml ├── lib └── constants │ ├── Cargo.toml │ └── src │ ├── env_vars.rs │ └── lib.rs ├── licenses ├── LICENSE.identify.txt └── LICENSE.pre-commit.txt ├── pyproject.toml ├── rust-toolchain.toml ├── scripts └── release.sh ├── src ├── archive.rs ├── builtin │ ├── meta_hooks.rs │ ├── mod.rs │ └── pre_commit_hooks │ │ ├── check_added_large_files.rs │ │ ├── fix_trailing_whitespace.rs │ │ └── mod.rs ├── cleanup.rs ├── cli │ ├── clean.rs │ ├── hook_impl.rs │ ├── install.rs │ ├── mod.rs │ ├── reporter.rs │ ├── run │ │ ├── filter.rs │ │ ├── keeper.rs │ │ ├── mod.rs │ │ └── run.rs │ ├── sample_config.rs │ ├── self_update.rs │ └── validate.rs ├── config.rs ├── fs.rs ├── git.rs ├── hook.rs ├── identify.rs ├── languages │ ├── docker.rs │ ├── docker_image.rs │ ├── fail.rs │ ├── mod.rs │ ├── node │ │ ├── installer.rs │ │ ├── mod.rs │ │ └── node.rs │ ├── python │ │ ├── mod.rs │ │ ├── python.rs │ │ └── uv.rs │ └── system.rs ├── main.rs ├── printer.rs ├── process.rs ├── profiler.rs ├── run.rs ├── snapshots │ ├── prefligit__config__tests__read_config.snap │ └── prefligit__config__tests__read_manifest.snap ├── store.rs ├── version.rs └── warnings.rs ├── tests ├── clean.rs ├── common │ └── mod.rs ├── files │ ├── node-hooks.yaml │ ├── uv-pre-commit-config.yaml │ └── uv-pre-commit-hooks.yaml ├── hook_impl.rs ├── install.rs ├── languages │ ├── docker.rs │ ├── docker_image.rs │ ├── fail.rs │ └── main.rs ├── run.rs ├── sample_config.rs └── validate.rs └── typos.toml /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:monthly", 6 | ], 7 | // release.yml is generated by cargo-dist and should not be updated. 8 | "ignorePaths": [ ".github/workflows/release.yml" ], 9 | "prHourlyLimit": 10, 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/build-binaries.yml: -------------------------------------------------------------------------------- 1 | # Build uv on all platforms. 2 | # 3 | # Generates both wheels (for PyPI) and archived binaries (for GitHub releases). 4 | # 5 | # Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local 6 | # artifacts job within `cargo-dist`. 7 | name: "Build binaries" 8 | 9 | on: 10 | workflow_call: 11 | inputs: 12 | plan: 13 | required: true 14 | type: string 15 | pull_request: 16 | paths: 17 | # When we change pyproject.toml, we want to ensure that the maturin builds still work. 18 | - pyproject.toml 19 | # And when we change this workflow itself... 20 | - .github/workflows/build-binaries.yml 21 | 22 | permissions: 23 | contents: read 24 | 25 | concurrency: 26 | group: ${{ github.workflow }}-${{ github.ref }} 27 | cancel-in-progress: true 28 | 29 | jobs: 30 | linux: 31 | runs-on: ${{ matrix.platform.runner }} 32 | strategy: 33 | matrix: 34 | platform: 35 | - runner: ubuntu-latest 36 | target: x86_64-unknown-linux-gnu 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: "Build wheels" 40 | uses: PyO3/maturin-action@v1 41 | with: 42 | target: ${{ matrix.platform.target }} 43 | args: --release --locked --out dist 44 | sccache: 'true' 45 | manylinux: auto 46 | - name: "Upload wheels" 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: wheels-linux-${{ matrix.platform.target }} 50 | path: dist 51 | - name: "Archive binary" 52 | shell: bash 53 | run: | 54 | set -euo pipefail 55 | 56 | TARGET=${{ matrix.platform.target }} 57 | ARCHIVE_NAME=prefligit-$TARGET 58 | ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz 59 | 60 | mkdir -p $ARCHIVE_NAME 61 | cp target/$TARGET/release/prefligit $ARCHIVE_NAME/prefligit 62 | tar czvf $ARCHIVE_FILE $ARCHIVE_NAME 63 | shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 64 | - name: "Upload binary" 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: artifacts-${{ matrix.platform.target }} 68 | path: | 69 | *.tar.gz 70 | *.sha256 71 | 72 | windows: 73 | runs-on: ${{ matrix.platform.runner }} 74 | strategy: 75 | matrix: 76 | platform: 77 | - runner: windows-latest 78 | target: x86_64-pc-windows-msvc 79 | steps: 80 | - uses: actions/checkout@v4 81 | - name: "Build wheels" 82 | uses: PyO3/maturin-action@v1 83 | with: 84 | target: ${{ matrix.platform.target }} 85 | args: --release --locked --out dist 86 | sccache: 'true' 87 | - name: "Upload wheels" 88 | uses: actions/upload-artifact@v4 89 | with: 90 | name: wheels-windows-${{ matrix.platform.target }} 91 | path: dist 92 | - name: "Archive binary" 93 | shell: bash 94 | run: | 95 | ARCHIVE_FILE=prefligit-${{ matrix.platform.target }}.zip 96 | 7z a $ARCHIVE_FILE ./target/${{ matrix.platform.target }}/release/prefligit.exe 97 | sha256sum $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 98 | - name: "Upload binary" 99 | uses: actions/upload-artifact@v4 100 | with: 101 | name: artifacts-${{ matrix.platform.target }} 102 | path: | 103 | *.zip 104 | *.sha256 105 | 106 | macos: 107 | runs-on: ${{ matrix.platform.runner }} 108 | strategy: 109 | matrix: 110 | platform: 111 | - runner: macos-15 112 | target: x86_64-apple-darwin 113 | - runner: macos-15 114 | target: aarch64-apple-darwin 115 | steps: 116 | - uses: actions/checkout@v4 117 | - name: "Build wheels" 118 | uses: PyO3/maturin-action@v1 119 | with: 120 | target: ${{ matrix.platform.target }} 121 | args: --release --locked --out dist 122 | sccache: 'true' 123 | - name: "Upload wheels" 124 | uses: actions/upload-artifact@v4 125 | with: 126 | name: wheels-macos-${{ matrix.platform.target }} 127 | path: dist 128 | - name: "Archive binary" 129 | run: | 130 | TARGET=${{ matrix.platform.target }} 131 | ARCHIVE_NAME=prefligit-$TARGET 132 | ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz 133 | 134 | mkdir -p $ARCHIVE_NAME 135 | cp target/$TARGET/release/prefligit $ARCHIVE_NAME/prefligit 136 | tar czvf $ARCHIVE_FILE $ARCHIVE_NAME 137 | shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 138 | - name: "Upload binary" 139 | uses: actions/upload-artifact@v4 140 | with: 141 | name: artifacts-${{ matrix.platform.target }} 142 | path: | 143 | *.tar.gz 144 | *.sha256 145 | 146 | sdist: 147 | runs-on: ubuntu-latest 148 | steps: 149 | - uses: actions/checkout@v4 150 | - name: Build sdist 151 | uses: PyO3/maturin-action@v1 152 | with: 153 | command: sdist 154 | args: --out dist 155 | - name: "Upload sdist" 156 | uses: actions/upload-artifact@v4 157 | with: 158 | name: wheels-sdist 159 | path: dist 160 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: 7 | - "README.md" 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | typos: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: crate-ci/typos@master 21 | 22 | lint: 23 | name: "lint" 24 | timeout-minutes: 30 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: "Install Rustfmt" 29 | run: rustup component add rustfmt 30 | - name: "rustfmt" 31 | run: cargo fmt --all --check 32 | 33 | cargo-clippy: 34 | name: "cargo clippy | ubuntu" 35 | timeout-minutes: 10 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: rui314/setup-mold@v1 40 | - uses: Swatinem/rust-cache@v2 41 | 42 | - name: "Install Rust toolchain" 43 | run: rustup component add clippy 44 | - name: "Clippy" 45 | run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings 46 | 47 | cargo-clippy-windows: 48 | timeout-minutes: 15 49 | runs-on: windows-latest 50 | name: "cargo clippy | windows" 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - name: Create Dev Drive 55 | run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1 56 | 57 | - uses: Swatinem/rust-cache@v2 58 | 59 | - name: "Install Rust toolchain" 60 | run: rustup component add clippy 61 | 62 | - name: "Clippy" 63 | run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings 64 | 65 | cargo-shear: 66 | name: "cargo shear" 67 | timeout-minutes: 10 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: cargo-bins/cargo-binstall@main 72 | - run: cargo binstall --no-confirm cargo-shear 73 | - run: cargo shear 74 | 75 | cargo-test-linux: 76 | timeout-minutes: 10 77 | runs-on: ubuntu-latest 78 | name: "cargo test | ubuntu" 79 | steps: 80 | - uses: actions/checkout@v4 81 | - uses: rui314/setup-mold@v1 82 | - uses: Swatinem/rust-cache@v2 83 | 84 | - name: "Install Rust toolchain" 85 | run: rustup show 86 | 87 | - name: "Install cargo nextest" 88 | uses: taiki-e/install-action@v2 89 | with: 90 | tool: cargo-nextest 91 | 92 | - name: "Install uv" 93 | uses: astral-sh/setup-uv@v5 94 | 95 | - name: "Cargo test" 96 | run: | 97 | cargo nextest show-config test-groups 98 | cargo nextest run \ 99 | --workspace \ 100 | --status-level skip --failure-output immediate --no-fail-fast -j 8 --final-status-level slow 101 | 102 | cargo-test-macos: 103 | timeout-minutes: 10 104 | runs-on: macos-latest 105 | name: "cargo test | macos" 106 | steps: 107 | - uses: actions/checkout@v4 108 | 109 | - uses: rui314/setup-mold@v1 110 | 111 | - uses: Swatinem/rust-cache@v2 112 | 113 | - name: "Install Rust toolchain" 114 | run: rustup show 115 | 116 | - name: "Install cargo nextest" 117 | uses: taiki-e/install-action@v2 118 | with: 119 | tool: cargo-nextest 120 | 121 | - name: "Install uv" 122 | uses: astral-sh/setup-uv@v5 123 | 124 | - name: "Cargo test" 125 | run: | 126 | cargo nextest show-config test-groups 127 | cargo nextest run \ 128 | --workspace \ 129 | --status-level skip --failure-output immediate --no-fail-fast -j 8 --final-status-level slow 130 | 131 | cargo-test-windows: 132 | timeout-minutes: 15 133 | runs-on: windows-latest 134 | name: "cargo test | windows" 135 | steps: 136 | - uses: actions/checkout@v4 137 | 138 | - name: Create Dev Drive 139 | run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1 140 | 141 | - uses: Swatinem/rust-cache@v2 142 | 143 | - name: "Install Rust toolchain" 144 | run: rustup show 145 | 146 | - name: "Install cargo nextest" 147 | uses: taiki-e/install-action@v2 148 | with: 149 | tool: cargo-nextest 150 | 151 | - name: "Install uv" 152 | uses: astral-sh/setup-uv@v5 153 | with: 154 | cache-local-path: ${{ env.DEV_DRIVE }}/uv-cache 155 | 156 | - name: "Cargo test" 157 | run: | 158 | cargo nextest show-config test-groups 159 | cargo nextest run --workspace --status-level skip --failure-output immediate --no-fail-fast -j 8 --final-status-level slow 160 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Publish a release to PyPI and crates.io. 2 | # 3 | # Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a publish job 4 | # within `cargo-dist`. 5 | name: "Publish" 6 | 7 | on: 8 | workflow_call: 9 | inputs: 10 | plan: 11 | required: true 12 | type: string 13 | 14 | jobs: 15 | pypi-publish: 16 | name: Upload to PyPI 17 | runs-on: ubuntu-latest 18 | environment: 19 | name: release 20 | permissions: 21 | # For PyPI's trusted publishing. 22 | id-token: write 23 | steps: 24 | - name: "Install uv" 25 | uses: astral-sh/setup-uv@v5 26 | - uses: actions/download-artifact@v4 27 | with: 28 | pattern: wheels-* 29 | path: wheels 30 | merge-multiple: true 31 | - name: Publish to PyPi 32 | run: uv publish -v wheels/* 33 | -------------------------------------------------------------------------------- /.github/workflows/setup-dev-drive.ps1: -------------------------------------------------------------------------------- 1 | # This creates a 10GB dev drive, and exports all required environment 2 | # variables so that rustup, prefligit and others all use the dev drive as much 3 | # as possible. 4 | # $Volume = New-VHD -Path C:/prefligit_dev_drive.vhdx -SizeBytes 10GB | 5 | # Mount-VHD -Passthru | 6 | # Initialize-Disk -Passthru | 7 | # New-Partition -AssignDriveLetter -UseMaximumSize | 8 | # Format-Volume -FileSystem ReFS -Confirm:$false -Force 9 | # 10 | # Write-Output $Volume 11 | 12 | $Drive = "D:" 13 | $Tmp = "$($Drive)\prefligit-tmp" 14 | 15 | # Create the directory ahead of time in an attempt to avoid race-conditions 16 | New-Item $Tmp -ItemType Directory 17 | 18 | Write-Output ` 19 | "DEV_DRIVE=$($Drive)" ` 20 | "TMP=$($Tmp)" ` 21 | "TEMP=$($Tmp)" ` 22 | "PREFLIGIT_INTERNAL__TEST_DIR=$($Tmp)" ` 23 | "RUSTUP_HOME=$($Drive)/.rustup" ` 24 | "CARGO_HOME=$($Drive)/.cargo" ` 25 | >> $env:GITHUB_ENV 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | .cache 4 | 5 | # Insta snapshots. 6 | *.pending-snap 7 | 8 | # JetBrains IDE 9 | .idea 10 | 11 | # macOS 12 | **/.DS_Store 13 | 14 | # profiling flamegraphs 15 | *.flamegraph.svg 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | exclude: | 4 | (?x)^( 5 | .*/(snapshots)/.*| 6 | )$ 7 | 8 | repos: 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: trailing-whitespace 13 | - id: end-of-file-fixer 14 | 15 | - repo: https://github.com/crate-ci/typos 16 | rev: v1.26.0 17 | hooks: 18 | - id: typos 19 | 20 | - repo: local 21 | hooks: 22 | - id: cargo-fmt 23 | name: cargo fmt 24 | entry: cargo fmt -- 25 | language: system 26 | types: [rust] 27 | pass_filenames: false # This makes it a lot faster 28 | 29 | - repo: local 30 | hooks: 31 | - id: cargo-clippy 32 | name: cargo clippy 33 | language: system 34 | types: [rust] 35 | pass_filenames: false 36 | entry: cargo clippy --all-targets --all-features -- -D warnings 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.0.10 4 | 5 | ### Breaking changes 6 | 7 | **Warning**: This release changed the store layout, it's recommended to delete the old store and install from scratch. 8 | 9 | To delete the old store, run: 10 | 11 | ```sh 12 | rm -rf ~/.cache/prefligit 13 | ``` 14 | 15 | ### Enhancements 16 | 17 | - Restructure store folders layout ([#181](https://github.com/j178/prefligit/pull/181)) 18 | - Fallback some env vars to to pre-commit ([#175](https://github.com/j178/prefligit/pull/175)) 19 | - Save patches to `$PREFLIGIT_HOME/patches` ([#182](https://github.com/j178/prefligit/pull/182)) 20 | 21 | ### Bug fixes 22 | 23 | - Fix removing git env vars ([#176](https://github.com/j178/prefligit/pull/176)) 24 | - Fix typo in Cargo.toml ([#160](https://github.com/j178/prefligit/pull/160)) 25 | 26 | ### Other changes 27 | 28 | - Do not publish to crates.io ([#191](https://github.com/j178/prefligit/pull/191)) 29 | - Bump cargo-dist to v0.28.0 ([#170](https://github.com/j178/prefligit/pull/170)) 30 | - Bump uv version to 0.6.0 ([#184](https://github.com/j178/prefligit/pull/184)) 31 | - Configure Renovate ([#168](https://github.com/j178/prefligit/pull/168)) 32 | - Format sample config output ([#172](https://github.com/j178/prefligit/pull/172)) 33 | - Make env vars a shareable crate ([#171](https://github.com/j178/prefligit/pull/171)) 34 | - Reduce String alloc ([#166](https://github.com/j178/prefligit/pull/166)) 35 | - Skip common git flags in command trace log ([#162](https://github.com/j178/prefligit/pull/162)) 36 | - Update Rust crate clap to v4.5.29 ([#173](https://github.com/j178/prefligit/pull/173)) 37 | - Update Rust crate which to v7.0.2 ([#163](https://github.com/j178/prefligit/pull/163)) 38 | - Update astral-sh/setup-uv action to v5 ([#164](https://github.com/j178/prefligit/pull/164)) 39 | - Upgrade Rust to 1.84 and upgrade dependencies ([#161](https://github.com/j178/prefligit/pull/161)) 40 | 41 | ## 0.0.9 42 | 43 | Due to a mistake in the release process, this release is skipped. 44 | 45 | ## 0.0.8 46 | 47 | ### Enhancements 48 | 49 | - Move home dir to `~/.cache/prefligit` ([#154](https://github.com/j178/prefligit/pull/154)) 50 | - Implement trailing-whitespace in Rust ([#137](https://github.com/j178/prefligit/pull/137)) 51 | - Limit hook install concurrency ([#145](https://github.com/j178/prefligit/pull/145)) 52 | - Simplify language default version implementation ([#150](https://github.com/j178/prefligit/pull/150)) 53 | - Support install uv from pypi ([#149](https://github.com/j178/prefligit/pull/149)) 54 | - Add executing command to error message ([#141](https://github.com/j178/prefligit/pull/141)) 55 | 56 | ### Bug fixes 57 | 58 | - Use hook `args` in fast path ([#139](https://github.com/j178/prefligit/pull/139)) 59 | 60 | ### Other changes 61 | 62 | - Remove hook install_key ([#153](https://github.com/j178/prefligit/pull/153)) 63 | - Remove pyvenv.cfg patch ([#156](https://github.com/j178/prefligit/pull/156)) 64 | - Try to use D drive on Windows CI ([#157](https://github.com/j178/prefligit/pull/157)) 65 | - Tweak trailing-whitespace-fixer ([#140](https://github.com/j178/prefligit/pull/140)) 66 | - Upgrade dist to v0.27.0 ([#158](https://github.com/j178/prefligit/pull/158)) 67 | - Uv install python into tools path ([#151](https://github.com/j178/prefligit/pull/151)) 68 | 69 | ## 0.0.7 70 | 71 | ### Enhancements 72 | 73 | - Add progress bar for hook init and install ([#122](https://github.com/j178/prefligit/pull/122)) 74 | - Add color to command help ([#131](https://github.com/j178/prefligit/pull/131)) 75 | - Add commit info to version display ([#130](https://github.com/j178/prefligit/pull/130)) 76 | - Support meta hooks reading ([#134](https://github.com/j178/prefligit/pull/134)) 77 | - Implement meta hooks ([#135](https://github.com/j178/prefligit/pull/135)) 78 | 79 | ### Bug fixes 80 | 81 | - Fix same repo clone multiple times ([#125](https://github.com/j178/prefligit/pull/125)) 82 | - Fix logging level after renaming ([#119](https://github.com/j178/prefligit/pull/119)) 83 | - Fix version tag distance ([#132](https://github.com/j178/prefligit/pull/132)) 84 | 85 | ### Other changes 86 | 87 | - Disable uv cache on Windows ([#127](https://github.com/j178/prefligit/pull/127)) 88 | - Impl Eq and Hash for ConfigRemoteRepo ([#126](https://github.com/j178/prefligit/pull/126)) 89 | - Make `pass_env_vars` runs on Windows ([#133](https://github.com/j178/prefligit/pull/133)) 90 | - Run cargo update ([#129](https://github.com/j178/prefligit/pull/129)) 91 | - Update Readme ([#128](https://github.com/j178/prefligit/pull/128)) 92 | 93 | ## 0.0.6 94 | 95 | ### Breaking changes 96 | 97 | In this release, we’ve renamed the project to `prefligit` (a deliberate misspelling of preflight) to prevent confusion with the existing pre-commit tool. For further information, refer to issue #73. 98 | 99 | - The command-line name is now `prefligit`. We suggest uninstalling any previous version of `pre-commit-rs` and installing `prefligit` from scratch. 100 | - The PyPI package is now listed as [`prefligit`](https://pypi.org/project/prefligit/). 101 | - The Cargo package is also now [`prefligit`](https://crates.io/crates/prefligit). 102 | - The Homebrew formula has been updated to `prefligit`. 103 | 104 | ### Enhancements 105 | 106 | - Support `docker_image` language ([#113](https://github.com/j178/pre-commit-rs/pull/113)) 107 | - Support `init-templatedir` subcommand ([#101](https://github.com/j178/pre-commit-rs/pull/101)) 108 | - Implement get filenames from merge conflicts ([#103](https://github.com/j178/pre-commit-rs/pull/103)) 109 | 110 | ### Bug fixes 111 | 112 | - Fix `prefligit install --hook-type` name ([#102](https://github.com/j178/pre-commit-rs/pull/102)) 113 | 114 | ### Other changes 115 | 116 | - Apply color option to log ([#100](https://github.com/j178/pre-commit-rs/pull/100)) 117 | - Improve tests ([#106](https://github.com/j178/pre-commit-rs/pull/106)) 118 | - Remove intermedia Language enum ([#107](https://github.com/j178/pre-commit-rs/pull/107)) 119 | - Run `cargo clippy` in the dev drive workspace ([#115](https://github.com/j178/pre-commit-rs/pull/115)) 120 | 121 | ## 0.0.5 122 | 123 | ### Enhancements 124 | 125 | v0.0.4 release process was broken, so this release is a actually a re-release of v0.0.4. 126 | 127 | - Improve subprocess trace and error output ([#92](https://github.com/j178/pre-commit-rs/pull/92)) 128 | - Stash working tree before running hooks ([#96](https://github.com/j178/pre-commit-rs/pull/96)) 129 | - Add color to command trace ([#94](https://github.com/j178/pre-commit-rs/pull/94)) 130 | - Improve hook output display ([#79](https://github.com/j178/pre-commit-rs/pull/79)) 131 | - Improve uv installation ([#78](https://github.com/j178/pre-commit-rs/pull/78)) 132 | - Support docker language ([#67](https://github.com/j178/pre-commit-rs/pull/67)) 133 | 134 | ## 0.0.4 135 | 136 | ### Enhancements 137 | 138 | - Improve subprocess trace and error output ([#92](https://github.com/j178/pre-commit-rs/pull/92)) 139 | - Stash working tree before running hooks ([#96](https://github.com/j178/pre-commit-rs/pull/96)) 140 | - Add color to command trace ([#94](https://github.com/j178/pre-commit-rs/pull/94)) 141 | - Improve hook output display ([#79](https://github.com/j178/pre-commit-rs/pull/79)) 142 | - Improve uv installation ([#78](https://github.com/j178/pre-commit-rs/pull/78)) 143 | - Support docker language ([#67](https://github.com/j178/pre-commit-rs/pull/67)) 144 | 145 | ## 0.0.3 146 | 147 | ### Bug fixes 148 | 149 | - Check uv installed after acquired lock ([#72](https://github.com/j178/pre-commit-rs/pull/72)) 150 | 151 | ### Other changes 152 | 153 | - Add copyright of the original pre-commit to LICENSE ([#74](https://github.com/j178/pre-commit-rs/pull/74)) 154 | - Add profiler ([#71](https://github.com/j178/pre-commit-rs/pull/71)) 155 | - Publish to PyPI ([#70](https://github.com/j178/pre-commit-rs/pull/70)) 156 | - Publish to crates.io ([#75](https://github.com/j178/pre-commit-rs/pull/75)) 157 | - Rename pypi package to `pre-commit-rusty` ([#76](https://github.com/j178/pre-commit-rs/pull/76)) 158 | 159 | ## 0.0.2 160 | 161 | ### Enhancements 162 | 163 | - Add `pre-commit self update` ([#68](https://github.com/j178/pre-commit-rs/pull/68)) 164 | - Auto install uv ([#66](https://github.com/j178/pre-commit-rs/pull/66)) 165 | - Generate shell completion ([#20](https://github.com/j178/pre-commit-rs/pull/20)) 166 | - Implement `pre-commit clean` ([#24](https://github.com/j178/pre-commit-rs/pull/24)) 167 | - Implement `pre-commit install` ([#28](https://github.com/j178/pre-commit-rs/pull/28)) 168 | - Implement `pre-commit sample-config` ([#37](https://github.com/j178/pre-commit-rs/pull/37)) 169 | - Implement `pre-commit uninstall` ([#36](https://github.com/j178/pre-commit-rs/pull/36)) 170 | - Implement `pre-commit validate-config` ([#25](https://github.com/j178/pre-commit-rs/pull/25)) 171 | - Implement `pre-commit validate-manifest` ([#26](https://github.com/j178/pre-commit-rs/pull/26)) 172 | - Implement basic `pre-commit hook-impl` ([#63](https://github.com/j178/pre-commit-rs/pull/63)) 173 | - Partition filenames and delegate to multiple subprocesses ([#7](https://github.com/j178/pre-commit-rs/pull/7)) 174 | - Refactor xargs ([#8](https://github.com/j178/pre-commit-rs/pull/8)) 175 | - Skip empty config argument ([#64](https://github.com/j178/pre-commit-rs/pull/64)) 176 | - Use `fancy-regex` ([#62](https://github.com/j178/pre-commit-rs/pull/62)) 177 | - feat: add fail language support ([#60](https://github.com/j178/pre-commit-rs/pull/60)) 178 | 179 | ### Bug Fixes 180 | 181 | - Fix stage operate_on_files ([#65](https://github.com/j178/pre-commit-rs/pull/65)) 182 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["lib/*"] 3 | 4 | [workspace.package] 5 | edition = "2024" 6 | 7 | [workspace.dependencies] 8 | constants = { path = "lib/constants" } 9 | 10 | tracing = "0.1.40" 11 | 12 | [package] 13 | name = "prefligit" 14 | version = "0.0.10" 15 | authors = ["j178 "] 16 | description = "pre-commit implemented in Rust" 17 | repository = "https://github.com/j178/prefligit" 18 | homepage = "https://github.com/j178/prefligit" 19 | edition.workspace = true 20 | license-file = "LICENSE" 21 | 22 | [features] 23 | default = ["docker"] 24 | profiler = ["dep:pprof", "profiler-flamegraph"] 25 | profiler-flamegraph = ["pprof/flamegraph"] 26 | docker = [] 27 | 28 | [dependencies] 29 | constants = { workspace = true } 30 | 31 | anstream = { version = "0.6.15" } 32 | anyhow = { version = "1.0.86" } 33 | assert_cmd = { version = "2.0.16", features = ["color"] } 34 | astral-tokio-tar = { version = "0.5.1" } 35 | async-compression = { version = "0.4.18", features = ["gzip", "xz", "tokio"] } 36 | async_zip = { git = "https://github.com/charliermarsh/rs-async-zip", rev = "c909fda63fcafe4af496a07bfda28a5aae97e58d", features = ["deflate", "tokio"] } 37 | axoupdater = { version = "0.9.0", default-features = false, features = [ "github_releases"] } 38 | bstr = { version = "1.11.0" } 39 | clap = { version = "4.5.16", features = ["derive", "env", "string", "wrap_help"] } 40 | clap_complete = { version = "4.5.37" } 41 | ctrlc = { version = "3.4.5" } 42 | dunce = { version = "1.0.5" } 43 | etcetera = { version = "0.10.0" } 44 | fancy-regex = "0.14.0" 45 | fs-err = { version = "3.1.0", features = ["tokio"] } 46 | fs2 = { version = "0.4.3" } 47 | futures = { version = "0.3.31" } 48 | hex = { version = "0.4.3" } 49 | http = { version = "1.1.0" } 50 | indicatif = { version = "0.17.8" } 51 | indoc = { version = "2.0.5" } 52 | itertools = { version = "0.14.0" } 53 | miette = { version = "7.5.0", features = ["fancy-no-backtrace"] } 54 | owo-colors = { version = "4.1.0" } 55 | rand = { version = "0.9.0" } 56 | rayon = { version = "1.10.0" } 57 | reqwest = { version = "0.12.9", default-features = false, features = ["stream"] } 58 | same-file = { version = "1.0.6" } 59 | semver = { version = "1.0.24", features = ["serde"] } 60 | seahash = { version = "4.1.0" } 61 | serde = { version = "1.0.210", features = ["derive"] } 62 | serde_json = { version = "1.0.132" } 63 | serde_yaml = { version = "0.9.34" } 64 | shlex = { version = "1.3.0" } 65 | target-lexicon = { version = "0.13.0" } 66 | tempfile = { version = "3.13.0" } 67 | textwrap = { version = "0.16.1" } 68 | thiserror = { version = "2.0.11" } 69 | tokio = { version = "1.40.0", features = ["fs", "process", "rt", "sync", "macros"] } 70 | tokio-util = { version = "0.7.13" } 71 | tracing = { workspace = true } 72 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 73 | unicode-width = { version = "0.2.0" } 74 | url = { version = "2.5.2", features = ["serde"] } 75 | which = { version = "7.0.1" } 76 | 77 | [target.'cfg(unix)'.dependencies] 78 | libc = { version = "0.2.164" } 79 | pprof = { version = "0.14.0", optional = true } 80 | 81 | [dev-dependencies] 82 | assert_fs = { version = "1.1.2" } 83 | etcetera = { version = "0.10.0" } 84 | insta = { version = "1.40.0", features = ["filters"] } 85 | insta-cmd = { version = "0.6.0" } 86 | predicates = { version = "3.1.2" } 87 | regex = { version = "1.11.0" } 88 | 89 | [build-dependencies] 90 | fs-err = { version = "3.1.0" } 91 | 92 | [lints] 93 | workspace = true 94 | 95 | [workspace.lints.rust] 96 | dead_code = "allow" 97 | 98 | [workspace.lints.clippy] 99 | pedantic = { level = "warn", priority = -2 } 100 | # Allowed pedantic lints 101 | char_lit_as_u8 = "allow" 102 | collapsible_else_if = "allow" 103 | collapsible_if = "allow" 104 | implicit_hasher = "allow" 105 | map_unwrap_or = "allow" 106 | match_same_arms = "allow" 107 | missing_errors_doc = "allow" 108 | missing_panics_doc = "allow" 109 | module_name_repetitions = "allow" 110 | must_use_candidate = "allow" 111 | similar_names = "allow" 112 | too_many_arguments = "allow" 113 | too_many_lines = "allow" 114 | used_underscore_binding = "allow" 115 | # Disallowed restriction lints 116 | print_stdout = "warn" 117 | print_stderr = "warn" 118 | dbg_macro = "warn" 119 | empty_drop = "warn" 120 | empty_structs_with_brackets = "warn" 121 | exit = "warn" 122 | get_unwrap = "warn" 123 | rc_buffer = "warn" 124 | rc_mutex = "warn" 125 | rest_pat_in_fully_bound_structs = "warn" 126 | 127 | [profile.bench] 128 | opt-level = 3 129 | debug = true # used by the profiler 130 | strip = false # keep symbols for the profiler 131 | 132 | # The profile that 'cargo dist' will build with 133 | [profile.dist] 134 | inherits = "release" 135 | lto = "thin" 136 | -------------------------------------------------------------------------------- /DIFF.md: -------------------------------------------------------------------------------- 1 | ## Difference from pre-commit 2 | 3 | - `prefligit` supports both `.pre-commit-config.yaml` and `.pre-commit-config.yml` configuration files. 4 | - `prefligit` implements some common hooks from `pre-commit-hooks` in Rust for better performance. 5 | - `prefligit` uses `~/.prefligit` as the default cache directory for toolchains and environments, and stores repos and hooks separately. 6 | - `prefligit` uses `uv` for managing Python environments and installations. 7 | - `prefligit` supports `language-version` as a version specifier and automatically installs the required toolchains. 8 | 9 | ### Future plans 10 | 11 | - Built-in support for monorepos. 12 | - Global configurations. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 j178 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prefligit 2 | 3 | ![Development Status](https://img.shields.io/badge/Development-Early_Stage-yellowgreen) 4 | [![CI](https://github.com/j178/prefligit/actions/workflows/ci.yml/badge.svg)](https://github.com/j178/prefligit/actions/workflows/ci.yml) 5 | 6 | prefligit 7 | 8 | A reimplementation of the [pre-commit](https://pre-commit.com/) tool in Rust, designed to be a faster, dependency-free and drop-in alternative, 9 | while also providing some additional opinionated features. 10 | 11 | > [!WARNING] 12 | > This project is still in very early development, only a few of the original pre-commit features are implemented. 13 | > It is not recommended for normal use yet, but feel free to try it out and provide feedback. 14 | 15 | > [!NOTE] 16 | > This project was previously named `pre-commit-rs`, but it was renamed to `prefligit` to prevent confusion with the existing pre-commit tool. 17 | > See [#73](https://github.com/j178/prefligit/issues/73) for more information. 18 | 19 | ## Features 20 | 21 | - A single binary with no dependencies, does not require Python or any other runtime. 22 | - Improved performance in hook preparation and execution. 23 | - Fully compatible with the original pre-commit configurations and hooks. 24 | - Integration with [`uv`](https://github.com/astral-sh/uv) for managing Python environments and installations. 25 | - Improved toolchain installations for Python, Node.js, Go, Rust and Ruby, shared between hooks. 26 | - (TODO) Built-in support for monorepos. 27 | - (TODO) Built-in implementation of some common hooks. 28 | 29 | ## Installation 30 | 31 |
32 | Standalone installer 33 | 34 | `prefligit` provides a standalone installer script to download and install the tool: 35 | 36 | ```console 37 | # On Linux and macOS 38 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/j178/prefligit/releases/download/v0.0.10/prefligit-installer.sh | sh 39 | 40 | # On Windows 41 | powershell -ExecutionPolicy ByPass -c "irm https://github.com/j178/prefligit/releases/download/v0.0.10/prefligit-installer.ps1 | iex" 42 | ``` 43 |
44 | 45 |
46 | PyPI 47 | 48 | `prefligit` is published as Python binary wheel to PyPI, you can install it using `pip`, `uv` (recommended), or `pipx`: 49 | 50 | ```console 51 | pip install prefligit 52 | 53 | # or 54 | 55 | uv tool install prefligit 56 | 57 | # or 58 | 59 | pipx install prefligit 60 | ``` 61 |
62 | 63 |
64 | Homebrew 65 | 66 | ```console 67 | brew install j178/tap/prefligit 68 | ``` 69 |
70 | 71 |
72 | Cargo 73 | 74 | Build from source using Cargo: 75 | 76 | ```console 77 | cargo install --locked --git https://github.com/j178/prefligit 78 | ``` 79 |
80 | 81 |
82 | GitHub Releases 83 | 84 | `prefligit` release artifacts can be downloaded directly from the [GitHub releases](https://github.com/j178/prefligit/releases). 85 |
86 | 87 | 88 | ## Usage 89 | 90 | This tool is designed to be a drop-in alternative for the original pre-commit tool, so you can use it with your existing configurations and hooks. 91 | 92 | Please refer to the [official documentation](https://pre-commit.com/) for more information on how to configure and use pre-commit. 93 | 94 | ## Acknowledgements 95 | 96 | This project is heavily inspired by the original [pre-commit](https://pre-commit.com/) tool, and it wouldn't be possible without the hard work 97 | of the maintainers and contributors of that project. 98 | 99 | And a special thanks to the [Astral](https://github.com/astral-sh) team for their remarkable projects, particularly [uv](https://github.com/astral-sh/uv), 100 | from which I've learned a lot on how to write efficient and idiomatic Rust code. 101 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | /* MIT License 2 | 3 | Copyright (c) 2023 Astral Software Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | */ 23 | 24 | use std::{ 25 | path::{Path, PathBuf}, 26 | process::Command, 27 | }; 28 | 29 | use fs_err as fs; 30 | 31 | fn main() { 32 | // The workspace root directory is not available without walking up the tree 33 | // https://github.com/rust-lang/cargo/issues/3946 34 | #[allow(clippy::disallowed_methods)] 35 | let workspace_root = Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()).to_path_buf(); 36 | 37 | commit_info(&workspace_root); 38 | } 39 | 40 | fn commit_info(workspace_root: &Path) { 41 | // If not in a git repository, do not attempt to retrieve commit information 42 | let git_dir = workspace_root.join(".git"); 43 | if !git_dir.exists() { 44 | return; 45 | } 46 | 47 | if let Some(git_head_path) = git_head(&git_dir) { 48 | println!("cargo:rerun-if-changed={}", git_head_path.display()); 49 | 50 | let git_head_contents = fs::read_to_string(git_head_path); 51 | if let Ok(git_head_contents) = git_head_contents { 52 | // The contents are either a commit or a reference in the following formats 53 | // - "" when the head is detached 54 | // - "ref " when working on a branch 55 | // If a commit, checking if the HEAD file has changed is sufficient 56 | // If a ref, we need to add the head file for that ref to rebuild on commit 57 | let mut git_ref_parts = git_head_contents.split_whitespace(); 58 | git_ref_parts.next(); 59 | if let Some(git_ref) = git_ref_parts.next() { 60 | let git_ref_path = git_dir.join(git_ref); 61 | println!("cargo:rerun-if-changed={}", git_ref_path.display()); 62 | } 63 | } 64 | } 65 | 66 | let output = match Command::new("git") 67 | .arg("log") 68 | .arg("-1") 69 | .arg("--date=short") 70 | .arg("--abbrev=9") 71 | // describe:tags => Instead of only considering annotated tags, consider lightweight tags as well. 72 | .arg("--format=%H %h %cd %(describe:tags)") 73 | .output() 74 | { 75 | Ok(output) if output.status.success() => output, 76 | _ => return, 77 | }; 78 | let stdout = String::from_utf8(output.stdout).unwrap(); 79 | let mut parts = stdout.split_whitespace(); 80 | let mut next = || parts.next().unwrap(); 81 | println!("cargo:rustc-env=PREFLIGIT_COMMIT_HASH={}", next()); 82 | println!("cargo:rustc-env=PREFLIGIT_COMMIT_SHORT_HASH={}", next()); 83 | println!("cargo:rustc-env=PREFLIGIT_COMMIT_DATE={}", next()); 84 | 85 | // Describe can fail for some commits 86 | // https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem 87 | if let Some(describe) = parts.next() { 88 | let mut describe_parts = describe.split('-'); 89 | println!( 90 | "cargo:rustc-env=PREFLIGIT_LAST_TAG={}", 91 | describe_parts.next().unwrap() 92 | ); 93 | // If this is the tagged commit, this component will be missing 94 | println!( 95 | "cargo:rustc-env=PREFLIGIT_LAST_TAG_DISTANCE={}", 96 | describe_parts.next().unwrap_or("0") 97 | ); 98 | } 99 | } 100 | 101 | fn git_head(git_dir: &Path) -> Option { 102 | // The typical case is a standard git repository. 103 | let git_head_path = git_dir.join("HEAD"); 104 | if git_head_path.exists() { 105 | return Some(git_head_path); 106 | } 107 | if !git_dir.is_file() { 108 | return None; 109 | } 110 | // If `.git/HEAD` doesn't exist and `.git` is actually a file, 111 | // then let's try to attempt to read it as a worktree. If it's 112 | // a worktree, then its contents will look like this, e.g.: 113 | // 114 | // gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2 115 | // 116 | // And the HEAD file we want to watch will be at: 117 | // 118 | // /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD 119 | let contents = fs::read_to_string(git_dir).ok()?; 120 | let (label, worktree_path) = contents.split_once(':')?; 121 | if label != "gitdir" { 122 | return None; 123 | } 124 | let worktree_path = worktree_path.trim(); 125 | Some(PathBuf::from(worktree_path)) 126 | } 127 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | disallowed-methods = [ 2 | "std::env::var", 3 | "std::env::var_os", 4 | ] 5 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # The archive format to use for non-windows builds (defaults .tar.xz) 9 | unix-archive = ".tar.gz" 10 | # CI backends to support 11 | ci = "github" 12 | # Whether CI should include auto-generated code to build local artifacts 13 | build-local-artifacts = false 14 | # Whether CI should trigger releases with dispatches instead of tag pushes 15 | dispatch-releases = true 16 | # Which actions to run on pull requests 17 | pr-run-mode = "plan" 18 | # Which phase dist should use to create the GitHub release 19 | github-release = "announce" 20 | # The installers to generate for each app 21 | installers = ["shell", "powershell", "homebrew"] 22 | # Target platforms to build apps for (Rust target-triple syntax) 23 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 24 | # Local artifacts jobs to run in CI 25 | local-artifacts-jobs = ["./build-binaries"] 26 | # Publish jobs to run in CI 27 | publish-jobs = ["./publish", "homebrew"] 28 | # A GitHub repo to push Homebrew formulas to 29 | tap = "j178/homebrew-tap" 30 | # Customize the Homebrew formula name 31 | formula = "prefligit" 32 | # Whether to install an updater program 33 | install-updater = false 34 | # Path that installers should place binaries in 35 | install-path = ["$XDG_BIN_HOME/", "$XDG_DATA_HOME/../bin", "~/.local/bin"] 36 | 37 | [dist.github-custom-runners] 38 | global = "ubuntu-latest" 39 | -------------------------------------------------------------------------------- /lib/constants/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "constants" 3 | version = "0.0.1" 4 | edition = { workspace = true } 5 | 6 | [dependencies] 7 | tracing = { workspace = true } 8 | 9 | [lints] 10 | workspace = true 11 | -------------------------------------------------------------------------------- /lib/constants/src/env_vars.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | 3 | use tracing::info; 4 | 5 | pub struct EnvVars; 6 | 7 | impl EnvVars { 8 | pub const PATH: &'static str = "PATH"; 9 | 10 | pub const SKIP: &'static str = "SKIP"; 11 | 12 | // Prefligit specific environment variables, public for users 13 | pub const PREFLIGIT_HOME: &'static str = "PREFLIGIT_HOME"; 14 | pub const PREFLIGIT_ALLOW_NO_CONFIG: &'static str = "PREFLIGIT_ALLOW_NO_CONFIG"; 15 | pub const PREFLIGIT_NO_CONCURRENCY: &'static str = "PREFLIGIT_NO_CONCURRENCY"; 16 | 17 | // Prefligit internal environment variables 18 | pub const PREFLIGIT_INTERNAL__TEST_DIR: &'static str = "PREFLIGIT_INTERNAL__TEST_DIR"; 19 | pub const PREFLIGIT_INTERNAL__SORT_FILENAMES: &'static str = 20 | "PREFLIGIT_INTERNAL__SORT_FILENAMES"; 21 | pub const PREFLIGIT_INTERNAL__SKIP_POST_CHECKOUT: &'static str = 22 | "PREFLIGIT_INTERNAL__SKIP_POST_CHECKOUT"; 23 | 24 | // Other environment variables 25 | pub const UV_NO_CACHE: &'static str = "UV_NO_CACHE"; 26 | pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR"; 27 | } 28 | 29 | impl EnvVars { 30 | // Pre-commit environment variables that we support for compatibility 31 | const PRE_COMMIT_ALLOW_NO_CONFIG: &'static str = "PRE_COMMIT_ALLOW_NO_CONFIG"; 32 | const PRE_COMMIT_NO_CONCURRENCY: &'static str = "PRE_COMMIT_NO_CONCURRENCY"; 33 | } 34 | 35 | impl EnvVars { 36 | /// Read an environment variable, falling back to pre-commit corresponding variable if not found. 37 | pub fn var_os(name: &str) -> Option { 38 | #[allow(clippy::disallowed_methods)] 39 | std::env::var_os(name).or_else(|| { 40 | let name = Self::pre_commit_name(name)?; 41 | let val = std::env::var_os(name)?; 42 | info!("Falling back to pre-commit environment variable for {name}"); 43 | Some(val) 44 | }) 45 | } 46 | 47 | pub fn is_set(name: &str) -> bool { 48 | Self::var_os(name).is_some() 49 | } 50 | 51 | /// Read an environment variable, falling back to pre-commit corresponding variable if not found. 52 | pub fn var(name: &str) -> Result { 53 | match Self::var_os(name) { 54 | Some(s) => s.into_string().map_err(std::env::VarError::NotUnicode), 55 | None => Err(std::env::VarError::NotPresent), 56 | } 57 | } 58 | 59 | fn pre_commit_name(name: &str) -> Option<&str> { 60 | match name { 61 | Self::PREFLIGIT_ALLOW_NO_CONFIG => Some(Self::PRE_COMMIT_ALLOW_NO_CONFIG), 62 | Self::PREFLIGIT_NO_CONCURRENCY => Some(Self::PRE_COMMIT_NO_CONCURRENCY), 63 | _ => None, 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/constants/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod env_vars; 2 | -------------------------------------------------------------------------------- /licenses/LICENSE.identify.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Chris Kuehl, Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /licenses/LICENSE.pre-commit.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "prefligit" 3 | version = "0.0.10" 4 | description = "pre-commit reimplemented in Rust" 5 | authors = [{ name = "j178", email = "hi@j178.dev" }] 6 | requires-python = ">=3.8" 7 | keywords = [ "pre-commit", "git", "hooks" ] 8 | readme = "README.md" 9 | license = { file = "LICENSE" } 10 | classfiers = [ 11 | "Development Status :: 2 - Pre-Alpha", 12 | "Environment :: Console", 13 | "Intended Audience :: Developers", 14 | "Operating System :: OS Independent", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Rust", 17 | "Topic :: Software Development :: Quality Assurance" 18 | ] 19 | 20 | [project.urls] 21 | Repository = "https://github.com/j178/prefligit" 22 | Changelog = "https://github.com/j178/prefligit/blob/main/CHANGELOG.md" 23 | Releases = "https://github.com/j178/prefligit/releases" 24 | 25 | [build-system] 26 | requires = ["maturin>=1.0,<2.0"] 27 | build-backend = "maturin" 28 | 29 | [tool.maturin] 30 | bindings = "bin" 31 | include = [ 32 | { path = "licenses/*", format = ["wheel", "sdist"]} 33 | ] 34 | 35 | [tool.rooster] 36 | version_tag_prefix = "v" 37 | major_labels = [] # We do not use the major version number yet 38 | minor_labels = ["breaking"] 39 | changelog_ignore_labels = ["internal", "ci", "testing"] 40 | changelog_sections.breaking = "Breaking changes" 41 | changelog_sections.enhancement = "Enhancements" 42 | changelog_sections.compatibility = "Enhancements" 43 | changelog_sections.performance = "Performance" 44 | changelog_sections.bug = "Bug fixes" 45 | changelog_sections.documentation = "Documentation" 46 | changelog_sections.__unknown__ = "Other changes" 47 | changelog_contributors = true 48 | 49 | version_files = [ 50 | "README.md", 51 | "Cargo.toml", 52 | ] 53 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85" 3 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Prepare for a release 3 | # 4 | # All additional options are passed to `rooster` 5 | set -eu 6 | 7 | script_root="$(realpath "$(dirname "$0")")" 8 | project_root="$(dirname "$script_root")" 9 | 10 | echo "Updating metadata with rooster..." 11 | cd "$project_root" 12 | 13 | # Update the changelog 14 | uvx --from 'rooster-blue>=0.0.7' --python 3.12 -- rooster release "$@" 15 | 16 | echo "Updating lockfile..." 17 | cargo update 18 | -------------------------------------------------------------------------------- /src/builtin/meta_hooks.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io::Write; 3 | use std::path::PathBuf; 4 | 5 | use anyhow::Result; 6 | use fancy_regex::Regex; 7 | use itertools::Itertools; 8 | use rayon::iter::{IntoParallelIterator, ParallelIterator}; 9 | 10 | use crate::cli::run::{CollectOptions, FileFilter, collect_files}; 11 | use crate::config::Language; 12 | use crate::hook::{Hook, Project}; 13 | use crate::store::Store; 14 | 15 | /// Ensures that the configured hooks apply to at least one file in the repository. 16 | pub(crate) async fn check_hooks_apply( 17 | _hook: &Hook, 18 | filenames: &[&String], 19 | _env_vars: &HashMap<&'static str, String>, 20 | ) -> Result<(i32, Vec)> { 21 | let store = Store::from_settings()?.init()?; 22 | 23 | let input = collect_files(CollectOptions::default().with_all_files(true)).await?; 24 | 25 | let mut code = 0; 26 | let mut output = Vec::new(); 27 | 28 | for filename in filenames { 29 | let mut project = Project::from_config_file(Some(PathBuf::from(filename)))?; 30 | let hooks = project.init_hooks(&store, None).await?; 31 | 32 | let filter = FileFilter::new( 33 | &input, 34 | project.config().files.as_deref(), 35 | project.config().exclude.as_deref(), 36 | )?; 37 | 38 | for hook in hooks { 39 | if hook.always_run || matches!(hook.language, Language::Fail) { 40 | continue; 41 | } 42 | 43 | let filenames = filter.for_hook(&hook)?; 44 | 45 | if filenames.is_empty() { 46 | code = 1; 47 | writeln!(&mut output, "{} does not apply to this repository", hook.id)?; 48 | } 49 | } 50 | } 51 | 52 | Ok((code, output)) 53 | } 54 | 55 | // Returns true if the exclude patter matches any files matching the include pattern. 56 | fn excludes_any + Sync>( 57 | files: &[T], 58 | include: Option<&str>, 59 | exclude: Option<&str>, 60 | ) -> Result { 61 | if exclude.is_none_or(|s| s == "^$") { 62 | return Ok(true); 63 | } 64 | 65 | let include = include.map(Regex::new).transpose()?; 66 | let exclude = exclude.map(Regex::new).transpose()?; 67 | Ok(files.into_par_iter().any(|f| { 68 | let f = f.as_ref(); 69 | if let Some(re) = &include { 70 | if !re.is_match(f).unwrap_or(false) { 71 | return false; 72 | } 73 | } 74 | if let Some(re) = &exclude { 75 | if !re.is_match(f).unwrap_or(false) { 76 | return false; 77 | } 78 | } 79 | true 80 | })) 81 | } 82 | 83 | /// Ensures that exclude directives apply to any file in the repository. 84 | pub(crate) async fn check_useless_excludes( 85 | _hook: &Hook, 86 | filenames: &[&String], 87 | _env_vars: &HashMap<&'static str, String>, 88 | ) -> Result<(i32, Vec)> { 89 | let store = Store::from_settings()?.init()?; 90 | 91 | let input = collect_files(CollectOptions::default().with_all_files(true)).await?; 92 | 93 | let mut code = 0; 94 | let mut output = Vec::new(); 95 | 96 | for filename in filenames { 97 | let mut project = Project::from_config_file(Some(PathBuf::from(filename)))?; 98 | 99 | if !excludes_any(&input, None, project.config().exclude.as_deref())? { 100 | code = 1; 101 | writeln!( 102 | &mut output, 103 | "The global exclude pattern {:?} does not match any files", 104 | project.config().exclude.as_deref().unwrap_or("") 105 | )?; 106 | } 107 | 108 | let hooks = project.init_hooks(&store, None).await?; 109 | 110 | let filter = FileFilter::new( 111 | &input, 112 | project.config().files.as_deref(), 113 | project.config().exclude.as_deref(), 114 | )?; 115 | 116 | for hook in hooks { 117 | let filtered_files = filter.by_tag(&hook); 118 | if !excludes_any( 119 | &filtered_files, 120 | hook.files.as_deref(), 121 | hook.exclude.as_deref(), 122 | )? { 123 | code = 1; 124 | writeln!( 125 | &mut output, 126 | "The exclude pattern {:?} for {} does not match any files", 127 | hook.exclude.as_deref().unwrap_or(""), 128 | hook.id 129 | )?; 130 | } 131 | } 132 | } 133 | 134 | Ok((code, output)) 135 | } 136 | 137 | /// Prints all arguments passed to the hook. Useful for debugging. 138 | pub fn identity( 139 | _hook: &Hook, 140 | filenames: &[&String], 141 | _env_vars: &HashMap<&'static str, String>, 142 | ) -> (i32, Vec) { 143 | (0, filenames.iter().join("\n").into_bytes()) 144 | } 145 | -------------------------------------------------------------------------------- /src/builtin/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::str::FromStr; 3 | 4 | use crate::builtin::pre_commit_hooks::{Implemented, is_pre_commit_hooks}; 5 | use crate::hook::{Hook, Repo}; 6 | 7 | mod meta_hooks; 8 | mod pre_commit_hooks; 9 | 10 | /// Returns true if the hook has a builtin Rust implementation. 11 | pub fn check_fast_path(hook: &Hook) -> bool { 12 | match hook.repo() { 13 | Repo::Meta { .. } => true, 14 | Repo::Remote { url, .. } if is_pre_commit_hooks(url) => { 15 | Implemented::from_str(hook.id.as_str()).is_ok() 16 | } 17 | _ => false, 18 | } 19 | } 20 | 21 | pub async fn run_fast_path( 22 | hook: &Hook, 23 | filenames: &[&String], 24 | env_vars: &HashMap<&'static str, String>, 25 | ) -> anyhow::Result<(i32, Vec)> { 26 | match hook.repo() { 27 | Repo::Meta { .. } => run_meta_hook(hook, filenames, env_vars).await, 28 | Repo::Remote { url, .. } if is_pre_commit_hooks(url) => { 29 | Implemented::from_str(hook.id.as_str()) 30 | .unwrap() 31 | .run(hook, filenames, env_vars) 32 | .await 33 | } 34 | _ => unreachable!(), 35 | } 36 | } 37 | 38 | async fn run_meta_hook( 39 | hook: &Hook, 40 | filenames: &[&String], 41 | env_vars: &HashMap<&'static str, String>, 42 | ) -> anyhow::Result<(i32, Vec)> { 43 | match hook.id.as_str() { 44 | "check-hooks-apply" => meta_hooks::check_hooks_apply(hook, filenames, env_vars).await, 45 | "check-useless-excludes" => { 46 | meta_hooks::check_useless_excludes(hook, filenames, env_vars).await 47 | } 48 | "identity" => Ok(meta_hooks::identity(hook, filenames, env_vars)), 49 | _ => unreachable!(), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/builtin/pre_commit_hooks/check_added_large_files.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use clap::Parser; 4 | use futures::StreamExt; 5 | 6 | use crate::git::{intent_to_add_files, lfs_files}; 7 | use crate::hook::Hook; 8 | use crate::run::CONCURRENCY; 9 | 10 | enum FileFilter { 11 | NoFilter, 12 | Files(HashSet), 13 | } 14 | 15 | impl FileFilter { 16 | fn contains(&self, path: &str) -> bool { 17 | match self { 18 | FileFilter::NoFilter => true, 19 | FileFilter::Files(files) => files.contains(path), 20 | } 21 | } 22 | } 23 | 24 | #[derive(Parser)] 25 | struct Args { 26 | #[arg(long)] 27 | enforce_all: bool, 28 | #[arg(default_value = "500")] 29 | max_kb: u64, 30 | } 31 | 32 | pub(crate) async fn check_added_large_files( 33 | hook: &Hook, 34 | filenames: &[&String], 35 | _env_vars: &HashMap<&'static str, String>, 36 | ) -> anyhow::Result<(i32, Vec)> { 37 | let entry = shlex::split(&hook.entry).ok_or(anyhow::anyhow!("Failed to parse entry"))?; 38 | let args = Args::try_parse_from(entry.iter().chain(&hook.args))?; 39 | 40 | let filter = if args.enforce_all { 41 | FileFilter::NoFilter 42 | } else { 43 | let add_files: HashSet<_> = intent_to_add_files().await?.into_iter().collect(); 44 | FileFilter::Files(add_files) 45 | }; 46 | 47 | let lfs_files = lfs_files::>(filenames).await?; 48 | let mut tasks = futures::stream::iter( 49 | filenames 50 | .iter() 51 | .filter(|f| filter.contains(f)) 52 | .filter(|f| !lfs_files.contains(f.as_str())), 53 | ) 54 | .map(async |filename| { 55 | let size = fs_err::tokio::metadata(filename).await?.len(); 56 | let size = size / 1024; 57 | if size > args.max_kb { 58 | anyhow::Ok(Some(format!( 59 | "{filename} ({size} KB) exceeds {} KB\n", 60 | args.max_kb 61 | ))) 62 | } else { 63 | anyhow::Ok(None) 64 | } 65 | }) 66 | .buffered(*CONCURRENCY); 67 | 68 | let mut code = 0; 69 | let mut output = Vec::new(); 70 | 71 | while let Some(result) = tasks.next().await { 72 | if let Some(e) = result? { 73 | code = 1; 74 | output.extend(e.into_bytes()); 75 | } 76 | } 77 | 78 | Ok((code, output)) 79 | } 80 | -------------------------------------------------------------------------------- /src/builtin/pre_commit_hooks/fix_trailing_whitespace.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::Path; 3 | 4 | use anyhow::Result; 5 | use bstr::ByteSlice; 6 | use clap::Parser; 7 | use futures::StreamExt; 8 | 9 | use crate::hook::Hook; 10 | use crate::run::CONCURRENCY; 11 | 12 | #[derive(Parser)] 13 | struct Args { 14 | #[arg(long)] 15 | markdown_linebreak_ext: Vec, 16 | #[arg(long)] 17 | chars: Vec, 18 | } 19 | 20 | pub(crate) async fn fix_trailing_whitespace( 21 | hook: &Hook, 22 | filenames: &[&String], 23 | _env_vars: &HashMap<&'static str, String>, 24 | ) -> Result<(i32, Vec)> { 25 | let entry = shlex::split(&hook.entry).ok_or(anyhow::anyhow!("Failed to parse entry"))?; 26 | let args = Args::try_parse_from(entry.iter().chain(&hook.args))?; 27 | 28 | let force_markdown = args.markdown_linebreak_ext.iter().any(|ext| ext == "*"); 29 | let markdown_exts = args 30 | .markdown_linebreak_ext 31 | .iter() 32 | .flat_map(|ext| ext.split(',')) 33 | .map(|ext| format!(".{}", ext.trim_start_matches('.')).to_ascii_lowercase()) 34 | .collect::>(); 35 | let chars = if args.chars.is_empty() { 36 | None 37 | } else { 38 | Some(args.chars) 39 | }; 40 | 41 | // Validate extensions don't contain path separators 42 | for ext in &markdown_exts { 43 | if ext[1..] 44 | .chars() 45 | .any(|c| matches!(c, '.' | '/' | '\\' | ':')) 46 | { 47 | return Err(anyhow::anyhow!( 48 | "bad --markdown-linebreak-ext extension '{ext}' (has . / \\ :)" 49 | )); 50 | } 51 | } 52 | 53 | let mut tasks = futures::stream::iter(filenames) 54 | .map(async |filename| { 55 | let ext = Path::new(filename) 56 | .extension() 57 | .and_then(|ext| ext.to_str()) 58 | .map(|ext| format!(".{}", ext.to_ascii_lowercase())); 59 | let is_markdown = force_markdown || ext.is_some_and(|ext| markdown_exts.contains(&ext)); 60 | 61 | // TODO: read file in chunks 62 | let content = fs_err::tokio::read(filename).await?; 63 | 64 | let mut modified = false; 65 | let mut output = Vec::new(); 66 | 67 | for mut line in content.split_inclusive(|&b| b == b'\n') { 68 | let eol = if line.ends_with(b"\r\n") { 69 | line = &line[..line.len() - 2]; 70 | b"\r\n".as_slice() 71 | } else if line.ends_with(b"\n") { 72 | line = &line[..line.len() - 1]; 73 | b"\n".as_slice() 74 | } else { 75 | b"".as_slice() 76 | }; 77 | 78 | if line.is_empty() { 79 | output.extend_from_slice(eol); 80 | continue; 81 | } 82 | 83 | let output_len = output.len(); 84 | 85 | if is_markdown 86 | && !line.iter().all(|&b| b.is_ascii_whitespace()) 87 | && line.ends_with(b" ") 88 | { 89 | // Preserve trailing two spaces for markdown, but trim any additional whitespace 90 | let trimmed = if let Some(chars) = chars.as_deref() { 91 | line[..line.len() - 2].trim_end_with(|b| chars.contains(&b)) 92 | } else { 93 | line[..line.len() - 2].trim_ascii_end() 94 | }; 95 | output.extend_from_slice(trimmed); 96 | output.extend_from_slice(b" "); 97 | output.extend_from_slice(eol); 98 | } else { 99 | // Normal whitespace trimming 100 | let trimmed = if let Some(chars) = chars.as_deref() { 101 | line.trim_end_with(|b| chars.contains(&b)) 102 | } else { 103 | line.trim_ascii_end() 104 | }; 105 | output.extend_from_slice(trimmed); 106 | output.extend_from_slice(eol); 107 | }; 108 | 109 | if line.len() + eol.len() != output.len() - output_len { 110 | modified = true; 111 | } 112 | } 113 | 114 | if modified { 115 | fs_err::tokio::write(filename, &output).await?; 116 | anyhow::Ok((1, format!("Fixing {filename}\n").into_bytes())) 117 | } else { 118 | anyhow::Ok((0, Vec::new())) 119 | } 120 | }) 121 | .buffered(*CONCURRENCY); 122 | 123 | let mut code = 0; 124 | let mut output = Vec::new(); 125 | 126 | while let Some(result) = tasks.next().await { 127 | let (c, o) = result?; 128 | code |= c; 129 | output.extend(o); 130 | } 131 | 132 | Ok((code, output)) 133 | } 134 | -------------------------------------------------------------------------------- /src/builtin/pre_commit_hooks/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::str::FromStr; 3 | 4 | use anyhow::Result; 5 | use url::Url; 6 | 7 | use crate::hook::Hook; 8 | 9 | mod check_added_large_files; 10 | mod fix_trailing_whitespace; 11 | 12 | pub(crate) enum Implemented { 13 | TrailingWhitespace, 14 | CheckAddedLargeFiles, 15 | } 16 | 17 | impl FromStr for Implemented { 18 | type Err = (); 19 | 20 | fn from_str(s: &str) -> Result { 21 | match s { 22 | "trailing-whitespace" => Ok(Self::TrailingWhitespace), 23 | "check-added-large-files" => Ok(Self::CheckAddedLargeFiles), 24 | _ => Err(()), 25 | } 26 | } 27 | } 28 | 29 | impl Implemented { 30 | pub(crate) async fn run( 31 | self, 32 | hook: &Hook, 33 | filenames: &[&String], 34 | env_vars: &HashMap<&'static str, String>, 35 | ) -> Result<(i32, Vec)> { 36 | match self { 37 | Self::TrailingWhitespace => { 38 | fix_trailing_whitespace::fix_trailing_whitespace(hook, filenames, env_vars).await 39 | } 40 | Self::CheckAddedLargeFiles => { 41 | check_added_large_files::check_added_large_files(hook, filenames, env_vars).await 42 | } 43 | } 44 | } 45 | } 46 | 47 | // TODO: compare rev 48 | pub(crate) fn is_pre_commit_hooks(url: &Url) -> bool { 49 | url.host_str() == Some("github.com") && url.path() == "/pre-commit/pre-commit-hooks" 50 | } 51 | -------------------------------------------------------------------------------- /src/cleanup.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | static CLEANUP_HOOKS: Mutex>> = Mutex::new(Vec::new()); 4 | 5 | /// Run all cleanup functions. 6 | pub fn cleanup() { 7 | let mut cleanup = CLEANUP_HOOKS.lock().unwrap(); 8 | for f in cleanup.drain(..) { 9 | f(); 10 | } 11 | } 12 | 13 | /// Add a cleanup function to be run when the program is interrupted. 14 | pub fn add_cleanup(f: F) { 15 | let mut cleanup = CLEANUP_HOOKS.lock().unwrap(); 16 | cleanup.push(Box::new(f)); 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/clean.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use anyhow::Result; 4 | use owo_colors::OwoColorize; 5 | 6 | use crate::cli::ExitStatus; 7 | use crate::fs::Simplified; 8 | use crate::printer::Printer; 9 | use crate::store::Store; 10 | 11 | pub(crate) fn clean(printer: Printer) -> Result { 12 | let store = Store::from_settings()?; 13 | 14 | if !store.path().exists() { 15 | writeln!(printer.stdout(), "Nothing to clean")?; 16 | return Ok(ExitStatus::Success); 17 | } 18 | 19 | fs_err::remove_dir_all(store.path())?; 20 | writeln!( 21 | printer.stdout(), 22 | "Cleaned `{}`", 23 | store.path().user_display().cyan() 24 | )?; 25 | 26 | Ok(ExitStatus::Success) 27 | } 28 | -------------------------------------------------------------------------------- /src/cli/hook_impl.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::ffi::OsString; 3 | use std::path::PathBuf; 4 | 5 | use anstream::eprintln; 6 | 7 | use constants::env_vars::EnvVars; 8 | 9 | use crate::cli::{self, ExitStatus, RunArgs}; 10 | use crate::config::HookType; 11 | use crate::printer::Printer; 12 | 13 | pub(crate) async fn hook_impl( 14 | config: Option, 15 | hook_type: HookType, 16 | _hook_dir: PathBuf, 17 | skip_on_missing_config: bool, 18 | args: Vec, 19 | printer: Printer, 20 | ) -> Result { 21 | // TODO: run in legacy mode 22 | 23 | if let Some(ref config_file) = config { 24 | if !config_file.try_exists()? { 25 | return if skip_on_missing_config || EnvVars::is_set(EnvVars::PREFLIGIT_ALLOW_NO_CONFIG) 26 | { 27 | Ok(ExitStatus::Success) 28 | } else { 29 | eprintln!("Config file not found: {}", config_file.display()); 30 | eprintln!( 31 | "- To temporarily silence this, run `{}=1 git ...`", 32 | EnvVars::PREFLIGIT_ALLOW_NO_CONFIG 33 | ); 34 | eprintln!( 35 | "- To permanently silence this, install hooks with the `--allow-missing-config` flag" 36 | ); 37 | eprintln!("- To uninstall hooks, run `prefligit uninstall`"); 38 | Ok(ExitStatus::Failure) 39 | }; 40 | } 41 | } 42 | 43 | if !hook_type.num_args().contains(&args.len()) { 44 | eprintln!("Invalid number of arguments for hook: {}", hook_type); 45 | return Ok(ExitStatus::Failure); 46 | } 47 | 48 | let run_args = to_run_args(hook_type, &args); 49 | 50 | cli::run( 51 | config, 52 | run_args.hook_id, 53 | Some(hook_type.into()), 54 | run_args.from_ref, 55 | run_args.to_ref, 56 | run_args.all_files, 57 | vec![], 58 | false, 59 | run_args.extra, 60 | false, 61 | printer, 62 | ) 63 | .await 64 | } 65 | 66 | fn to_run_args(hook_type: HookType, args: &[OsString]) -> RunArgs { 67 | let mut run_args = RunArgs::default(); 68 | 69 | match hook_type { 70 | HookType::PrePush => { 71 | run_args.extra.remote_name = Some(args[0].to_string_lossy().into_owned()); 72 | run_args.extra.remote_url = Some(args[1].to_string_lossy().into_owned()); 73 | // TODO: implement pre-push 74 | } 75 | HookType::CommitMsg => { 76 | run_args.extra.commit_msg_filename = Some(PathBuf::from(&args[0])); 77 | } 78 | HookType::PrepareCommitMsg => { 79 | run_args.extra.commit_msg_filename = Some(PathBuf::from(&args[0])); 80 | if args.len() > 1 { 81 | run_args.extra.prepare_commit_message_source = 82 | Some(args[1].to_string_lossy().into_owned()); 83 | } 84 | if args.len() > 2 { 85 | run_args.extra.commit_object_name = Some(args[2].to_string_lossy().into_owned()); 86 | } 87 | } 88 | HookType::PostCheckout => { 89 | run_args.from_ref = Some(args[0].to_string_lossy().into_owned()); 90 | run_args.to_ref = Some(args[1].to_string_lossy().into_owned()); 91 | run_args.extra.checkout_type = Some(args[2].to_string_lossy().into_owned()); 92 | } 93 | HookType::PostMerge => run_args.extra.is_squash_merge = args[0] == "1", 94 | HookType::PostRewrite => { 95 | run_args.extra.rewrite_command = Some(args[0].to_string_lossy().into_owned()); 96 | } 97 | HookType::PreRebase => { 98 | run_args.extra.pre_rebase_upstream = Some(args[0].to_string_lossy().into_owned()); 99 | if args.len() > 1 { 100 | run_args.extra.pre_rebase_branch = Some(args[1].to_string_lossy().into_owned()); 101 | } 102 | } 103 | HookType::PostCommit | HookType::PreMergeCommit | HookType::PreCommit => {} 104 | } 105 | 106 | run_args 107 | } 108 | -------------------------------------------------------------------------------- /src/cli/install.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write as _; 2 | use std::io::Write; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use anyhow::Result; 6 | use indoc::indoc; 7 | use owo_colors::OwoColorize; 8 | use same_file::is_same_file; 9 | 10 | use crate::cli::reporter::{HookInitReporter, HookInstallReporter}; 11 | use crate::cli::run; 12 | use crate::cli::{ExitStatus, HookType}; 13 | use crate::fs::Simplified; 14 | use crate::git; 15 | use crate::git::git_cmd; 16 | use crate::hook::Project; 17 | use crate::printer::Printer; 18 | use crate::store::Store; 19 | 20 | pub(crate) async fn install( 21 | config: Option, 22 | hook_types: Vec, 23 | install_hooks: bool, 24 | overwrite: bool, 25 | allow_missing_config: bool, 26 | printer: Printer, 27 | git_dir: Option<&Path>, 28 | ) -> Result { 29 | if git_dir.is_none() && git::has_hooks_path_set().await? { 30 | writeln!( 31 | printer.stderr(), 32 | indoc::indoc! {" 33 | Cowardly refusing to install hooks with `core.hooksPath` set. 34 | hint: `git config --unset-all core.hooksPath` to fix this. 35 | "} 36 | )?; 37 | return Ok(ExitStatus::Failure); 38 | } 39 | 40 | let hook_types = get_hook_types(config.clone(), hook_types); 41 | 42 | let hooks_path = if let Some(dir) = git_dir { 43 | dir.join("hooks") 44 | } else { 45 | git::get_git_common_dir().await?.join("hooks") 46 | }; 47 | 48 | fs_err::create_dir_all(&hooks_path)?; 49 | 50 | let project = Project::from_config_file(config); 51 | let config_file = project.as_ref().ok().map(Project::config_file); 52 | for hook_type in hook_types { 53 | install_hook_script( 54 | config_file, 55 | hook_type, 56 | &hooks_path, 57 | overwrite, 58 | allow_missing_config, 59 | printer, 60 | )?; 61 | } 62 | 63 | if install_hooks { 64 | let mut project = project?; 65 | let store = Store::from_settings()?.init()?; 66 | let _lock = store.lock_async().await?; 67 | 68 | let reporter = HookInitReporter::from(printer); 69 | let hooks = project.init_hooks(&store, Some(&reporter)).await?; 70 | let reporter = HookInstallReporter::from(printer); 71 | run::install_hooks(&hooks, &store, &reporter).await?; 72 | } 73 | 74 | Ok(ExitStatus::Success) 75 | } 76 | 77 | fn get_hook_types(config_file: Option, hook_types: Vec) -> Vec { 78 | let project = Project::from_config_file(config_file); 79 | 80 | let mut hook_types = if hook_types.is_empty() { 81 | if let Ok(ref project) = project { 82 | project 83 | .config() 84 | .default_install_hook_types 85 | .clone() 86 | .unwrap_or_default() 87 | } else { 88 | vec![] 89 | } 90 | } else { 91 | hook_types 92 | }; 93 | if hook_types.is_empty() { 94 | hook_types = vec![HookType::PreCommit]; 95 | } 96 | 97 | hook_types 98 | } 99 | 100 | fn install_hook_script( 101 | config_file: Option<&Path>, 102 | hook_type: HookType, 103 | hooks_path: &Path, 104 | overwrite: bool, 105 | skip_on_missing_config: bool, 106 | printer: Printer, 107 | ) -> Result<()> { 108 | let hook_path = hooks_path.join(hook_type.as_str()); 109 | 110 | if hook_path.try_exists()? { 111 | if overwrite { 112 | writeln!( 113 | printer.stdout(), 114 | "Overwriting existing hook at {}", 115 | hook_path.user_display().cyan() 116 | )?; 117 | } else { 118 | if !is_our_script(&hook_path)? { 119 | let legacy_path = format!("{}.legacy", hook_path.display()); 120 | fs_err::rename(&hook_path, &legacy_path)?; 121 | writeln!( 122 | printer.stdout(), 123 | "Hook already exists at {}, move it to {}.", 124 | hook_path.user_display().cyan(), 125 | legacy_path.user_display().yellow() 126 | )?; 127 | } 128 | } 129 | } 130 | 131 | let mut args = vec![ 132 | "hook-impl".to_string(), 133 | format!("--hook-type={}", hook_type.as_str()), 134 | ]; 135 | if let Some(config_file) = config_file { 136 | args.push(format!(r#"--config="{}""#, config_file.user_display())); 137 | } 138 | if skip_on_missing_config { 139 | args.push("--skip-on-missing-config".to_string()); 140 | } 141 | 142 | let prefligit = std::env::current_exe()?; 143 | let pre_commit = prefligit.simplified().display().to_string(); 144 | let hook_script = HOOK_TMPL 145 | .replace("ARGS=(hook-impl)", &format!("ARGS=({})", args.join(" "))) 146 | .replace( 147 | r#"PREFLIGIT="prefligit""#, 148 | &format!(r#"PREFLIGIT="{pre_commit}""#), 149 | ); 150 | fs_err::OpenOptions::new() 151 | .write(true) 152 | .create(true) 153 | .truncate(true) 154 | .open(&hook_path)? 155 | .write_all(hook_script.as_bytes())?; 156 | 157 | #[cfg(unix)] 158 | { 159 | use std::os::unix::fs::PermissionsExt; 160 | 161 | let mut perms = hook_path.metadata()?.permissions(); 162 | perms.set_mode(0o755); 163 | fs_err::set_permissions(&hook_path, perms)?; 164 | } 165 | 166 | writeln!( 167 | printer.stdout(), 168 | "prefligit installed at {}", 169 | hook_path.user_display().cyan() 170 | )?; 171 | 172 | Ok(()) 173 | } 174 | 175 | static HOOK_TMPL: &str = indoc! { r#" 176 | #!/usr/bin/env bash 177 | # File generated by prefligit: https://github.com/j178/prefligit 178 | # ID: 182c10f181da4464a3eec51b83331688 179 | 180 | ARGS=(hook-impl) 181 | 182 | HERE="$(cd "$(dirname "$0")" && pwd)" 183 | ARGS+=(--hook-dir "$HERE" -- "$@") 184 | PREFLIGIT="prefligit" 185 | 186 | exec "$PREFLIGIT" "${ARGS[@]}" 187 | 188 | "# }; 189 | 190 | static PRIOR_HASHES: &[&str] = &[]; 191 | 192 | // Use a different hash for each change to the script. 193 | // Use a different hash from `pre-commit` since our script is different. 194 | static CURRENT_HASH: &str = "182c10f181da4464a3eec51b83331688"; 195 | 196 | /// Checks if the script contains any of the hashes that `prefligit` has used in the past. 197 | fn is_our_script(hook_path: &Path) -> Result { 198 | let content = fs_err::read_to_string(hook_path)?; 199 | Ok(std::iter::once(CURRENT_HASH) 200 | .chain(PRIOR_HASHES.iter().copied()) 201 | .any(|hash| content.contains(hash))) 202 | } 203 | 204 | pub(crate) async fn uninstall( 205 | config: Option, 206 | hook_types: Vec, 207 | printer: Printer, 208 | ) -> Result { 209 | for hook_type in get_hook_types(config, hook_types) { 210 | let hooks_path = git::get_git_common_dir().await?.join("hooks"); 211 | let hook_path = hooks_path.join(hook_type.as_str()); 212 | let legacy_path = hooks_path.join(format!("{}.legacy", hook_type.as_str())); 213 | 214 | if !hook_path.try_exists()? { 215 | writeln!( 216 | printer.stderr(), 217 | "{} does not exist, skipping.", 218 | hook_path.user_display().cyan() 219 | )?; 220 | } else if !is_our_script(&hook_path)? { 221 | writeln!( 222 | printer.stderr(), 223 | "{} is not managed by prefligit, skipping.", 224 | hook_path.user_display().cyan() 225 | )?; 226 | } else { 227 | fs_err::remove_file(&hook_path)?; 228 | writeln!( 229 | printer.stdout(), 230 | "Uninstalled {}", 231 | hook_type.as_str().cyan() 232 | )?; 233 | 234 | if legacy_path.try_exists()? { 235 | fs_err::rename(&legacy_path, &hook_path)?; 236 | writeln!( 237 | printer.stdout(), 238 | "Restored previous hook to {}", 239 | hook_path.user_display().cyan() 240 | )?; 241 | } 242 | } 243 | } 244 | 245 | Ok(ExitStatus::Success) 246 | } 247 | 248 | pub(crate) async fn init_template_dir( 249 | directory: PathBuf, 250 | config: Option, 251 | hook_types: Vec, 252 | requires_config: bool, 253 | printer: Printer, 254 | ) -> Result { 255 | install( 256 | config, 257 | hook_types, 258 | false, 259 | true, 260 | !requires_config, 261 | printer, 262 | Some(&directory), 263 | ) 264 | .await?; 265 | 266 | let output = git_cmd("git config")? 267 | .arg("config") 268 | .arg("init.templateDir") 269 | .check(false) 270 | .output() 271 | .await?; 272 | let template_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); 273 | 274 | if template_dir.is_empty() || !is_same_file(&directory, Path::new(&template_dir))? { 275 | writeln!( 276 | printer.stderr(), 277 | "{}", 278 | indoc::formatdoc! {" 279 | `init.templateDir` not set to the target directory 280 | try `git config --global init.templateDir '{directory}'`? 281 | ", directory = directory.user_display().cyan() } 282 | )?; 283 | } 284 | 285 | Ok(ExitStatus::Success) 286 | } 287 | -------------------------------------------------------------------------------- /src/cli/reporter.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::collections::HashMap; 3 | use std::sync::{Arc, Mutex}; 4 | use std::time::Duration; 5 | 6 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 7 | use owo_colors::OwoColorize; 8 | 9 | use crate::hook; 10 | use crate::hook::Hook; 11 | use crate::printer::Printer; 12 | 13 | #[derive(Default, Debug)] 14 | struct BarState { 15 | /// A map of progress bars, by ID. 16 | bars: HashMap, 17 | /// A monotonic counter for bar IDs. 18 | id: usize, 19 | } 20 | 21 | impl BarState { 22 | /// Returns a unique ID for a new progress bar. 23 | fn id(&mut self) -> usize { 24 | self.id += 1; 25 | self.id 26 | } 27 | } 28 | 29 | struct ProgressReporter { 30 | printer: Printer, 31 | root: ProgressBar, 32 | state: Arc>, 33 | children: MultiProgress, 34 | } 35 | 36 | impl ProgressReporter { 37 | fn new(root: ProgressBar, children: MultiProgress, printer: Printer) -> Self { 38 | Self { 39 | printer, 40 | root, 41 | state: Arc::default(), 42 | children, 43 | } 44 | } 45 | 46 | fn on_start(&self, msg: impl Into>) -> usize { 47 | let mut state = self.state.lock().unwrap(); 48 | let id = state.id(); 49 | 50 | let progress = self.children.insert_before( 51 | &self.root, 52 | ProgressBar::with_draw_target(None, self.printer.target()), 53 | ); 54 | 55 | progress.set_style(ProgressStyle::with_template("{wide_msg}").unwrap()); 56 | progress.set_message(msg); 57 | 58 | state.bars.insert(id, progress); 59 | id 60 | } 61 | 62 | fn on_progress(&self, id: usize) { 63 | let progress = { 64 | let mut state = self.state.lock().unwrap(); 65 | state.bars.remove(&id).unwrap() 66 | }; 67 | 68 | self.root.inc(1); 69 | progress.finish_and_clear(); 70 | } 71 | 72 | fn on_complete(&self) { 73 | self.root.set_message(""); 74 | self.root.finish_and_clear(); 75 | } 76 | } 77 | 78 | pub(crate) struct HookInitReporter { 79 | reporter: ProgressReporter, 80 | } 81 | 82 | impl From for HookInitReporter { 83 | fn from(printer: Printer) -> Self { 84 | let multi = MultiProgress::with_draw_target(printer.target()); 85 | let root = multi.add(ProgressBar::with_draw_target(None, printer.target())); 86 | root.enable_steady_tick(Duration::from_millis(200)); 87 | root.set_style( 88 | ProgressStyle::with_template("{spinner:.white} {msg:.dim}") 89 | .unwrap() 90 | .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), 91 | ); 92 | root.set_message("Initializing hooks..."); 93 | 94 | let reporter = ProgressReporter::new(root, multi, printer); 95 | Self { reporter } 96 | } 97 | } 98 | 99 | impl hook::HookInitReporter for HookInitReporter { 100 | fn on_clone_start(&self, repo: &str) -> usize { 101 | self.reporter 102 | .on_start(format!("{} {}", "Cloning".bold().cyan(), repo.dimmed())) 103 | } 104 | 105 | fn on_clone_complete(&self, id: usize) { 106 | self.reporter.on_progress(id); 107 | } 108 | 109 | fn on_complete(&self) { 110 | self.reporter.on_complete(); 111 | } 112 | } 113 | 114 | pub struct HookInstallReporter { 115 | reporter: ProgressReporter, 116 | } 117 | 118 | impl From for HookInstallReporter { 119 | fn from(printer: Printer) -> Self { 120 | let multi = MultiProgress::with_draw_target(printer.target()); 121 | let root = multi.add(ProgressBar::with_draw_target(None, printer.target())); 122 | root.enable_steady_tick(Duration::from_millis(200)); 123 | root.set_style( 124 | ProgressStyle::with_template("{spinner:.white} {msg:.dim}") 125 | .unwrap() 126 | .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), 127 | ); 128 | root.set_message("Installing hooks..."); 129 | 130 | let reporter = ProgressReporter::new(root, multi, printer); 131 | Self { reporter } 132 | } 133 | } 134 | 135 | impl HookInstallReporter { 136 | pub fn on_install_start(&self, hook: &Hook) -> usize { 137 | self.reporter.on_start(format!( 138 | "{} {}", 139 | "Installing".bold().cyan(), 140 | hook.id.dimmed(), 141 | )) 142 | } 143 | 144 | pub fn on_install_complete(&self, id: usize) { 145 | self.reporter.on_progress(id); 146 | } 147 | 148 | pub fn on_complete(&self) { 149 | self.reporter.on_complete(); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/cli/run/filter.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use anyhow::Result; 4 | use fancy_regex as regex; 5 | use fancy_regex::Regex; 6 | use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; 7 | use tracing::{debug, error}; 8 | 9 | use constants::env_vars::EnvVars; 10 | 11 | use crate::config::Stage; 12 | use crate::fs::normalize_path; 13 | use crate::git; 14 | use crate::hook::Hook; 15 | use crate::identify::tags_from_path; 16 | 17 | /// Filter filenames by include/exclude patterns. 18 | pub struct FilenameFilter { 19 | include: Option, 20 | exclude: Option, 21 | } 22 | 23 | impl FilenameFilter { 24 | pub fn new(include: Option<&str>, exclude: Option<&str>) -> Result> { 25 | let include = include.map(Regex::new).transpose()?; 26 | let exclude = exclude.map(Regex::new).transpose()?; 27 | Ok(Self { include, exclude }) 28 | } 29 | 30 | pub fn filter(&self, filename: impl AsRef) -> bool { 31 | let filename = filename.as_ref(); 32 | if let Some(re) = &self.include { 33 | if !re.is_match(filename).unwrap_or(false) { 34 | return false; 35 | } 36 | } 37 | if let Some(re) = &self.exclude { 38 | if re.is_match(filename).unwrap_or(false) { 39 | return false; 40 | } 41 | } 42 | true 43 | } 44 | 45 | pub fn from_hook(hook: &Hook) -> Result> { 46 | Self::new(hook.files.as_deref(), hook.exclude.as_deref()) 47 | } 48 | } 49 | 50 | /// Filter files by tags. 51 | struct FileTagFilter<'a> { 52 | all: &'a [String], 53 | any: &'a [String], 54 | exclude: &'a [String], 55 | } 56 | 57 | impl<'a> FileTagFilter<'a> { 58 | fn new(types: &'a [String], types_or: &'a [String], exclude_types: &'a [String]) -> Self { 59 | Self { 60 | all: types, 61 | any: types_or, 62 | exclude: exclude_types, 63 | } 64 | } 65 | 66 | fn filter(&self, file_types: &[&str]) -> bool { 67 | if !self.all.is_empty() && !self.all.iter().all(|t| file_types.contains(&t.as_str())) { 68 | return false; 69 | } 70 | if !self.any.is_empty() && !self.any.iter().any(|t| file_types.contains(&t.as_str())) { 71 | return false; 72 | } 73 | if self 74 | .exclude 75 | .iter() 76 | .any(|t| file_types.contains(&t.as_str())) 77 | { 78 | return false; 79 | } 80 | true 81 | } 82 | 83 | fn from_hook(hook: &'a Hook) -> Self { 84 | Self::new(&hook.types, &hook.types_or, &hook.exclude_types) 85 | } 86 | } 87 | 88 | pub struct FileFilter<'a> { 89 | filenames: Vec<&'a String>, 90 | } 91 | 92 | impl<'a> FileFilter<'a> { 93 | pub fn new( 94 | filenames: &'a [String], 95 | include: Option<&str>, 96 | exclude: Option<&str>, 97 | ) -> Result> { 98 | let filter = FilenameFilter::new(include, exclude)?; 99 | 100 | let filenames = filenames 101 | .into_par_iter() 102 | .filter(|filename| filter.filter(filename)) 103 | .filter(|filename| { 104 | // TODO: does this check really necessary? 105 | // Ignore not existing files. 106 | std::fs::symlink_metadata(filename) 107 | .map(|m| m.file_type().is_file()) 108 | .unwrap_or(false) 109 | }) 110 | .collect::>(); 111 | 112 | Ok(Self { filenames }) 113 | } 114 | 115 | pub fn len(&self) -> usize { 116 | self.filenames.len() 117 | } 118 | 119 | pub fn by_tag(&self, hook: &Hook) -> Vec<&String> { 120 | let filter = FileTagFilter::from_hook(hook); 121 | let filenames: Vec<_> = self 122 | .filenames 123 | .par_iter() 124 | .filter(|filename| { 125 | let path = Path::new(filename); 126 | match tags_from_path(path) { 127 | Ok(tags) => filter.filter(&tags), 128 | Err(err) => { 129 | error!(filename, error = %err, "Failed to get tags"); 130 | false 131 | } 132 | } 133 | }) 134 | .copied() 135 | .collect(); 136 | 137 | filenames 138 | } 139 | 140 | pub fn for_hook(&self, hook: &Hook) -> Result, Box> { 141 | let filter = FilenameFilter::from_hook(hook)?; 142 | let filenames = self 143 | .filenames 144 | .par_iter() 145 | .filter(|filename| filter.filter(filename)); 146 | 147 | let filter = FileTagFilter::from_hook(hook); 148 | let filenames: Vec<_> = filenames 149 | .filter(|filename| { 150 | let path = Path::new(filename); 151 | match tags_from_path(path) { 152 | Ok(tags) => filter.filter(&tags), 153 | Err(err) => { 154 | error!(filename, error = %err, "Failed to get tags"); 155 | false 156 | } 157 | } 158 | }) 159 | .copied() 160 | .collect(); 161 | 162 | Ok(filenames) 163 | } 164 | } 165 | 166 | #[derive(Default)] 167 | pub struct CollectOptions { 168 | pub hook_stage: Option, 169 | pub from_ref: Option, 170 | pub to_ref: Option, 171 | pub all_files: bool, 172 | pub files: Vec, 173 | pub commit_msg_filename: Option, 174 | } 175 | 176 | impl CollectOptions { 177 | pub fn with_all_files(mut self, all_files: bool) -> Self { 178 | self.all_files = all_files; 179 | self 180 | } 181 | } 182 | 183 | /// Get all filenames to run hooks on. 184 | #[allow(clippy::too_many_arguments)] 185 | pub async fn collect_files(opts: CollectOptions) -> Result> { 186 | let CollectOptions { 187 | hook_stage, 188 | from_ref, 189 | to_ref, 190 | all_files, 191 | files, 192 | commit_msg_filename, 193 | } = opts; 194 | 195 | let mut filenames = collect_files_from_args( 196 | hook_stage, 197 | from_ref, 198 | to_ref, 199 | all_files, 200 | files, 201 | commit_msg_filename, 202 | ) 203 | .await?; 204 | 205 | // Sort filenames if in tests to make the order consistent. 206 | if EnvVars::is_set(EnvVars::PREFLIGIT_INTERNAL__SORT_FILENAMES) { 207 | filenames.sort_unstable(); 208 | } 209 | 210 | for filename in &mut filenames { 211 | normalize_path(filename); 212 | } 213 | Ok(filenames) 214 | } 215 | 216 | #[allow(clippy::too_many_arguments)] 217 | async fn collect_files_from_args( 218 | hook_stage: Option, 219 | from_ref: Option, 220 | to_ref: Option, 221 | all_files: bool, 222 | files: Vec, 223 | commit_msg_filename: Option, 224 | ) -> Result> { 225 | if let Some(hook_stage) = hook_stage { 226 | if !hook_stage.operate_on_files() { 227 | return Ok(vec![]); 228 | } 229 | if hook_stage == Stage::PrepareCommitMsg || hook_stage == Stage::CommitMsg { 230 | return Ok(vec![ 231 | commit_msg_filename 232 | .expect("commit message filename is required") 233 | .to_string_lossy() 234 | .to_string(), 235 | ]); 236 | } 237 | } 238 | 239 | if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) { 240 | let files = git::get_changed_files(&from_ref, &to_ref).await?; 241 | debug!( 242 | "Files changed between {} and {}: {}", 243 | from_ref, 244 | to_ref, 245 | files.len() 246 | ); 247 | return Ok(files); 248 | } 249 | 250 | if !files.is_empty() { 251 | let files: Vec<_> = files 252 | .into_iter() 253 | .map(|f| f.to_string_lossy().to_string()) 254 | .collect(); 255 | debug!("Files passed as arguments: {}", files.len()); 256 | return Ok(files); 257 | } 258 | if all_files { 259 | let files = git::get_all_files().await?; 260 | debug!("All files in the repo: {}", files.len()); 261 | return Ok(files); 262 | } 263 | if git::is_in_merge_conflict().await? { 264 | let files = git::get_conflicted_files().await?; 265 | debug!("Conflicted files: {}", files.len()); 266 | return Ok(files); 267 | } 268 | 269 | let files = git::get_staged_files().await?; 270 | debug!("Staged files: {}", files.len()); 271 | Ok(files) 272 | } 273 | -------------------------------------------------------------------------------- /src/cli/run/keeper.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::process::Command; 3 | use std::sync::Mutex; 4 | 5 | use anstream::eprintln; 6 | use anyhow::Result; 7 | use owo_colors::OwoColorize; 8 | use tracing::{error, trace}; 9 | 10 | use constants::env_vars::EnvVars; 11 | 12 | use crate::cleanup::add_cleanup; 13 | use crate::fs::Simplified; 14 | use crate::git::{self, GIT, git_cmd}; 15 | use crate::store::Store; 16 | 17 | static RESTORE_WORKTREE: Mutex> = Mutex::new(None); 18 | 19 | struct IntentToAddKeeper(Vec); 20 | struct WorkingTreeKeeper(Option); 21 | 22 | impl IntentToAddKeeper { 23 | async fn clean() -> Result { 24 | let files = git::intent_to_add_files().await?; 25 | if files.is_empty() { 26 | return Ok(Self(vec![])); 27 | } 28 | 29 | // TODO: xargs 30 | git_cmd("git rm")? 31 | .arg("rm") 32 | .arg("--cached") 33 | .arg("--") 34 | .args(&files) 35 | .check(true) 36 | .stdout(std::process::Stdio::null()) 37 | .stderr(std::process::Stdio::null()) 38 | .status() 39 | .await?; 40 | 41 | Ok(Self(files.into_iter().map(PathBuf::from).collect())) 42 | } 43 | 44 | fn restore(&self) -> Result<()> { 45 | // Restore the intent-to-add changes. 46 | if !self.0.is_empty() { 47 | Command::new(GIT.as_ref()?) 48 | .arg("add") 49 | .arg("--intent-to-add") 50 | .arg("--") 51 | // TODO: xargs 52 | .args(&self.0) 53 | .stdout(std::process::Stdio::null()) 54 | .stderr(std::process::Stdio::null()) 55 | .status()?; 56 | } 57 | Ok(()) 58 | } 59 | } 60 | 61 | impl Drop for IntentToAddKeeper { 62 | fn drop(&mut self) { 63 | if let Err(err) = self.restore() { 64 | anstream::eprintln!( 65 | "{}", 66 | format!("Failed to restore intent-to-add changes: {err}").red() 67 | ); 68 | } 69 | } 70 | } 71 | 72 | impl WorkingTreeKeeper { 73 | async fn clean(patch_dir: &Path) -> Result { 74 | let tree = git::write_tree().await?; 75 | 76 | let mut cmd = git_cmd("git diff-index")?; 77 | let output = cmd 78 | .arg("diff-index") 79 | .arg("--ignore-submodules") 80 | .arg("--binary") 81 | .arg("--exit-code") 82 | .arg("--no-color") 83 | .arg("--no-ext-diff") 84 | .arg(tree) 85 | .arg("--") 86 | .check(false) 87 | .output() 88 | .await?; 89 | 90 | if output.status.success() { 91 | trace!("No non-staged changes detected"); 92 | // No non-staged changes 93 | Ok(Self(None)) 94 | } else if output.status.code() == Some(1) { 95 | if output.stdout.trim_ascii().is_empty() { 96 | trace!("diff-index status code 1 with empty stdout"); 97 | // probably git auto crlf behavior quirks 98 | Ok(Self(None)) 99 | } else { 100 | let now = std::time::SystemTime::now(); 101 | let pid = std::process::id(); 102 | let patch_name = format!( 103 | "{}-{}.patch", 104 | now.duration_since(std::time::UNIX_EPOCH)?.as_millis(), 105 | pid 106 | ); 107 | let patch_path = patch_dir.join(&patch_name); 108 | 109 | eprintln!( 110 | "{}", 111 | format!( 112 | "Non-staged changes detected, saving to `{}`", 113 | patch_path.user_display() 114 | ) 115 | .yellow() 116 | ); 117 | fs_err::create_dir_all(patch_dir)?; 118 | fs_err::write(&patch_path, output.stdout)?; 119 | 120 | // Clean the working tree 121 | Self::checkout_working_tree()?; 122 | 123 | Ok(Self(Some(patch_path))) 124 | } 125 | } else { 126 | Err(cmd.check_status(output.status).unwrap_err().into()) 127 | } 128 | } 129 | 130 | fn checkout_working_tree() -> Result<()> { 131 | let status = Command::new(GIT.as_ref()?) 132 | .arg("-c") 133 | .arg("submodule.recurse=0") 134 | .arg("checkout") 135 | .arg("--") 136 | .arg(".") 137 | // prevent recursive post-checkout hooks 138 | .env(EnvVars::PREFLIGIT_INTERNAL__SKIP_POST_CHECKOUT, "1") 139 | .stdout(std::process::Stdio::null()) 140 | .stderr(std::process::Stdio::null()) 141 | .status()?; 142 | if status.success() { 143 | Ok(()) 144 | } else { 145 | Err(anyhow::anyhow!("Failed to checkout working tree")) 146 | } 147 | } 148 | 149 | fn git_apply(patch: &Path) -> Result<()> { 150 | let status = Command::new(GIT.as_ref()?) 151 | .arg("apply") 152 | .arg("--whitespace=nowarn") 153 | .arg(patch) 154 | .stdout(std::process::Stdio::null()) 155 | .stderr(std::process::Stdio::null()) 156 | .status()?; 157 | if status.success() { 158 | Ok(()) 159 | } else { 160 | Err(anyhow::anyhow!("Failed to apply the patch")) 161 | } 162 | } 163 | 164 | fn restore(&self) -> Result<()> { 165 | let Some(patch) = self.0.as_ref() else { 166 | return Ok(()); 167 | }; 168 | 169 | // Try to apply the patch 170 | if Self::git_apply(patch).is_err() { 171 | error!("Failed to apply the patch, rolling back changes"); 172 | eprintln!( 173 | "{}", 174 | "Failed to apply the patch, rolling back changes".red() 175 | ); 176 | 177 | Self::checkout_working_tree()?; 178 | Self::git_apply(patch)?; 179 | }; 180 | 181 | eprintln!( 182 | "{}", 183 | format!( 184 | "\nRestored working tree changes from `{}`", 185 | patch.user_display() 186 | ) 187 | .yellow() 188 | ); 189 | 190 | Ok(()) 191 | } 192 | } 193 | 194 | impl Drop for WorkingTreeKeeper { 195 | fn drop(&mut self) { 196 | if let Err(err) = self.restore() { 197 | eprintln!( 198 | "{}", 199 | format!("Failed to restore working tree changes: {err}").red() 200 | ); 201 | } 202 | } 203 | } 204 | 205 | /// Clean Git intent-to-add files and working tree changes, and restore them when dropped. 206 | pub struct WorkTreeKeeper { 207 | intent_to_add: Option, 208 | working_tree: Option, 209 | } 210 | 211 | #[derive(Default)] 212 | pub struct RestoreGuard { 213 | _guard: (), 214 | } 215 | 216 | impl Drop for RestoreGuard { 217 | fn drop(&mut self) { 218 | if let Some(mut keeper) = RESTORE_WORKTREE.lock().unwrap().take() { 219 | keeper.restore(); 220 | } 221 | } 222 | } 223 | 224 | impl WorkTreeKeeper { 225 | /// Clear intent-to-add changes from the index and clear the non-staged changes from the working directory. 226 | /// Restore them when the instance is dropped. 227 | pub async fn clean(store: &Store) -> Result { 228 | let cleaner = Self { 229 | intent_to_add: Some(IntentToAddKeeper::clean().await?), 230 | working_tree: Some(WorkingTreeKeeper::clean(&store.patches_dir()).await?), 231 | }; 232 | 233 | // Set to the global for the cleanup hook. 234 | *RESTORE_WORKTREE.lock().unwrap() = Some(cleaner); 235 | 236 | // Make sure restoration when ctrl-c is pressed. 237 | add_cleanup(|| { 238 | if let Some(guard) = &mut *RESTORE_WORKTREE.lock().unwrap() { 239 | guard.restore(); 240 | } 241 | }); 242 | 243 | Ok(RestoreGuard::default()) 244 | } 245 | 246 | /// Restore the intent-to-add changes and non-staged changes. 247 | fn restore(&mut self) { 248 | self.intent_to_add.take(); 249 | self.working_tree.take(); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/cli/run/mod.rs: -------------------------------------------------------------------------------- 1 | pub use filter::{CollectOptions, FileFilter, collect_files}; 2 | pub(crate) use run::{install_hooks, run}; 3 | 4 | mod filter; 5 | mod keeper; 6 | #[allow(clippy::module_inception)] 7 | mod run; 8 | -------------------------------------------------------------------------------- /src/cli/sample_config.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::ExitStatus; 2 | 3 | static SAMPLE_CONFIG: &str = "\ 4 | # See https://pre-commit.com for more information 5 | # See https://pre-commit.com/hooks.html for more hooks 6 | repos: 7 | - repo: 'https://github.com/pre-commit/pre-commit-hooks' 8 | rev: v5.0.0 9 | hooks: 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: check-yaml 13 | - id: check-added-large-files 14 | "; 15 | 16 | #[allow(clippy::print_stdout)] 17 | pub(crate) fn sample_config() -> ExitStatus { 18 | print!("{SAMPLE_CONFIG}"); 19 | ExitStatus::Success 20 | } 21 | -------------------------------------------------------------------------------- /src/cli/self_update.rs: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Astral Software Inc. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | use std::env; 24 | use std::fmt::Write; 25 | 26 | use anyhow::Result; 27 | use axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest}; 28 | use owo_colors::OwoColorize; 29 | use tracing::{debug, enabled}; 30 | 31 | use crate::cli::ExitStatus; 32 | use crate::printer::Printer; 33 | 34 | /// Attempt to update the prefligit binary. 35 | pub(crate) async fn self_update( 36 | version: Option, 37 | token: Option, 38 | printer: Printer, 39 | ) -> Result { 40 | let mut updater = AxoUpdater::new_for("prefligit"); 41 | if enabled!(tracing::Level::DEBUG) { 42 | unsafe { env::set_var("INSTALLER_PRINT_VERBOSE", "1") }; 43 | updater.enable_installer_output(); 44 | } else { 45 | updater.disable_installer_output(); 46 | } 47 | 48 | if let Some(ref token) = token { 49 | updater.set_github_token(token); 50 | } 51 | 52 | // Load the "install receipt" for the current binary. If the receipt is not found, then 53 | // prefligit was likely installed via a package manager. 54 | let Ok(updater) = updater.load_receipt() else { 55 | debug!("no receipt found; assuming prefligit was installed via a package manager"); 56 | writeln!( 57 | printer.stderr(), 58 | "{}", 59 | format_args!( 60 | concat!( 61 | "{}{} Self-update is only available for prefligit binaries installed via the standalone installation scripts.", 62 | "\n", 63 | "\n", 64 | "If you installed prefligit with pip, brew, or another package manager, update prefligit with `pip install --upgrade`, `brew upgrade`, or similar." 65 | ), 66 | "warning".yellow().bold(), 67 | ":".bold() 68 | ) 69 | )?; 70 | return Ok(ExitStatus::Error); 71 | }; 72 | 73 | // Ensure the receipt is for the current binary. If it's not, then the user likely has multiple 74 | // prefligit binaries installed, and the current binary was _not_ installed via the standalone 75 | // installation scripts. 76 | if !updater.check_receipt_is_for_this_executable()? { 77 | debug!( 78 | "receipt is not for this executable; assuming prefligit was installed via a package manager" 79 | ); 80 | writeln!( 81 | printer.stderr(), 82 | "{}", 83 | format_args!( 84 | concat!( 85 | "{}{} Self-update is only available for prefligit binaries installed via the standalone installation scripts.", 86 | "\n", 87 | "\n", 88 | "If you installed prefligit with pip, brew, or another package manager, update prefligit with `pip install --upgrade`, `brew upgrade`, or similar." 89 | ), 90 | "warning".yellow().bold(), 91 | ":".bold() 92 | ) 93 | )?; 94 | return Ok(ExitStatus::Error); 95 | } 96 | 97 | writeln!( 98 | printer.stderr(), 99 | "{}", 100 | format_args!( 101 | "{}{} Checking for updates...", 102 | "info".cyan().bold(), 103 | ":".bold() 104 | ) 105 | )?; 106 | 107 | let update_request = if let Some(version) = version { 108 | UpdateRequest::SpecificTag(version) 109 | } else { 110 | UpdateRequest::Latest 111 | }; 112 | 113 | updater.configure_version_specifier(update_request); 114 | 115 | // Run the updater. This involves a network request, since we need to determine the latest 116 | // available version of prefligit. 117 | match updater.run().await { 118 | Ok(Some(result)) => { 119 | let version_information = if let Some(old_version) = result.old_version { 120 | format!( 121 | "from {} to {}", 122 | format!("v{old_version}").bold().white(), 123 | format!("v{}", result.new_version).bold().white(), 124 | ) 125 | } else { 126 | format!("to {}", format!("v{}", result.new_version).bold().white()) 127 | }; 128 | 129 | writeln!( 130 | printer.stderr(), 131 | "{}", 132 | format_args!( 133 | "{}{} Upgraded prefligit {}! {}", 134 | "success".green().bold(), 135 | ":".bold(), 136 | version_information, 137 | format!( 138 | "https://github.com/j178/prefligit/releases/tag/{}", 139 | result.new_version_tag 140 | ) 141 | .cyan() 142 | ) 143 | )?; 144 | } 145 | Ok(None) => { 146 | writeln!( 147 | printer.stderr(), 148 | "{}", 149 | format_args!( 150 | "{}{} You're on the latest version of prefligit ({})", 151 | "success".green().bold(), 152 | ":".bold(), 153 | format!("v{}", env!("CARGO_PKG_VERSION")).bold().white() 154 | ) 155 | )?; 156 | } 157 | Err(err) => { 158 | return if let AxoupdateError::Reqwest(err) = err { 159 | if err.status() == Some(http::StatusCode::FORBIDDEN) && token.is_none() { 160 | writeln!( 161 | printer.stderr(), 162 | "{}", 163 | format_args!( 164 | "{}{} GitHub API rate limit exceeded. Please provide a GitHub token via the {} option.", 165 | "error".red().bold(), 166 | ":".bold(), 167 | "`--token`".green().bold() 168 | ) 169 | )?; 170 | Ok(ExitStatus::Error) 171 | } else { 172 | Err(err.into()) 173 | } 174 | } else { 175 | Err(err.into()) 176 | }; 177 | } 178 | } 179 | 180 | Ok(ExitStatus::Success) 181 | } 182 | -------------------------------------------------------------------------------- /src/cli/validate.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::iter; 3 | use std::path::PathBuf; 4 | 5 | use anstream::eprintln; 6 | use owo_colors::OwoColorize; 7 | 8 | use crate::cli::ExitStatus; 9 | use crate::config::{read_config, read_manifest}; 10 | 11 | pub(crate) fn validate_configs(configs: Vec) -> ExitStatus { 12 | let mut status = ExitStatus::Success; 13 | 14 | for config in configs { 15 | if let Err(err) = read_config(&config) { 16 | eprintln!("{}: {}", "error".red().bold(), err); 17 | for source in iter::successors(err.source(), |&err| err.source()) { 18 | eprintln!(" {}: {}", "caused by".red().bold(), source); 19 | } 20 | status = ExitStatus::Failure; 21 | } 22 | } 23 | 24 | status 25 | } 26 | 27 | pub(crate) fn validate_manifest(configs: Vec) -> ExitStatus { 28 | let mut status = ExitStatus::Success; 29 | 30 | for config in configs { 31 | if let Err(err) = read_manifest(&config) { 32 | eprintln!("{}: {}", "error".red().bold(), err); 33 | for source in iter::successors(err.source(), |&err| err.source()) { 34 | eprintln!(" {}: {}", "caused by".red().bold(), source); 35 | } 36 | status = ExitStatus::Failure; 37 | } 38 | } 39 | 40 | status 41 | } 42 | -------------------------------------------------------------------------------- /src/languages/docker.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::collections::HashMap; 3 | use std::fs; 4 | use std::hash::{Hash, Hasher}; 5 | 6 | use anstream::ColorChoice; 7 | use anyhow::Result; 8 | use fancy_regex::Regex; 9 | use seahash::SeaHasher; 10 | use tracing::trace; 11 | 12 | use crate::fs::CWD; 13 | use crate::hook::{Hook, ResolvedHook}; 14 | use crate::languages::LanguageImpl; 15 | use crate::process::Cmd; 16 | use crate::run::run_by_batch; 17 | use crate::store::Store; 18 | 19 | const PRE_COMMIT_LABEL: &str = "PRE_COMMIT"; 20 | 21 | #[derive(Debug, Copy, Clone)] 22 | pub struct Docker; 23 | 24 | impl Docker { 25 | fn docker_tag(hook: &ResolvedHook) -> String { 26 | let mut hasher = SeaHasher::new(); 27 | hook.hash(&mut hasher); 28 | let digest = crate::store::to_hex(hasher.finish()); 29 | format!("prefligit-{digest}") 30 | } 31 | 32 | async fn build_docker_image(hook: &ResolvedHook, pull: bool) -> Result<()> { 33 | let Some(src) = hook.repo_path() else { 34 | anyhow::bail!("Language `docker` cannot work with `local` repository"); 35 | }; 36 | 37 | let mut cmd = Cmd::new("docker", "build docker image"); 38 | 39 | let cmd = cmd 40 | .arg("build") 41 | .arg("--tag") 42 | .arg(Self::docker_tag(hook)) 43 | .arg("--label") 44 | .arg(PRE_COMMIT_LABEL); 45 | 46 | // Always attempt to pull all referenced images. 47 | if pull { 48 | cmd.arg("--pull"); 49 | } 50 | 51 | // This must come last for old versions of docker. 52 | // see https://github.com/pre-commit/pre-commit/issues/477 53 | cmd.arg("."); 54 | 55 | cmd.current_dir(src).check(true).output().await?; 56 | 57 | Ok(()) 58 | } 59 | 60 | /// see 61 | fn is_in_docker() -> bool { 62 | if fs::metadata("/.dockerenv").is_ok() || fs::metadata("/run/.containerenv").is_ok() { 63 | return true; 64 | } 65 | false 66 | } 67 | 68 | /// Get container id the process is running in. 69 | /// 70 | /// There are no reliable way to get the container id inside container, see 71 | /// 72 | fn current_container_id() -> Result { 73 | // Adapted from https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/7167/files 74 | let regex = Regex::new(r".*/docker/containers/([0-9a-f]{64})/.*").expect("invalid regex"); 75 | let cgroup_path = fs::read_to_string("/proc/self/cgroup")?; 76 | let Some(captures) = regex.captures(&cgroup_path)? else { 77 | anyhow::bail!("Failed to get container id: no match found"); 78 | }; 79 | let Some(id) = captures.get(1).map(|m| m.as_str().to_string()) else { 80 | anyhow::bail!("Failed to get container id: no capture found"); 81 | }; 82 | Ok(id) 83 | } 84 | 85 | /// Get the path of the current directory in the host. 86 | async fn get_docker_path(path: &str) -> Result> { 87 | if !Self::is_in_docker() { 88 | return Ok(Cow::Borrowed(path)); 89 | }; 90 | 91 | let Ok(container_id) = Self::current_container_id() else { 92 | return Ok(Cow::Borrowed(path)); 93 | }; 94 | 95 | trace!(?container_id, "Get container id"); 96 | 97 | if let Ok(output) = Cmd::new("docker", "inspect container") 98 | .arg("inspect") 99 | .arg("--format") 100 | .arg("'{{json .Mounts}}'") 101 | .arg(&container_id) 102 | .check(true) 103 | .output() 104 | .await 105 | { 106 | #[derive(serde::Deserialize, Debug)] 107 | struct Mount { 108 | #[serde(rename = "Source")] 109 | source: String, 110 | #[serde(rename = "Destination")] 111 | destination: String, 112 | } 113 | 114 | let stdout = String::from_utf8_lossy(&output.stdout); 115 | let stdout = stdout.trim().trim_matches('\''); 116 | let mounts: Vec = serde_json::from_str(stdout)?; 117 | 118 | trace!(?mounts, "Get docker mounts"); 119 | 120 | for mount in mounts { 121 | if path.starts_with(&mount.destination) { 122 | let mut path = path.replace(&mount.destination, &mount.source); 123 | if path.contains('\\') { 124 | // Replace `/` with `\` on Windows 125 | path = path.replace('/', "\\"); 126 | } 127 | return Ok(Cow::Owned(path)); 128 | } 129 | } 130 | } 131 | 132 | Ok(Cow::Borrowed(path)) 133 | } 134 | 135 | pub(crate) async fn docker_run_cmd() -> Result { 136 | let mut command = Cmd::new("docker", "run container"); 137 | command.arg("run").arg("--rm"); 138 | 139 | match ColorChoice::global() { 140 | ColorChoice::Always | ColorChoice::AlwaysAnsi => { 141 | command.arg("--tty"); 142 | } 143 | _ => {} 144 | } 145 | 146 | // Run as a non-root user 147 | #[cfg(unix)] 148 | { 149 | command.arg("--user"); 150 | command.arg(format!("{}:{}", unsafe { libc::geteuid() }, unsafe { 151 | libc::getegid() 152 | })); 153 | } 154 | 155 | command 156 | .arg("-v") 157 | // https://docs.docker.com/engine/reference/commandline/run/#mount-volumes-from-container-volumes-from 158 | .arg(format!( 159 | "{}:/src:ro,Z", 160 | Self::get_docker_path(&CWD.to_string_lossy()).await? 161 | )) 162 | .arg("--workdir") 163 | .arg("/src"); 164 | 165 | Ok(command) 166 | } 167 | } 168 | 169 | impl LanguageImpl for Docker { 170 | fn supports_dependency(&self) -> bool { 171 | true 172 | } 173 | 174 | async fn resolve(&self, hook: &Hook, _store: &Store) -> Result { 175 | Ok(ResolvedHook::NoNeedInstall(hook.clone())) 176 | } 177 | 178 | async fn install(&self, hook: &ResolvedHook, _store: &Store) -> Result<()> { 179 | let env = hook.env_path().expect("Docker must have env path"); 180 | 181 | // TODO: check unsupported language version 182 | if !hook.additional_dependencies.is_empty() { 183 | anyhow::bail!("Docker does not support additional dependencies"); 184 | } 185 | 186 | Docker::build_docker_image(hook, true).await?; 187 | fs_err::tokio::create_dir_all(env).await?; 188 | Ok(()) 189 | } 190 | 191 | async fn check_health(&self) -> Result<()> { 192 | todo!() 193 | } 194 | 195 | async fn run( 196 | &self, 197 | hook: &ResolvedHook, 198 | filenames: &[&String], 199 | env_vars: &HashMap<&'static str, String>, 200 | _store: &Store, 201 | ) -> Result<(i32, Vec)> { 202 | Docker::build_docker_image(hook, false).await?; 203 | 204 | let docker_tag = Docker::docker_tag(hook); 205 | 206 | let cmds = shlex::split(&hook.entry).ok_or(anyhow::anyhow!("Failed to parse entry"))?; 207 | 208 | let run = async move |batch: Vec| { 209 | // docker run [OPTIONS] IMAGE [COMMAND] [ARG...] 210 | let mut cmd = Docker::docker_run_cmd().await?; 211 | let cmd = cmd 212 | .arg("--entrypoint") 213 | .arg(&cmds[0]) 214 | .arg(&docker_tag) 215 | .args(&cmds[1..]) 216 | .args(&hook.args) 217 | .args(batch) 218 | .check(false) 219 | .envs(env_vars); 220 | 221 | let mut output = cmd.output().await?; 222 | output.stdout.extend(output.stderr); 223 | let code = output.status.code().unwrap_or(1); 224 | anyhow::Ok((code, output.stdout)) 225 | }; 226 | 227 | let results = run_by_batch(hook, filenames, run).await?; 228 | 229 | // Collect results 230 | let mut combined_status = 0; 231 | let mut combined_output = Vec::new(); 232 | 233 | for (code, output) in results { 234 | combined_status |= code; 235 | combined_output.extend(output); 236 | } 237 | 238 | Ok((combined_status, combined_output)) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/languages/docker_image.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::hook::{Hook, ResolvedHook}; 6 | use crate::languages::LanguageImpl; 7 | use crate::languages::docker::Docker; 8 | use crate::run::run_by_batch; 9 | use crate::store::Store; 10 | 11 | #[derive(Debug, Copy, Clone)] 12 | pub struct DockerImage; 13 | 14 | impl LanguageImpl for DockerImage { 15 | fn supports_dependency(&self) -> bool { 16 | false 17 | } 18 | 19 | async fn resolve(&self, hook: &Hook, _store: &Store) -> Result { 20 | Ok(ResolvedHook::NoNeedInstall(hook.clone())) 21 | } 22 | 23 | async fn install(&self, _hook: &ResolvedHook, _store: &Store) -> Result<()> { 24 | Ok(()) 25 | } 26 | 27 | async fn check_health(&self) -> Result<()> { 28 | todo!() 29 | } 30 | 31 | async fn run( 32 | &self, 33 | hook: &ResolvedHook, 34 | filenames: &[&String], 35 | env_vars: &HashMap<&'static str, String>, 36 | _store: &Store, 37 | ) -> Result<(i32, Vec)> { 38 | let cmds = shlex::split(&hook.entry).ok_or(anyhow::anyhow!("Failed to parse entry"))?; 39 | 40 | let run = async move |batch: Vec| { 41 | let mut cmd = Docker::docker_run_cmd().await?; 42 | let cmd = cmd 43 | .args(&cmds[..]) 44 | .args(&hook.args) 45 | .args(batch) 46 | .check(false) 47 | .envs(env_vars); 48 | 49 | let mut output = cmd.output().await?; 50 | output.stdout.extend(output.stderr); 51 | let code = output.status.code().unwrap_or(1); 52 | anyhow::Ok((code, output.stdout)) 53 | }; 54 | 55 | let results = run_by_batch(hook, filenames, run).await?; 56 | 57 | // Collect results 58 | let mut combined_status = 0; 59 | let mut combined_output = Vec::new(); 60 | 61 | for (code, output) in results { 62 | combined_status |= code; 63 | combined_output.extend(output); 64 | } 65 | 66 | Ok((combined_status, combined_output)) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/languages/fail.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::hook::{Hook, ResolvedHook}; 6 | use crate::languages::LanguageImpl; 7 | use crate::store::Store; 8 | 9 | #[derive(Debug, Copy, Clone)] 10 | pub struct Fail; 11 | 12 | impl LanguageImpl for Fail { 13 | fn supports_dependency(&self) -> bool { 14 | false 15 | } 16 | 17 | async fn resolve(&self, hook: &Hook, _store: &Store) -> Result { 18 | Ok(ResolvedHook::NoNeedInstall(hook.clone())) 19 | } 20 | 21 | async fn install(&self, _hook: &ResolvedHook, _store: &Store) -> Result<()> { 22 | Ok(()) 23 | } 24 | 25 | async fn check_health(&self) -> Result<()> { 26 | Ok(()) 27 | } 28 | 29 | async fn run( 30 | &self, 31 | hook: &ResolvedHook, 32 | filenames: &[&String], 33 | _env_vars: &HashMap<&'static str, String>, 34 | _store: &Store, 35 | ) -> Result<(i32, Vec)> { 36 | let mut out = hook.entry.as_bytes().to_vec(); 37 | out.extend(b"\n\n"); 38 | for f in filenames { 39 | out.extend(f.as_bytes()); 40 | out.push(b'\n'); 41 | } 42 | out.push(b'\n'); 43 | 44 | Ok((1, out)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/languages/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::builtin; 6 | use crate::config::Language; 7 | use crate::hook::{Hook, ResolvedHook}; 8 | use crate::store::Store; 9 | 10 | mod docker; 11 | mod docker_image; 12 | mod fail; 13 | mod node; 14 | mod python; 15 | mod system; 16 | 17 | static PYTHON: python::Python = python::Python; 18 | static NODE: node::Node = node::Node; 19 | static SYSTEM: system::System = system::System; 20 | static FAIL: fail::Fail = fail::Fail; 21 | static DOCKER: docker::Docker = docker::Docker; 22 | static DOCKER_IMAGE: docker_image::DockerImage = docker_image::DockerImage; 23 | 24 | trait LanguageImpl { 25 | /// Whether the language supports installing dependencies. 26 | /// 27 | /// For example, Python and Node.js support installing dependencies, while 28 | /// System and Fail do not. 29 | fn supports_dependency(&self) -> bool; 30 | async fn resolve(&self, hook: &Hook, store: &Store) -> Result; 31 | async fn install(&self, hook: &ResolvedHook, store: &Store) -> Result<()>; 32 | async fn check_health(&self) -> Result<()>; 33 | async fn run( 34 | &self, 35 | hook: &ResolvedHook, 36 | filenames: &[&String], 37 | env_vars: &HashMap<&'static str, String>, 38 | store: &Store, 39 | ) -> Result<(i32, Vec)>; 40 | } 41 | 42 | impl Language { 43 | /// Return whether the language allows specifying the version. 44 | /// See 45 | pub fn supports_language_version(self) -> bool { 46 | matches!( 47 | self, 48 | Self::Python | Self::Node | Self::Ruby | Self::Rust | Self::Golang 49 | ) 50 | } 51 | 52 | pub fn supports_dependency(self) -> bool { 53 | match self { 54 | Self::Python => PYTHON.supports_dependency(), 55 | Self::Node => NODE.supports_dependency(), 56 | Self::System => SYSTEM.supports_dependency(), 57 | Self::Fail => FAIL.supports_dependency(), 58 | Self::Docker => DOCKER.supports_dependency(), 59 | Self::DockerImage => DOCKER_IMAGE.supports_dependency(), 60 | _ => todo!("{}", self.as_str()), 61 | } 62 | } 63 | 64 | pub async fn resolve(&self, hook: &Hook, store: &Store) -> Result { 65 | match self { 66 | Self::Python => PYTHON.resolve(hook, store).await, 67 | Self::Node => NODE.resolve(hook, store).await, 68 | Self::System => SYSTEM.resolve(hook, store).await, 69 | Self::Fail => FAIL.resolve(hook, store).await, 70 | Self::Docker => DOCKER.resolve(hook, store).await, 71 | Self::DockerImage => DOCKER_IMAGE.resolve(hook, store).await, 72 | _ => todo!("{}", self.as_str()), 73 | } 74 | } 75 | 76 | pub async fn install(&self, hook: &ResolvedHook, store: &Store) -> Result<()> { 77 | match self { 78 | Self::Python => PYTHON.install(hook, store).await, 79 | Self::Node => NODE.install(hook, store).await, 80 | Self::System => SYSTEM.install(hook, store).await, 81 | Self::Fail => FAIL.install(hook, store).await, 82 | Self::Docker => DOCKER.install(hook, store).await, 83 | Self::DockerImage => DOCKER_IMAGE.install(hook, store).await, 84 | _ => todo!("{}", self.as_str()), 85 | } 86 | } 87 | 88 | pub async fn check_health(&self) -> Result<()> { 89 | match self { 90 | Self::Python => PYTHON.check_health().await, 91 | Self::Node => NODE.check_health().await, 92 | Self::System => SYSTEM.check_health().await, 93 | Self::Fail => FAIL.check_health().await, 94 | Self::Docker => DOCKER.check_health().await, 95 | Self::DockerImage => DOCKER_IMAGE.check_health().await, 96 | _ => todo!("{}", self.as_str()), 97 | } 98 | } 99 | 100 | pub async fn run( 101 | &self, 102 | hook: &ResolvedHook, 103 | filenames: &[&String], 104 | env_vars: &HashMap<&'static str, String>, 105 | store: &Store, 106 | ) -> Result<(i32, Vec)> { 107 | // fast path for hooks implemented in Rust 108 | if builtin::check_fast_path(hook) { 109 | return builtin::run_fast_path(hook, filenames, env_vars).await; 110 | } 111 | 112 | match self { 113 | Self::Python => PYTHON.run(hook, filenames, env_vars, store).await, 114 | Self::Node => NODE.run(hook, filenames, env_vars, store).await, 115 | Self::System => SYSTEM.run(hook, filenames, env_vars, store).await, 116 | Self::Fail => FAIL.run(hook, filenames, env_vars, store).await, 117 | Self::Docker => DOCKER.run(hook, filenames, env_vars, store).await, 118 | Self::DockerImage => DOCKER_IMAGE.run(hook, filenames, env_vars, store).await, 119 | _ => todo!("{}", self.as_str()), 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/languages/node/mod.rs: -------------------------------------------------------------------------------- 1 | mod installer; 2 | #[allow(clippy::module_inception)] 3 | mod node; 4 | 5 | pub use node::Node; 6 | -------------------------------------------------------------------------------- /src/languages/node/node.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::hook::Hook; 6 | use crate::hook::ResolvedHook; 7 | use crate::languages::LanguageImpl; 8 | use crate::languages::node::installer::NodeInstaller; 9 | use crate::store::{Store, ToolBucket}; 10 | 11 | #[derive(Debug, Copy, Clone)] 12 | pub struct Node; 13 | 14 | impl LanguageImpl for Node { 15 | fn supports_dependency(&self) -> bool { 16 | true 17 | } 18 | 19 | async fn resolve(&self, _hook: &Hook, _store: &Store) -> Result { 20 | todo!() 21 | } 22 | 23 | async fn install(&self, hook: &ResolvedHook, store: &Store) -> Result<()> { 24 | let env = hook.env_path().expect("Node must have env path"); 25 | fs_err::create_dir_all(env)?; 26 | 27 | let node_dir = store.tools_path(ToolBucket::Node); 28 | 29 | let installer = NodeInstaller::new(node_dir); 30 | let node = installer.install(&hook.language_version).await?; 31 | 32 | // TODO: Create an env 33 | _ = node; 34 | 35 | Ok(()) 36 | } 37 | 38 | async fn check_health(&self) -> Result<()> { 39 | todo!() 40 | } 41 | 42 | async fn run( 43 | &self, 44 | _hook: &ResolvedHook, 45 | _filenames: &[&String], 46 | _env_vars: &HashMap<&'static str, String>, 47 | _store: &Store, 48 | ) -> Result<(i32, Vec)> { 49 | Ok((0, Vec::new())) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/languages/python/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod python; 3 | mod uv; 4 | 5 | pub use python::Python; 6 | -------------------------------------------------------------------------------- /src/languages/python/python.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use anyhow::{Context, Result, anyhow}; 5 | use tracing::debug; 6 | 7 | use crate::hook::ResolvedHook; 8 | use crate::hook::{Hook, InstallInfo}; 9 | use crate::languages::LanguageImpl; 10 | use crate::languages::python::uv::Uv; 11 | use crate::process::Cmd; 12 | use crate::run::run_by_batch; 13 | use crate::store::{Store, ToolBucket}; 14 | 15 | use constants::env_vars::EnvVars; 16 | 17 | #[derive(Debug, Copy, Clone)] 18 | pub struct Python; 19 | 20 | impl LanguageImpl for Python { 21 | fn supports_dependency(&self) -> bool { 22 | true 23 | } 24 | 25 | async fn resolve(&self, hook: &Hook, store: &Store) -> Result { 26 | // Select from installed hooks 27 | if let Some(info) = store.installed_hooks().find(|info| info.matches(hook)) { 28 | debug!("Found installed hook: {}", info.env_path.display()); 29 | return Ok(ResolvedHook::Installed { 30 | hook: hook.clone(), 31 | info, 32 | }); 33 | } 34 | debug!("No matching installed hook found for {}", hook); 35 | 36 | // Select toolchain from system or managed 37 | let uv = Uv::install(store).await?; 38 | let python = uv 39 | .find_python(hook, store) 40 | .await? 41 | .into_iter() 42 | .next() 43 | .ok_or_else(|| anyhow!("Failed to resolve hook"))?; 44 | debug!(python = %python.display(), "Resolved Python"); 45 | 46 | // Get Python version 47 | let stdout = Cmd::new(&python, "get Python version") 48 | .arg("-I") 49 | .arg("-c") 50 | .arg("import sys; print('.'.join(map(str, sys.version_info[:3])))") 51 | .check(true) 52 | .output() 53 | .await? 54 | .stdout; 55 | let version = String::from_utf8_lossy(&stdout) 56 | .trim() 57 | .parse::() 58 | .with_context(|| "Failed to parse Python version")?; 59 | 60 | Ok(ResolvedHook::NotInstalled { 61 | hook: hook.clone(), 62 | toolchain: python.clone(), 63 | info: InstallInfo::new(hook.language, version, hook.dependencies().to_vec(), store), 64 | }) 65 | } 66 | 67 | async fn install(&self, hook: &ResolvedHook, store: &Store) -> Result<()> { 68 | let ResolvedHook::NotInstalled { 69 | hook, 70 | toolchain, 71 | info, 72 | } = hook 73 | else { 74 | unreachable!("Python hook must be NotInstalled") 75 | }; 76 | 77 | let uv = Uv::install(store).await?; 78 | 79 | // Create venv 80 | let mut cmd = uv.cmd("create venv"); 81 | cmd.arg("venv") 82 | .arg(&info.env_path) 83 | .arg("--python") 84 | .arg(toolchain) 85 | .env( 86 | EnvVars::UV_PYTHON_INSTALL_DIR, 87 | store.tools_path(ToolBucket::Python), 88 | ); 89 | 90 | cmd.check(true).output().await?; 91 | 92 | // Install dependencies 93 | if let Some(repo_path) = hook.repo_path() { 94 | uv.cmd("install dependencies") 95 | .arg("pip") 96 | .arg("install") 97 | .arg(".") 98 | .args(&hook.additional_dependencies) 99 | .current_dir(repo_path) 100 | .env("VIRTUAL_ENV", &info.env_path) 101 | .check(true) 102 | .output() 103 | .await?; 104 | } else if !hook.additional_dependencies.is_empty() { 105 | uv.cmd("install dependencies") 106 | .arg("pip") 107 | .arg("install") 108 | .args(&hook.additional_dependencies) 109 | .env("VIRTUAL_ENV", &info.env_path) 110 | .check(true) 111 | .output() 112 | .await?; 113 | } else { 114 | debug!("No dependencies to install"); 115 | } 116 | Ok(()) 117 | } 118 | 119 | async fn check_health(&self) -> Result<()> { 120 | todo!() 121 | } 122 | 123 | async fn run( 124 | &self, 125 | hook: &ResolvedHook, 126 | filenames: &[&String], 127 | env_vars: &HashMap<&'static str, String>, 128 | _store: &Store, 129 | ) -> Result<(i32, Vec)> { 130 | // Get environment directory and parse command 131 | let env_dir = hook.env_path().expect("Python must have env path"); 132 | 133 | let cmds = shlex::split(&hook.entry) 134 | .ok_or_else(|| anyhow::anyhow!("Failed to parse entry command"))?; 135 | 136 | // Construct PATH with venv bin directory first 137 | let new_path = std::env::join_paths( 138 | std::iter::once(bin_dir(env_dir)).chain( 139 | EnvVars::var_os(EnvVars::PATH) 140 | .as_ref() 141 | .iter() 142 | .flat_map(std::env::split_paths), 143 | ), 144 | )?; 145 | 146 | let run = async move |batch: Vec| { 147 | // TODO: combine stdout and stderr 148 | let mut output = Cmd::new(&cmds[0], "run python command") 149 | .args(&cmds[1..]) 150 | .env("VIRTUAL_ENV", env_dir) 151 | .env("PATH", &new_path) 152 | .env_remove("PYTHONHOME") 153 | .envs(env_vars) 154 | .args(&hook.args) 155 | .args(batch) 156 | .check(false) 157 | .output() 158 | .await?; 159 | 160 | output.stdout.extend(output.stderr); 161 | let code = output.status.code().unwrap_or(1); 162 | anyhow::Ok((code, output.stdout)) 163 | }; 164 | 165 | let results = run_by_batch(hook, filenames, run).await?; 166 | 167 | // Collect results 168 | let mut combined_status = 0; 169 | let mut combined_output = Vec::new(); 170 | 171 | for (code, output) in results { 172 | combined_status |= code; 173 | combined_output.extend(output); 174 | } 175 | 176 | Ok((combined_status, combined_output)) 177 | } 178 | } 179 | 180 | fn bin_dir(venv: &Path) -> PathBuf { 181 | if cfg!(windows) { 182 | venv.join("Scripts") 183 | } else { 184 | venv.join("bin") 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/languages/python/uv.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::{Path, PathBuf}; 3 | use std::time::Duration; 4 | 5 | use anyhow::Result; 6 | use axoupdater::{AxoUpdater, ReleaseSource, ReleaseSourceType, UpdateRequest}; 7 | use tokio::task::JoinSet; 8 | use tracing::{debug, enabled, trace, warn}; 9 | 10 | use crate::config::LanguagePreference; 11 | use crate::fs::LockedFile; 12 | use crate::hook::Hook; 13 | use crate::process::Cmd; 14 | use crate::store::{Store, ToolBucket}; 15 | 16 | use constants::env_vars::EnvVars; 17 | 18 | // The version of `uv` to install. Should update periodically. 19 | const UV_VERSION: &str = "0.6.0"; 20 | 21 | #[derive(Debug)] 22 | enum PyPiMirror { 23 | Pypi, 24 | Tuna, 25 | Aliyun, 26 | Tencent, 27 | Custom(String), 28 | } 29 | 30 | // TODO: support reading pypi source user config, or allow user to set mirror 31 | // TODO: allow opt-out uv 32 | 33 | impl PyPiMirror { 34 | fn url(&self) -> &str { 35 | match self { 36 | Self::Pypi => "https://pypi.org/simple/", 37 | Self::Tuna => "https://pypi.tuna.tsinghua.edu.cn/simple/", 38 | Self::Aliyun => "https://mirrors.aliyun.com/pypi/simple/", 39 | Self::Tencent => "https://mirrors.cloud.tencent.com/pypi/simple/", 40 | Self::Custom(url) => url, 41 | } 42 | } 43 | 44 | fn iter() -> impl Iterator { 45 | vec![Self::Pypi, Self::Tuna, Self::Aliyun, Self::Tencent].into_iter() 46 | } 47 | } 48 | 49 | #[derive(Debug)] 50 | enum InstallSource { 51 | /// Download uv from GitHub releases. 52 | GitHub, 53 | /// Download uv from `PyPi`. 54 | PyPi(PyPiMirror), 55 | /// Install uv by running `pip install uv`. 56 | Pip, 57 | } 58 | 59 | impl InstallSource { 60 | async fn install(&self, target: &Path) -> Result<()> { 61 | match self { 62 | Self::GitHub => self.install_from_github(target).await, 63 | Self::PyPi(source) => self.install_from_pypi(target, source).await, 64 | Self::Pip => self.install_from_pip(target).await, 65 | } 66 | } 67 | 68 | async fn install_from_github(&self, target: &Path) -> Result<()> { 69 | let mut installer = AxoUpdater::new_for("uv"); 70 | installer.configure_version_specifier(UpdateRequest::SpecificTag(UV_VERSION.to_string())); 71 | installer.always_update(true); 72 | installer.set_install_dir(&target.to_string_lossy()); 73 | installer.set_release_source(ReleaseSource { 74 | release_type: ReleaseSourceType::GitHub, 75 | owner: "astral-sh".to_string(), 76 | name: "uv".to_string(), 77 | app_name: "uv".to_string(), 78 | }); 79 | if enabled!(tracing::Level::DEBUG) { 80 | installer.enable_installer_output(); 81 | unsafe { env::set_var("INSTALLER_PRINT_VERBOSE", "1") }; 82 | } else { 83 | installer.disable_installer_output(); 84 | } 85 | // We don't want the installer to modify the PATH, and don't need the receipt. 86 | unsafe { env::set_var("UV_UNMANAGED_INSTALL", "1") }; 87 | 88 | match installer.run().await { 89 | Ok(Some(result)) => { 90 | debug!( 91 | uv = %target.display(), 92 | version = result.new_version_tag, 93 | "Successfully installed uv" 94 | ); 95 | Ok(()) 96 | } 97 | Ok(None) => Ok(()), 98 | Err(err) => { 99 | warn!(?err, "Failed to install uv"); 100 | Err(err.into()) 101 | } 102 | } 103 | } 104 | 105 | async fn install_from_pypi(&self, target: &Path, _source: &PyPiMirror) -> Result<()> { 106 | // TODO: Implement this, currently just fallback to pip install 107 | // Determine the host system 108 | // Get the html page 109 | // Parse html, get the latest version url 110 | // Download the tarball 111 | // Extract the tarball 112 | self.install_from_pip(target).await 113 | } 114 | 115 | async fn install_from_pip(&self, target: &Path) -> Result<()> { 116 | Cmd::new("python3", "pip install uv") 117 | .arg("-m") 118 | .arg("pip") 119 | .arg("install") 120 | .arg("--prefix") 121 | .arg(target) 122 | .arg(format!("uv=={UV_VERSION}")) 123 | .check(true) 124 | .output() 125 | .await?; 126 | 127 | let bin_dir = target.join(if cfg!(windows) { "Scripts" } else { "bin" }); 128 | let lib_dir = target.join(if cfg!(windows) { "Lib" } else { "lib" }); 129 | 130 | let uv = target 131 | .join(&bin_dir) 132 | .join("uv") 133 | .with_extension(env::consts::EXE_EXTENSION); 134 | fs_err::tokio::rename( 135 | &uv, 136 | target.join("uv").with_extension(env::consts::EXE_EXTENSION), 137 | ) 138 | .await?; 139 | fs_err::tokio::remove_dir_all(bin_dir).await?; 140 | fs_err::tokio::remove_dir_all(lib_dir).await?; 141 | 142 | Ok(()) 143 | } 144 | } 145 | 146 | pub struct Uv { 147 | path: PathBuf, 148 | } 149 | 150 | impl Uv { 151 | pub fn new(path: PathBuf) -> Self { 152 | Self { path } 153 | } 154 | 155 | pub fn cmd(&self, summary: &str) -> Cmd { 156 | Cmd::new(&self.path, summary) 157 | } 158 | 159 | pub async fn find_python(&self, hook: &Hook, store: &Store) -> Result> { 160 | let python_preference = match hook.language_version.preference { 161 | LanguagePreference::Managed => "managed", 162 | LanguagePreference::OnlySystem => "only-system", 163 | LanguagePreference::OnlyManaged => "only-managed", 164 | }; 165 | 166 | let mut cmd = Cmd::new(&self.path, "uv resolve"); 167 | 168 | cmd.arg("python") 169 | .arg("find") 170 | .arg("--python-preference") 171 | .arg(python_preference) 172 | .arg("--no-project") 173 | .env( 174 | EnvVars::UV_PYTHON_INSTALL_DIR, 175 | store.tools_path(ToolBucket::Python), 176 | ); 177 | if let Some(req) = &hook.language_version.request { 178 | cmd.arg(req.to_string()); 179 | } 180 | 181 | let output = cmd.check(true).output().await?; 182 | let stdout = String::from_utf8(output.stdout)?; 183 | Ok(stdout.lines().map(PathBuf::from).collect()) 184 | } 185 | 186 | async fn select_source() -> Result { 187 | async fn check_github(client: &reqwest::Client) -> Result { 188 | let url = format!( 189 | "https://github.com/astral-sh/uv/releases/download/{UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" 190 | ); 191 | let response = client 192 | .head(url) 193 | .timeout(Duration::from_secs(3)) 194 | .send() 195 | .await?; 196 | trace!(?response, "Checked GitHub"); 197 | Ok(response.status().is_success()) 198 | } 199 | 200 | async fn select_best_pypi(client: &reqwest::Client) -> Result { 201 | let mut best = PyPiMirror::Pypi; 202 | let mut tasks = PyPiMirror::iter() 203 | .map(|source| { 204 | let client = client.clone(); 205 | async move { 206 | let url = format!("{}uv/", source.url()); 207 | let response = client 208 | .head(&url) 209 | .timeout(Duration::from_secs(2)) 210 | .send() 211 | .await; 212 | (source, response) 213 | } 214 | }) 215 | .collect::>(); 216 | 217 | while let Some(result) = tasks.join_next().await { 218 | if let Ok((source, response)) = result { 219 | trace!(?source, ?response, "Checked source"); 220 | if response.is_ok_and(|resp| resp.status().is_success()) { 221 | best = source; 222 | break; 223 | } 224 | } 225 | } 226 | 227 | Ok(best) 228 | } 229 | 230 | let client = reqwest::Client::new(); 231 | let source = tokio::select! { 232 | Ok(true) = check_github(&client) => InstallSource::GitHub, 233 | Ok(source) = select_best_pypi(&client) => InstallSource::PyPi(source), 234 | else => { 235 | warn!("Failed to check uv source availability, falling back to pip install"); 236 | InstallSource::Pip 237 | } 238 | }; 239 | 240 | trace!(?source, "Selected uv source"); 241 | Ok(source) 242 | } 243 | 244 | pub async fn install(store: &Store) -> Result { 245 | // 1) Check if `uv` is installed already. 246 | // TODO: check minimum supported uv version 247 | if let Ok(uv) = which::which("uv") { 248 | trace!(uv = %uv.display(), "Found uv from PATH"); 249 | return Ok(Self::new(uv)); 250 | } 251 | 252 | // 2) Check if `uv` is installed by `prefligit` 253 | let uv_dir = store.tools_path(ToolBucket::Uv); 254 | let uv = uv_dir.join("uv").with_extension(env::consts::EXE_EXTENSION); 255 | if uv.is_file() { 256 | trace!(uv = %uv.display(), "Found managed uv"); 257 | return Ok(Self::new(uv)); 258 | } 259 | 260 | fs_err::tokio::create_dir_all(&uv_dir).await?; 261 | let _lock = LockedFile::acquire(uv_dir.join(".lock"), "uv").await?; 262 | 263 | if uv.is_file() { 264 | trace!(uv = %uv.display(), "Found managed uv"); 265 | return Ok(Self::new(uv)); 266 | } 267 | 268 | let source = Self::select_source().await?; 269 | source.install(&uv_dir).await?; 270 | 271 | Ok(Self::new(uv)) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/languages/system.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::hook::{Hook, ResolvedHook}; 6 | use crate::languages::LanguageImpl; 7 | use crate::process::Cmd; 8 | use crate::run::run_by_batch; 9 | use crate::store::Store; 10 | 11 | #[derive(Debug, Copy, Clone)] 12 | pub struct System; 13 | 14 | impl LanguageImpl for System { 15 | fn supports_dependency(&self) -> bool { 16 | false 17 | } 18 | 19 | async fn resolve(&self, hook: &Hook, _store: &Store) -> Result { 20 | Ok(ResolvedHook::NoNeedInstall(hook.clone())) 21 | } 22 | 23 | async fn install(&self, _hook: &ResolvedHook, _store: &Store) -> Result<()> { 24 | Ok(()) 25 | } 26 | 27 | async fn check_health(&self) -> Result<()> { 28 | Ok(()) 29 | } 30 | 31 | async fn run( 32 | &self, 33 | hook: &ResolvedHook, 34 | filenames: &[&String], 35 | env_vars: &HashMap<&'static str, String>, 36 | _store: &Store, 37 | ) -> Result<(i32, Vec)> { 38 | let cmds = shlex::split(&hook.entry).ok_or(anyhow::anyhow!("Failed to parse entry"))?; 39 | 40 | let run = async move |batch: Vec| { 41 | let mut output = Cmd::new(&cmds[0], "run system command") 42 | .args(&cmds[1..]) 43 | .args(&hook.args) 44 | .args(batch) 45 | .envs(env_vars) 46 | .check(false) 47 | .output() 48 | .await?; 49 | 50 | output.stdout.extend(output.stderr); 51 | let code = output.status.code().unwrap_or(1); 52 | anyhow::Ok((code, output.stdout)) 53 | }; 54 | 55 | let results = run_by_batch(hook, filenames, run).await?; 56 | 57 | // Collect results 58 | let mut combined_status = 0; 59 | let mut combined_output = Vec::new(); 60 | 61 | for (code, output) in results { 62 | combined_status |= code; 63 | combined_output.extend(output); 64 | } 65 | 66 | Ok((combined_status, combined_output)) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | use std::path::{Path, PathBuf}; 3 | use std::process::ExitCode; 4 | use std::str::FromStr; 5 | 6 | use anstream::{ColorChoice, eprintln}; 7 | use anyhow::{Context, Result}; 8 | use clap::{CommandFactory, Parser}; 9 | use owo_colors::OwoColorize; 10 | use tracing::{debug, error}; 11 | use tracing_subscriber::EnvFilter; 12 | use tracing_subscriber::filter::Directive; 13 | 14 | use crate::cleanup::cleanup; 15 | use crate::cli::{Cli, Command, ExitStatus, SelfCommand, SelfNamespace, SelfUpdateArgs}; 16 | use crate::git::get_root; 17 | use crate::printer::Printer; 18 | 19 | mod archive; 20 | mod builtin; 21 | mod cleanup; 22 | mod cli; 23 | mod config; 24 | mod fs; 25 | mod git; 26 | mod hook; 27 | mod identify; 28 | mod languages; 29 | mod printer; 30 | mod process; 31 | #[cfg(all(unix, feature = "profiler"))] 32 | mod profiler; 33 | mod run; 34 | mod store; 35 | mod version; 36 | mod warnings; 37 | 38 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] 39 | pub(crate) enum Level { 40 | /// Suppress all tracing output by default (overridable by `RUST_LOG`). 41 | #[default] 42 | Default, 43 | /// Show verbose messages. 44 | Verbose, 45 | /// Show debug messages by default (overridable by `RUST_LOG`). 46 | Debug, 47 | /// Show trace messages by default (overridable by `RUST_LOG`). 48 | Trace, 49 | /// Show trace messages for all crates by default (overridable by `RUST_LOG`). 50 | TraceAll, 51 | } 52 | 53 | fn setup_logging(level: Level) -> Result<()> { 54 | let directive = match level { 55 | Level::Default | Level::Verbose => tracing::level_filters::LevelFilter::OFF.into(), 56 | Level::Debug => Directive::from_str("prefligit=debug")?, 57 | Level::Trace => Directive::from_str("prefligit=trace")?, 58 | Level::TraceAll => Directive::from_str("trace")?, 59 | }; 60 | 61 | let filter = EnvFilter::builder() 62 | .with_default_directive(directive) 63 | .from_env() 64 | .context("Invalid RUST_LOG directive")?; 65 | 66 | let ansi = match anstream::Stderr::choice(&std::io::stderr()) { 67 | ColorChoice::Always | ColorChoice::AlwaysAnsi => true, 68 | ColorChoice::Never => false, 69 | // We just asked anstream for a choice, that can't be auto 70 | ColorChoice::Auto => unreachable!(), 71 | }; 72 | 73 | let format = tracing_subscriber::fmt::format() 74 | .with_target(false) 75 | .without_time() 76 | .with_ansi(ansi); 77 | tracing_subscriber::fmt::fmt() 78 | .with_env_filter(filter) 79 | .event_format(format) 80 | .with_writer(anstream::stderr) 81 | .init(); 82 | Ok(()) 83 | } 84 | 85 | /// Adjusts relative paths in the CLI arguments to be relative to the new working directory. 86 | fn adjust_relative_paths(cli: &mut Cli, new_cwd: &Path) -> Result<()> { 87 | if let Some(path) = &mut cli.globals.config { 88 | if path.exists() { 89 | *path = std::path::absolute(&*path)?; 90 | } 91 | } 92 | 93 | if let Some(Command::Run(ref mut args) | Command::TryRepo(ref mut args)) = cli.command { 94 | args.files = args 95 | .files 96 | .iter() 97 | .map(|path| fs::relative_to(std::path::absolute(path)?, new_cwd)) 98 | .collect::, std::io::Error>>()?; 99 | args.extra.commit_msg_filename = args 100 | .extra 101 | .commit_msg_filename 102 | .as_ref() 103 | .map(|path| fs::relative_to(std::path::absolute(path)?, new_cwd)) 104 | .transpose()?; 105 | } 106 | 107 | Ok(()) 108 | } 109 | 110 | async fn run(mut cli: Cli) -> Result { 111 | ColorChoice::write_global(cli.globals.color.into()); 112 | 113 | setup_logging(match cli.globals.verbose { 114 | 0 => Level::Default, 115 | 1 => Level::Verbose, 116 | 2 => Level::Debug, 117 | 3 => Level::Trace, 118 | _ => Level::TraceAll, 119 | })?; 120 | 121 | let printer = if cli.globals.quiet { 122 | Printer::Quiet 123 | } else if cli.globals.verbose > 0 { 124 | Printer::Verbose 125 | } else if cli.globals.no_progress { 126 | Printer::NoProgress 127 | } else { 128 | Printer::Default 129 | }; 130 | 131 | if cli.globals.quiet { 132 | warnings::disable(); 133 | } else { 134 | warnings::enable(); 135 | } 136 | 137 | if cli.command.is_none() { 138 | cli.command = Some(Command::Run(Box::new(cli.run_args.clone()))); 139 | } 140 | 141 | debug!("prefligit: {}", version::version()); 142 | 143 | match get_root().await { 144 | Ok(root) => { 145 | debug!("Git root: {}", root.display()); 146 | 147 | // Adjust relative paths before changing the working directory. 148 | adjust_relative_paths(&mut cli, &root)?; 149 | 150 | std::env::set_current_dir(&root)?; 151 | } 152 | Err(err) => { 153 | error!("Failed to find git root: {}", err); 154 | } 155 | } 156 | 157 | macro_rules! show_settings { 158 | ($arg:expr) => { 159 | if cli.globals.show_settings { 160 | writeln!(printer.stdout(), "{:#?}", $arg)?; 161 | return Ok(ExitStatus::Success); 162 | } 163 | }; 164 | ($arg:expr, false) => { 165 | if cli.globals.show_settings { 166 | writeln!(printer.stdout(), "{:#?}", $arg)?; 167 | } 168 | }; 169 | } 170 | show_settings!(cli.globals, false); 171 | 172 | match cli.command.unwrap() { 173 | Command::Install(args) => { 174 | show_settings!(args); 175 | 176 | cli::install( 177 | cli.globals.config, 178 | args.hook_types, 179 | args.install_hooks, 180 | args.overwrite, 181 | args.allow_missing_config, 182 | printer, 183 | None, 184 | ) 185 | .await 186 | } 187 | Command::Uninstall(args) => { 188 | show_settings!(args); 189 | 190 | cli::uninstall(cli.globals.config, args.hook_types, printer).await 191 | } 192 | Command::Run(args) => { 193 | show_settings!(args); 194 | 195 | cli::run( 196 | cli.globals.config, 197 | args.hook_id, 198 | args.hook_stage, 199 | args.from_ref, 200 | args.to_ref, 201 | args.all_files, 202 | args.files, 203 | args.show_diff_on_failure, 204 | args.extra, 205 | cli.globals.verbose > 0, 206 | printer, 207 | ) 208 | .await 209 | } 210 | Command::HookImpl(args) => { 211 | show_settings!(args); 212 | 213 | cli::hook_impl( 214 | cli.globals.config, 215 | args.hook_type, 216 | args.hook_dir, 217 | args.skip_on_missing_config, 218 | args.args, 219 | printer, 220 | ) 221 | .await 222 | } 223 | Command::Clean => cli::clean(printer), 224 | Command::ValidateConfig(args) => { 225 | show_settings!(args); 226 | 227 | Ok(cli::validate_configs(args.configs)) 228 | } 229 | Command::ValidateManifest(args) => { 230 | show_settings!(args); 231 | 232 | Ok(cli::validate_manifest(args.manifests)) 233 | } 234 | Command::SampleConfig => Ok(cli::sample_config()), 235 | Command::Self_(SelfNamespace { 236 | command: 237 | SelfCommand::Update(SelfUpdateArgs { 238 | target_version, 239 | token, 240 | }), 241 | }) => cli::self_update(target_version, token, printer).await, 242 | Command::GenerateShellCompletion(args) => { 243 | show_settings!(args); 244 | 245 | let mut command = Cli::command(); 246 | let bin_name = command 247 | .get_bin_name() 248 | .unwrap_or_else(|| command.get_name()) 249 | .to_owned(); 250 | clap_complete::generate(args.shell, &mut command, bin_name, &mut std::io::stdout()); 251 | Ok(ExitStatus::Success) 252 | } 253 | Command::InitTemplateDir(args) => { 254 | show_settings!(args); 255 | 256 | cli::init_template_dir( 257 | args.directory, 258 | cli.globals.config, 259 | args.hook_types, 260 | args.no_allow_missing_config, 261 | printer, 262 | ) 263 | .await 264 | } 265 | _ => { 266 | writeln!(printer.stderr(), "Command not implemented yet")?; 267 | Ok(ExitStatus::Failure) 268 | } 269 | } 270 | } 271 | 272 | fn main() -> ExitCode { 273 | ctrlc::set_handler(move || { 274 | cleanup(); 275 | 276 | #[allow(clippy::exit, clippy::cast_possible_wrap)] 277 | std::process::exit(if cfg!(windows) { 278 | 0xC000_013A_u32 as i32 279 | } else { 280 | 130 281 | }); 282 | }) 283 | .expect("Error setting Ctrl-C handler"); 284 | 285 | let cli = match Cli::try_parse() { 286 | Ok(cli) => cli, 287 | Err(err) => err.exit(), 288 | }; 289 | 290 | // Initialize the profiler guard if the feature is enabled. 291 | let mut _profiler_guard = None; 292 | #[cfg(all(unix, feature = "profiler"))] 293 | { 294 | _profiler_guard = profiler::start_profiling(); 295 | } 296 | #[cfg(not(all(unix, feature = "profiler")))] 297 | { 298 | _profiler_guard = Some(()); 299 | } 300 | 301 | let runtime = tokio::runtime::Builder::new_current_thread() 302 | .enable_all() 303 | .build() 304 | .expect("Failed to create tokio runtime"); 305 | let result = runtime.block_on(Box::pin(run(cli))); 306 | runtime.shutdown_background(); 307 | 308 | // Report the profiler if the feature is enabled 309 | #[cfg(all(unix, feature = "profiler"))] 310 | { 311 | profiler::finish_profiling(_profiler_guard); 312 | } 313 | 314 | match result { 315 | Ok(code) => code.into(), 316 | Err(err) => { 317 | let mut causes = err.chain(); 318 | eprintln!("{}: {}", "error".red().bold(), causes.next().unwrap()); 319 | for err in causes { 320 | eprintln!(" {}: {}", "caused by".red().bold(), err); 321 | } 322 | ExitStatus::Error.into() 323 | } 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/printer.rs: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Astral Software Inc. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | use anstream::{eprint, print}; 24 | use indicatif::ProgressDrawTarget; 25 | 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 27 | pub enum Printer { 28 | /// A printer that prints to standard streams (e.g., stdout). 29 | Default, 30 | /// A printer that suppresses all output. 31 | Quiet, 32 | /// A printer that prints all output, including debug messages. 33 | Verbose, 34 | /// A printer that prints to standard streams, excluding all progress outputs 35 | NoProgress, 36 | } 37 | 38 | impl Printer { 39 | /// Return the [`ProgressDrawTarget`] for this printer. 40 | pub fn target(self) -> ProgressDrawTarget { 41 | match self { 42 | Self::Default => ProgressDrawTarget::stderr(), 43 | Self::Quiet => ProgressDrawTarget::hidden(), 44 | // Confusingly, hide the progress bar when in verbose mode. 45 | // Otherwise, it gets interleaved with debug messages. 46 | Self::Verbose => ProgressDrawTarget::hidden(), 47 | Self::NoProgress => ProgressDrawTarget::hidden(), 48 | } 49 | } 50 | 51 | /// Return the [`Stdout`] for this printer. 52 | pub fn stdout(self) -> Stdout { 53 | match self { 54 | Self::Default => Stdout::Enabled, 55 | Self::Quiet => Stdout::Disabled, 56 | Self::Verbose => Stdout::Enabled, 57 | Self::NoProgress => Stdout::Enabled, 58 | } 59 | } 60 | 61 | /// Return the [`Stderr`] for this printer. 62 | pub fn stderr(self) -> Stderr { 63 | match self { 64 | Self::Default => Stderr::Enabled, 65 | Self::Quiet => Stderr::Disabled, 66 | Self::Verbose => Stderr::Enabled, 67 | Self::NoProgress => Stderr::Enabled, 68 | } 69 | } 70 | } 71 | 72 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 73 | pub enum Stdout { 74 | Enabled, 75 | Disabled, 76 | } 77 | 78 | impl std::fmt::Write for Stdout { 79 | fn write_str(&mut self, s: &str) -> std::fmt::Result { 80 | match self { 81 | Self::Enabled => { 82 | #[allow(clippy::print_stdout, clippy::ignored_unit_patterns)] 83 | { 84 | print!("{s}"); 85 | } 86 | } 87 | Self::Disabled => {} 88 | } 89 | 90 | Ok(()) 91 | } 92 | } 93 | 94 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 95 | pub enum Stderr { 96 | Enabled, 97 | Disabled, 98 | } 99 | 100 | impl std::fmt::Write for Stderr { 101 | fn write_str(&mut self, s: &str) -> std::fmt::Result { 102 | match self { 103 | Self::Enabled => { 104 | #[allow(clippy::print_stderr, clippy::ignored_unit_patterns)] 105 | { 106 | eprint!("{s}"); 107 | } 108 | } 109 | Self::Disabled => {} 110 | } 111 | 112 | Ok(()) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/profiler.rs: -------------------------------------------------------------------------------- 1 | use tracing::error; 2 | 3 | /// Creates a profiler guard and returns it. 4 | pub(crate) fn start_profiling() -> Option> { 5 | match pprof::ProfilerGuardBuilder::default() 6 | .frequency(1000) 7 | .blocklist(&["libc", "libgcc", "pthread", "vdso"]) 8 | .build() 9 | { 10 | Ok(guard) => Some(guard), 11 | Err(e) => { 12 | error!("Failed to build profiler guard: {e}"); 13 | None 14 | } 15 | } 16 | } 17 | 18 | /// Reports the profiling results. 19 | pub(crate) fn finish_profiling(profiler_guard: Option) { 20 | match profiler_guard 21 | .expect("Failed to retrieve profiler guard") 22 | .report() 23 | .build() 24 | { 25 | Ok(report) => { 26 | #[cfg(feature = "profiler-flamegraph")] 27 | { 28 | let random = rand::random::(); 29 | let file = fs_err::File::create(format!( 30 | "{}.{random}.flamegraph.svg", 31 | env!("CARGO_PKG_NAME"), 32 | )) 33 | .expect("Failed to create flamegraph file"); 34 | if let Err(e) = report.flamegraph(file) { 35 | error!("failed to create flamegraph file: {e}"); 36 | } 37 | } 38 | 39 | #[cfg(not(feature = "profiler-flamegraph"))] 40 | { 41 | info!("profiling report: {:?}", &report); 42 | } 43 | } 44 | Err(e) => { 45 | error!("Failed to build profiler report: {e}"); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/run.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::max; 2 | use std::sync::LazyLock; 3 | 4 | use futures::StreamExt; 5 | use tracing::trace; 6 | 7 | use constants::env_vars::EnvVars; 8 | 9 | use crate::hook::Hook; 10 | 11 | pub static CONCURRENCY: LazyLock = LazyLock::new(|| { 12 | if EnvVars::is_set(EnvVars::PREFLIGIT_NO_CONCURRENCY) { 13 | 1 14 | } else { 15 | std::thread::available_parallelism() 16 | .map(std::num::NonZero::get) 17 | .unwrap_or(1) 18 | } 19 | }); 20 | 21 | fn target_concurrency(serial: bool) -> usize { 22 | if serial { 1 } else { *CONCURRENCY } 23 | } 24 | 25 | /// Iterator that yields partitions of filenames that fit within the maximum command line length. 26 | struct Partitions<'a> { 27 | hook: &'a Hook, 28 | filenames: &'a [&'a String], 29 | concurrency: usize, 30 | current_index: usize, 31 | command_length: usize, 32 | max_per_batch: usize, 33 | max_cli_length: usize, 34 | } 35 | 36 | // TODO: do a more accurate calculation 37 | impl<'a> Partitions<'a> { 38 | fn new(hook: &'a Hook, filenames: &'a [&'a String], concurrency: usize) -> Self { 39 | let max_per_batch = max(4, filenames.len().div_ceil(concurrency)); 40 | // TODO: subtract the env size 41 | let max_cli_length = if cfg!(unix) { 42 | 1 << 12 43 | } else { 44 | (1 << 15) - 2048 // UNICODE_STRING max - headroom 45 | }; 46 | let command_length = 47 | hook.entry.len() + hook.args.iter().map(String::len).sum::() + hook.args.len(); 48 | 49 | Self { 50 | hook, 51 | filenames, 52 | concurrency, 53 | current_index: 0, 54 | command_length, 55 | max_per_batch, 56 | max_cli_length, 57 | } 58 | } 59 | } 60 | 61 | impl<'a> Iterator for Partitions<'a> { 62 | // TODO: produce slices instead of Vec 63 | type Item = Vec<&'a String>; 64 | 65 | fn next(&mut self) -> Option { 66 | // Handle empty filenames case 67 | if self.filenames.is_empty() && self.current_index == 0 { 68 | self.current_index = 1; 69 | return Some(vec![]); 70 | } 71 | 72 | if self.current_index >= self.filenames.len() { 73 | return None; 74 | } 75 | 76 | let mut current = Vec::new(); 77 | let mut current_length = self.command_length + 1; 78 | 79 | while self.current_index < self.filenames.len() { 80 | let filename = self.filenames[self.current_index]; 81 | let length = filename.len() + 1; 82 | 83 | if current_length + length > self.max_cli_length || current.len() >= self.max_per_batch 84 | { 85 | break; 86 | } 87 | 88 | current.push(filename); 89 | current_length += length; 90 | self.current_index += 1; 91 | } 92 | 93 | if current.is_empty() { 94 | None 95 | } else { 96 | Some(current) 97 | } 98 | } 99 | } 100 | 101 | pub async fn run_by_batch( 102 | hook: &Hook, 103 | filenames: &[&String], 104 | run: F, 105 | ) -> anyhow::Result> 106 | where 107 | F: AsyncFn(Vec) -> anyhow::Result, 108 | T: Send + 'static, 109 | { 110 | let concurrency = target_concurrency(hook.require_serial); 111 | 112 | // Split files into batches 113 | let partitions = Partitions::new(hook, filenames, concurrency); 114 | trace!( 115 | total_files = filenames.len(), 116 | concurrency = concurrency, 117 | "Running {}", 118 | hook.id, 119 | ); 120 | 121 | let mut tasks = futures::stream::iter(partitions) 122 | .map(|batch| { 123 | // TODO: avoid this allocation 124 | let batch: Vec<_> = batch.into_iter().map(ToString::to_string).collect(); 125 | run(batch) 126 | }) 127 | .buffered(concurrency); 128 | 129 | let mut results = Vec::new(); 130 | while let Some(result) = tasks.next().await { 131 | results.push(result?); 132 | } 133 | 134 | Ok(results) 135 | } 136 | -------------------------------------------------------------------------------- /src/snapshots/prefligit__config__tests__read_manifest.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: manifest 4 | --- 5 | Manifest { 6 | hooks: [ 7 | ManifestHook { 8 | id: "pip-compile", 9 | name: "pip-compile", 10 | entry: "uv pip compile", 11 | language: Python, 12 | options: HookOptions { 13 | alias: None, 14 | files: Some( 15 | "^requirements\\.(in|txt)$", 16 | ), 17 | exclude: None, 18 | types: None, 19 | types_or: None, 20 | exclude_types: None, 21 | additional_dependencies: Some( 22 | [], 23 | ), 24 | args: Some( 25 | [], 26 | ), 27 | always_run: None, 28 | fail_fast: None, 29 | pass_filenames: Some( 30 | false, 31 | ), 32 | description: Some( 33 | "Automatically run 'uv pip compile' on your requirements", 34 | ), 35 | language_version: None, 36 | log_file: None, 37 | require_serial: None, 38 | stages: None, 39 | verbose: None, 40 | minimum_pre_commit_version: Some( 41 | "2.9.2", 42 | ), 43 | }, 44 | }, 45 | ManifestHook { 46 | id: "uv-lock", 47 | name: "uv-lock", 48 | entry: "uv lock", 49 | language: Python, 50 | options: HookOptions { 51 | alias: None, 52 | files: Some( 53 | "^(uv\\.lock|pyproject\\.toml|uv\\.toml)$", 54 | ), 55 | exclude: None, 56 | types: None, 57 | types_or: None, 58 | exclude_types: None, 59 | additional_dependencies: Some( 60 | [], 61 | ), 62 | args: Some( 63 | [], 64 | ), 65 | always_run: None, 66 | fail_fast: None, 67 | pass_filenames: Some( 68 | false, 69 | ), 70 | description: Some( 71 | "Automatically run 'uv lock' on your project dependencies", 72 | ), 73 | language_version: None, 74 | log_file: None, 75 | require_serial: None, 76 | stages: None, 77 | verbose: None, 78 | minimum_pre_commit_version: Some( 79 | "2.9.2", 80 | ), 81 | }, 82 | }, 83 | ManifestHook { 84 | id: "uv-export", 85 | name: "uv-export", 86 | entry: "uv export", 87 | language: Python, 88 | options: HookOptions { 89 | alias: None, 90 | files: Some( 91 | "^uv\\.lock$", 92 | ), 93 | exclude: None, 94 | types: None, 95 | types_or: None, 96 | exclude_types: None, 97 | additional_dependencies: Some( 98 | [], 99 | ), 100 | args: Some( 101 | [ 102 | "--frozen", 103 | "--output-file=requirements.txt", 104 | ], 105 | ), 106 | always_run: None, 107 | fail_fast: None, 108 | pass_filenames: Some( 109 | false, 110 | ), 111 | description: Some( 112 | "Automatically run 'uv export' on your project dependencies", 113 | ), 114 | language_version: None, 115 | log_file: None, 116 | require_serial: None, 117 | stages: None, 118 | verbose: None, 119 | minimum_pre_commit_version: Some( 120 | "2.9.2", 121 | ), 122 | }, 123 | }, 124 | ], 125 | } 126 | -------------------------------------------------------------------------------- /src/store.rs: -------------------------------------------------------------------------------- 1 | use std::hash::{Hash, Hasher}; 2 | use std::io::Write; 3 | use std::path::{Path, PathBuf}; 4 | use std::sync::LazyLock; 5 | 6 | use anyhow::Result; 7 | use etcetera::BaseStrategy; 8 | use seahash::SeaHasher; 9 | use thiserror::Error; 10 | use tracing::debug; 11 | 12 | use constants::env_vars::EnvVars; 13 | 14 | use crate::config::RemoteRepo; 15 | use crate::fs::LockedFile; 16 | use crate::git::clone_repo; 17 | use crate::hook::InstallInfo; 18 | 19 | #[derive(Debug, Error)] 20 | pub enum Error { 21 | #[error("Home directory not found")] 22 | HomeNotFound, 23 | #[error(transparent)] 24 | Io(#[from] std::io::Error), 25 | #[error(transparent)] 26 | Fmt(#[from] std::fmt::Error), 27 | #[error(transparent)] 28 | Repo(#[from] crate::hook::Error), 29 | #[error(transparent)] 30 | Git(#[from] crate::git::Error), 31 | #[error(transparent)] 32 | Serde(#[from] serde_json::Error), 33 | } 34 | 35 | static STORE_HOME: LazyLock> = LazyLock::new(|| { 36 | if let Some(path) = EnvVars::var_os(EnvVars::PREFLIGIT_HOME) { 37 | debug!( 38 | path = %path.to_string_lossy(), 39 | "Loading store from PREFLIGIT_HOME env var", 40 | ); 41 | Some(path.into()) 42 | } else { 43 | etcetera::choose_base_strategy() 44 | .map(|path| path.cache_dir().join("prefligit")) 45 | .ok() 46 | } 47 | }); 48 | 49 | /// A store for managing repos. 50 | #[derive(Debug)] 51 | pub struct Store { 52 | path: PathBuf, 53 | } 54 | 55 | impl Store { 56 | pub fn from_settings() -> Result { 57 | Ok(Self::from_path( 58 | STORE_HOME.as_ref().ok_or(Error::HomeNotFound)?, 59 | )) 60 | } 61 | 62 | pub fn from_path(path: impl Into) -> Self { 63 | Self { path: path.into() } 64 | } 65 | 66 | pub fn path(&self) -> &Path { 67 | self.path.as_ref() 68 | } 69 | 70 | /// Initialize the store. 71 | pub fn init(self) -> Result { 72 | fs_err::create_dir_all(&self.path)?; 73 | 74 | match fs_err::OpenOptions::new() 75 | .write(true) 76 | .create_new(true) 77 | .open(self.path.join("README")) { 78 | Ok(mut f) => f.write_all(b"This directory is maintained by the prefligit project.\nLearn more: https://github.com/j178/prefligit\n")?, 79 | Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => (), 80 | Err(err) => return Err(err.into()), 81 | } 82 | Ok(self) 83 | } 84 | 85 | /// Clone a remote repo into the store. 86 | pub async fn clone_repo(&self, repo: &RemoteRepo) -> Result { 87 | // Check if the repo is already cloned. 88 | let target = self.repo_path(repo); 89 | if target.join(".prefligit-repo.json").try_exists()? { 90 | return Ok(target); 91 | } 92 | 93 | fs_err::tokio::create_dir_all(self.repos_dir()).await?; 94 | 95 | // Clone and checkout the repo. 96 | let temp = tempfile::tempdir_in(self.repos_dir())?; 97 | debug!( 98 | target = %temp.path().display(), 99 | %repo, 100 | "Cloning repo", 101 | ); 102 | clone_repo(repo.repo.as_str(), &repo.rev, temp.path()).await?; 103 | 104 | // TODO: add windows retry 105 | fs_err::tokio::remove_dir_all(&target).await.ok(); 106 | fs_err::tokio::rename(temp, &target).await?; 107 | 108 | let content = serde_json::to_string_pretty(&repo)?; 109 | fs_err::tokio::write(target.join(".prefligit-repo.json"), content).await?; 110 | 111 | Ok(target) 112 | } 113 | 114 | pub fn installed_hooks(&self) -> impl Iterator { 115 | fs_err::read_dir(self.hooks_dir()) 116 | .ok() 117 | .into_iter() 118 | .flatten() 119 | .flatten() 120 | .filter_map(|entry| { 121 | let path = entry.path(); 122 | let mut file = fs_err::File::open(path.join(".prefligit-hook.json")).ok()?; 123 | serde_json::from_reader(&mut file).ok() 124 | }) 125 | } 126 | 127 | /// Lock the store. 128 | pub fn lock(&self) -> Result { 129 | LockedFile::acquire_blocking(self.path.join(".lock"), "store") 130 | } 131 | 132 | pub async fn lock_async(&self) -> Result { 133 | LockedFile::acquire(self.path.join(".lock"), "store").await 134 | } 135 | 136 | /// Returns the path to the cloned repo. 137 | fn repo_path(&self, repo: &RemoteRepo) -> PathBuf { 138 | let mut hasher = SeaHasher::new(); 139 | repo.hash(&mut hasher); 140 | let digest = to_hex(hasher.finish()); 141 | self.repos_dir().join(digest) 142 | } 143 | 144 | pub fn repos_dir(&self) -> PathBuf { 145 | self.path.join("repos") 146 | } 147 | 148 | pub fn hooks_dir(&self) -> PathBuf { 149 | self.path.join("hooks") 150 | } 151 | 152 | pub fn patches_dir(&self) -> PathBuf { 153 | self.path.join("patches") 154 | } 155 | 156 | /// The path to the tool directory in the store. 157 | pub fn tools_path(&self, tool: ToolBucket) -> PathBuf { 158 | self.path.join("tools").join(tool.as_str()) 159 | } 160 | } 161 | 162 | #[derive(Copy, Clone)] 163 | pub enum ToolBucket { 164 | Uv, 165 | Python, 166 | Node, 167 | } 168 | 169 | impl ToolBucket { 170 | pub fn as_str(&self) -> &str { 171 | match self { 172 | ToolBucket::Uv => "uv", 173 | ToolBucket::Python => "python", 174 | ToolBucket::Node => "node", 175 | } 176 | } 177 | } 178 | 179 | /// Convert a u64 to a hex string. 180 | pub fn to_hex(num: u64) -> String { 181 | hex::encode(num.to_le_bytes()) 182 | } 183 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | /* MIT License 2 | 3 | Copyright (c) 2023 Astral Software Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | */ 23 | 24 | // See also 25 | use std::fmt; 26 | 27 | use serde::Serialize; 28 | 29 | /// Information about the git repository where prefligit was built from. 30 | #[derive(Serialize)] 31 | pub(crate) struct CommitInfo { 32 | short_commit_hash: String, 33 | commit_hash: String, 34 | commit_date: String, 35 | last_tag: Option, 36 | commits_since_last_tag: u32, 37 | } 38 | 39 | /// prefligit's version. 40 | #[derive(Serialize)] 41 | pub struct VersionInfo { 42 | /// prefligit's version, such as "0.0.6" 43 | version: String, 44 | /// Information about the git commit we may have been built from. 45 | /// 46 | /// `None` if not built from a git repo or if retrieval failed. 47 | commit_info: Option, 48 | } 49 | 50 | impl fmt::Display for VersionInfo { 51 | /// Formatted version information: "[+] ( )" 52 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 53 | write!(f, "{}", self.version)?; 54 | 55 | if let Some(ref ci) = self.commit_info { 56 | if ci.commits_since_last_tag > 0 { 57 | write!(f, "+{}", ci.commits_since_last_tag)?; 58 | } 59 | write!(f, " ({} {})", ci.short_commit_hash, ci.commit_date)?; 60 | } 61 | 62 | Ok(()) 63 | } 64 | } 65 | 66 | impl From for clap::builder::Str { 67 | fn from(val: VersionInfo) -> Self { 68 | val.to_string().into() 69 | } 70 | } 71 | 72 | /// Returns information about prefligit's version. 73 | pub fn version() -> VersionInfo { 74 | // Environment variables are only read at compile-time 75 | macro_rules! option_env_str { 76 | ($name:expr) => { 77 | option_env!($name).map(|s| s.to_string()) 78 | }; 79 | } 80 | 81 | // This version is pulled from Cargo.toml and set by Cargo 82 | let version = env!("CARGO_PKG_VERSION").to_string(); 83 | 84 | // Commit info is pulled from git and set by `build.rs` 85 | let commit_info = option_env_str!("PREFLIGIT_COMMIT_HASH").map(|commit_hash| CommitInfo { 86 | short_commit_hash: option_env_str!("PREFLIGIT_COMMIT_SHORT_HASH").unwrap(), 87 | commit_hash, 88 | commit_date: option_env_str!("PREFLIGIT_COMMIT_DATE").unwrap(), 89 | last_tag: option_env_str!("PREFLIGIT_LAST_TAG"), 90 | commits_since_last_tag: option_env_str!("PREFLIGIT_LAST_TAG_DISTANCE") 91 | .as_deref() 92 | .map_or(0, |value| value.parse::().unwrap_or(0)), 93 | }); 94 | 95 | VersionInfo { 96 | version, 97 | commit_info, 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/warnings.rs: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Astral Software Inc. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | use std::collections::HashSet; 24 | use std::sync::atomic::AtomicBool; 25 | use std::sync::{LazyLock, Mutex}; 26 | 27 | // macro hygiene: The user might not have direct dependencies on those crates 28 | #[doc(hidden)] 29 | pub use anstream; 30 | #[doc(hidden)] 31 | pub use owo_colors; 32 | 33 | /// Whether user-facing warnings are enabled. 34 | pub static ENABLED: AtomicBool = AtomicBool::new(false); 35 | 36 | /// Enable user-facing warnings. 37 | pub fn enable() { 38 | ENABLED.store(true, std::sync::atomic::Ordering::SeqCst); 39 | } 40 | 41 | /// Disable user-facing warnings. 42 | pub fn disable() { 43 | ENABLED.store(false, std::sync::atomic::Ordering::SeqCst); 44 | } 45 | 46 | /// Warn a user, if warnings are enabled. 47 | #[macro_export] 48 | macro_rules! warn_user { 49 | ($($arg:tt)*) => { 50 | use $crate::warnings::anstream::eprintln; 51 | use $crate::warnings::owo_colors::OwoColorize; 52 | 53 | if $crate::warnings::ENABLED.load(std::sync::atomic::Ordering::SeqCst) { 54 | let message = format!("{}", format_args!($($arg)*)); 55 | let formatted = message.bold(); 56 | eprintln!("{}{} {formatted}", "warning".yellow().bold(), ":".bold()); 57 | } 58 | }; 59 | } 60 | 61 | pub static WARNINGS: LazyLock>> = LazyLock::new(Mutex::default); 62 | 63 | /// Warn a user once, if warnings are enabled, with uniqueness determined by the content of the 64 | /// message. 65 | #[macro_export] 66 | macro_rules! warn_user_once { 67 | ($($arg:tt)*) => { 68 | use $crate::warnings::anstream::eprintln; 69 | use $crate::warnings::::owo_colors::OwoColorize; 70 | 71 | if $crate::warnings::ENABLED.load(std::sync::atomic::Ordering::SeqCst) { 72 | if let Ok(mut states) = $crate::warnings::WARNINGS.lock() { 73 | let message = format!("{}", format_args!($($arg)*)); 74 | if states.insert(message.clone()) { 75 | eprintln!("{}{} {}", "warning".yellow().bold(), ":".bold(), message.bold()); 76 | } 77 | } 78 | } 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /tests/clean.rs: -------------------------------------------------------------------------------- 1 | use assert_fs::assert::PathAssert; 2 | use assert_fs::fixture::{PathChild, PathCreateDir}; 3 | 4 | use crate::common::{TestContext, cmd_snapshot}; 5 | 6 | mod common; 7 | 8 | #[test] 9 | fn clean() -> anyhow::Result<()> { 10 | let context = TestContext::new(); 11 | 12 | let home = context.workdir().child("home"); 13 | home.create_dir_all()?; 14 | 15 | cmd_snapshot!(context.filters(), context.clean().env("PREFLIGIT_HOME", &*home), @r#" 16 | success: true 17 | exit_code: 0 18 | ----- stdout ----- 19 | Cleaned `home` 20 | 21 | ----- stderr ----- 22 | "#); 23 | 24 | home.assert(predicates::path::missing()); 25 | 26 | Ok(()) 27 | } 28 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code, unreachable_pub)] 2 | 3 | use std::ffi::OsStr; 4 | use std::path::{Path, PathBuf}; 5 | use std::process::Command; 6 | 7 | use assert_cmd::assert::OutputAssertExt; 8 | use assert_fs::fixture::{ChildPath, FileWriteStr, PathChild}; 9 | use etcetera::BaseStrategy; 10 | 11 | use constants::env_vars::EnvVars; 12 | 13 | pub struct TestContext { 14 | temp_dir: ChildPath, 15 | home_dir: ChildPath, 16 | 17 | /// Standard filters for this test context. 18 | filters: Vec<(String, String)>, 19 | 20 | // To keep the directory alive. 21 | #[allow(dead_code)] 22 | _root: tempfile::TempDir, 23 | } 24 | 25 | impl TestContext { 26 | pub fn new() -> Self { 27 | let bucket = Self::test_bucket_dir(); 28 | fs_err::create_dir_all(&bucket).expect("Failed to create test bucket"); 29 | 30 | let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory"); 31 | 32 | let temp_dir = ChildPath::new(root.path()).child("temp"); 33 | fs_err::create_dir_all(&temp_dir).expect("Failed to create test working directory"); 34 | 35 | let home_dir = ChildPath::new(root.path()).child("home"); 36 | fs_err::create_dir_all(&home_dir).expect("Failed to create test home directory"); 37 | 38 | let mut filters = Vec::new(); 39 | 40 | filters.extend( 41 | Self::path_patterns(&temp_dir) 42 | .into_iter() 43 | .map(|pattern| (pattern, "[TEMP_DIR]/".to_string())), 44 | ); 45 | filters.extend( 46 | Self::path_patterns(&home_dir) 47 | .into_iter() 48 | .map(|pattern| (pattern, "[HOME]/".to_string())), 49 | ); 50 | 51 | let current_exe = assert_cmd::cargo::cargo_bin("prefligit"); 52 | filters.extend( 53 | Self::path_patterns(¤t_exe) 54 | .into_iter() 55 | .map(|pattern| (pattern, "[CURRENT_EXE]".to_string())), 56 | ); 57 | 58 | Self { 59 | temp_dir, 60 | home_dir, 61 | filters, 62 | _root: root, 63 | } 64 | } 65 | 66 | pub fn test_bucket_dir() -> PathBuf { 67 | EnvVars::var(EnvVars::PREFLIGIT_INTERNAL__TEST_DIR) 68 | .map(PathBuf::from) 69 | .unwrap_or_else(|_| { 70 | etcetera::base_strategy::choose_base_strategy() 71 | .expect("Failed to find base strategy") 72 | .data_dir() 73 | .join("prefligit") 74 | .join("tests") 75 | }) 76 | } 77 | 78 | /// Generate an escaped regex pattern for the given path. 79 | fn path_pattern(path: impl AsRef) -> String { 80 | format!( 81 | // Trim the trailing separator for cross-platform directories filters 82 | r"{}\\?/?", 83 | regex::escape(&path.as_ref().display().to_string()) 84 | // Make separators platform agnostic because on Windows we will display 85 | // paths with Unix-style separators sometimes 86 | .replace(r"\\", r"(\\|\/)") 87 | ) 88 | } 89 | 90 | /// Generate various escaped regex patterns for the given path. 91 | pub fn path_patterns(path: impl AsRef) -> Vec { 92 | let mut patterns = Vec::new(); 93 | 94 | // We can only canonicalize paths that exist already 95 | if path.as_ref().exists() { 96 | patterns.push(Self::path_pattern( 97 | path.as_ref() 98 | .canonicalize() 99 | .expect("Failed to create canonical path"), 100 | )); 101 | } 102 | 103 | // Include a non-canonicalized version 104 | patterns.push(Self::path_pattern(path)); 105 | 106 | patterns 107 | } 108 | 109 | /// Read a file in the temporary directory 110 | pub fn read(&self, file: impl AsRef) -> String { 111 | fs_err::read_to_string(self.temp_dir.join(&file)) 112 | .unwrap_or_else(|_| panic!("Missing file: `{}`", file.as_ref().display())) 113 | } 114 | 115 | pub fn command(&self) -> Command { 116 | let bin = assert_cmd::cargo::cargo_bin("prefligit"); 117 | let mut cmd = Command::new(bin); 118 | cmd.current_dir(self.workdir()); 119 | cmd.env(EnvVars::PREFLIGIT_HOME, &*self.home_dir); 120 | cmd.env(EnvVars::PREFLIGIT_INTERNAL__SORT_FILENAMES, "1"); 121 | cmd 122 | } 123 | 124 | pub fn run(&self) -> Command { 125 | let mut command = self.command(); 126 | command.arg("run"); 127 | command 128 | } 129 | 130 | pub fn clean(&self) -> Command { 131 | let mut command = self.command(); 132 | command.arg("clean"); 133 | command 134 | } 135 | 136 | pub fn validate_config(&self) -> Command { 137 | let mut command = self.command(); 138 | command.arg("validate-config"); 139 | command 140 | } 141 | 142 | pub fn validate_manifest(&self) -> Command { 143 | let mut command = self.command(); 144 | command.arg("validate-manifest"); 145 | command 146 | } 147 | 148 | pub fn install(&self) -> Command { 149 | let mut command = self.command(); 150 | command.arg("install"); 151 | command 152 | } 153 | 154 | pub fn uninstall(&self) -> Command { 155 | let mut command = self.command(); 156 | command.arg("uninstall"); 157 | command 158 | } 159 | 160 | pub fn sample_config(&self) -> Command { 161 | let mut command = self.command(); 162 | command.arg("sample-config"); 163 | command 164 | } 165 | 166 | /// Standard snapshot filters _plus_ those for this test context. 167 | pub fn filters(&self) -> Vec<(&str, &str)> { 168 | // Put test context snapshots before the default filters 169 | // This ensures we don't replace other patterns inside paths from the test context first 170 | self.filters 171 | .iter() 172 | .map(|(p, r)| (p.as_str(), r.as_str())) 173 | .chain(INSTA_FILTERS.iter().copied()) 174 | .collect() 175 | } 176 | 177 | /// Get the working directory for the test context. 178 | pub fn workdir(&self) -> &ChildPath { 179 | &self.temp_dir 180 | } 181 | 182 | /// Initialize a sample project for prefligit. 183 | pub fn init_project(&self) { 184 | Command::new("git") 185 | .arg("init") 186 | .arg("--initial-branch=master") 187 | .current_dir(&self.temp_dir) 188 | .assert() 189 | .success(); 190 | } 191 | 192 | /// Configure git user and email. 193 | pub fn configure_git_author(&self) { 194 | Command::new("git") 195 | .arg("config") 196 | .arg("user.name") 197 | .arg("Prefligit Test") 198 | .current_dir(&self.temp_dir) 199 | .assert() 200 | .success(); 201 | Command::new("git") 202 | .arg("config") 203 | .arg("user.email") 204 | .arg("test@prefligit.dev") 205 | .current_dir(&self.temp_dir) 206 | .assert() 207 | .success(); 208 | } 209 | 210 | /// Run `git add`. 211 | pub fn git_add(&self, path: impl AsRef) { 212 | Command::new("git") 213 | .arg("add") 214 | .arg(path) 215 | .current_dir(&self.temp_dir) 216 | .assert() 217 | .success(); 218 | } 219 | 220 | /// Run `git commit`. 221 | pub fn git_commit(&self, message: &str) { 222 | Command::new("git") 223 | .arg("commit") 224 | .arg("-m") 225 | .arg(message) 226 | .current_dir(&self.temp_dir) 227 | .assert() 228 | .success(); 229 | } 230 | 231 | /// Write a `.pre-commit-config.yaml` file in the temporary directory. 232 | pub fn write_pre_commit_config(&self, content: &str) { 233 | self.temp_dir 234 | .child(".pre-commit-config.yaml") 235 | .write_str(content) 236 | .expect("Failed to write pre-commit config"); 237 | } 238 | } 239 | 240 | #[doc(hidden)] // Macro and test context only, don't use directly. 241 | pub const INSTA_FILTERS: &[(&str, &str)] = &[ 242 | // File sizes 243 | (r"(\s|\()(\d+\.)?\d+([KM]i)?B", "$1[SIZE]"), 244 | // Rewrite Windows output to Unix output 245 | (r"\\([\w\d]|\.\.)", "/$1"), 246 | (r"prefligit.exe", "prefligit"), 247 | // The exact message is host language dependent 248 | ( 249 | r"Caused by: .* \(os error 2\)", 250 | "Caused by: No such file or directory (os error 2)", 251 | ), 252 | // Time seconds 253 | (r"(\d+\.)?\d+(ms|s)", "[TIME]"), 254 | ]; 255 | 256 | #[allow(unused_macros)] 257 | macro_rules! cmd_snapshot { 258 | ($spawnable:expr, @$snapshot:literal) => {{ 259 | cmd_snapshot!($crate::common::INSTA_FILTERS.iter().copied().collect::>(), $spawnable, @$snapshot) 260 | }}; 261 | ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{ 262 | let mut settings = insta::Settings::clone_current(); 263 | for (matcher, replacement) in $filters { 264 | settings.add_filter(matcher, replacement); 265 | } 266 | let _guard = settings.bind_to_scope(); 267 | insta_cmd::assert_cmd_snapshot!($spawnable, @$snapshot); 268 | }}; 269 | } 270 | 271 | #[allow(unused_imports)] 272 | pub(crate) use cmd_snapshot; 273 | -------------------------------------------------------------------------------- /tests/files/node-hooks.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-prettier 3 | rev: v3.1.0 4 | hooks: 5 | - id: prettier 6 | types_or: [yaml, json5] 7 | language_version: default 8 | - id: prettier 9 | types_or: [yaml, json5] 10 | language_version: system 11 | - id: prettier 12 | types_or: [yaml, json5] 13 | language_version: system 14 | - id: prettier 15 | types_or: [yaml, json5] 16 | # code name 17 | language_version: jod 18 | - id: prettier 19 | types_or: [yaml, json5] 20 | # major version 21 | language_version: '23' 22 | - id: prettier 23 | types_or: [yaml, json5] 24 | # major and minor version 25 | language_version: '23.4' 26 | - id: prettier 27 | types_or: [yaml, json5] 28 | # major and minor version and patch 29 | language_version: 23.4.0 30 | - id: prettier 31 | types_or: [yaml, json5] 32 | # version requirement 33 | language_version: ^23.4.0 34 | -------------------------------------------------------------------------------- /tests/files/uv-pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | exclude: | 4 | (?x)^( 5 | .*/(snapshots)/.*| 6 | )$ 7 | 8 | repos: 9 | - repo: https://github.com/abravalheri/validate-pyproject 10 | rev: v0.20.2 11 | hooks: 12 | - id: validate-pyproject 13 | 14 | - repo: https://github.com/crate-ci/typos 15 | rev: v1.26.0 16 | hooks: 17 | - id: typos 18 | 19 | - repo: local 20 | hooks: 21 | - id: cargo-fmt 22 | name: cargo fmt 23 | entry: cargo fmt -- 24 | language: system 25 | types: [rust] 26 | pass_filenames: false # This makes it a lot faster 27 | 28 | - repo: local 29 | hooks: 30 | - id: cargo-dev-generate-all 31 | name: cargo dev generate-all 32 | entry: cargo dev generate-all 33 | language: system 34 | types: [rust] 35 | pass_filenames: false 36 | files: ^crates/(uv-cli|uv-settings)/ 37 | 38 | - repo: https://github.com/pre-commit/mirrors-prettier 39 | rev: v3.1.0 40 | hooks: 41 | - id: prettier 42 | types_or: [yaml, json5] 43 | 44 | - repo: https://github.com/astral-sh/ruff-pre-commit 45 | rev: v0.6.9 46 | hooks: 47 | - id: ruff-format 48 | - id: ruff 49 | args: [--fix, --exit-non-zero-on-fix] 50 | -------------------------------------------------------------------------------- /tests/files/uv-pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: pip-compile 2 | name: pip-compile 3 | description: "Automatically run 'uv pip compile' on your requirements" 4 | entry: uv pip compile 5 | language: python 6 | files: ^requirements\.(in|txt)$ 7 | args: [] 8 | pass_filenames: false 9 | additional_dependencies: [] 10 | minimum_pre_commit_version: "2.9.2" 11 | - id: uv-lock 12 | name: uv-lock 13 | description: "Automatically run 'uv lock' on your project dependencies" 14 | entry: uv lock 15 | language: python 16 | files: ^(uv\.lock|pyproject\.toml|uv\.toml)$ 17 | args: [] 18 | pass_filenames: false 19 | additional_dependencies: [] 20 | minimum_pre_commit_version: "2.9.2" 21 | - id: uv-export 22 | name: uv-export 23 | description: "Automatically run 'uv export' on your project dependencies" 24 | entry: uv export 25 | language: python 26 | files: ^uv\.lock$ 27 | args: ["--frozen", "--output-file=requirements.txt"] 28 | pass_filenames: false 29 | additional_dependencies: [] 30 | minimum_pre_commit_version: "2.9.2" 31 | -------------------------------------------------------------------------------- /tests/hook_impl.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | use common::TestContext; 4 | use indoc::indoc; 5 | 6 | use crate::common::cmd_snapshot; 7 | 8 | mod common; 9 | 10 | #[test] 11 | fn hook_impl() { 12 | let context = TestContext::new(); 13 | 14 | context.init_project(); 15 | 16 | context.write_pre_commit_config(indoc! { r" 17 | repos: 18 | - repo: local 19 | hooks: 20 | - id: fail 21 | name: fail 22 | language: fail 23 | entry: always fail 24 | always_run: true 25 | "}); 26 | 27 | context.git_add("."); 28 | context.configure_git_author(); 29 | let mut commit = Command::new("git"); 30 | commit 31 | .arg("commit") 32 | .current_dir(context.workdir()) 33 | .arg("-m") 34 | .arg("Initial commit"); 35 | 36 | cmd_snapshot!(context.filters(), context.install(), @r#" 37 | success: true 38 | exit_code: 0 39 | ----- stdout ----- 40 | prefligit installed at .git/hooks/pre-commit 41 | 42 | ----- stderr ----- 43 | "#); 44 | 45 | cmd_snapshot!(context.filters(), commit, @r#" 46 | success: false 47 | exit_code: 1 48 | ----- stdout ----- 49 | 50 | ----- stderr ----- 51 | fail.....................................................................Failed 52 | - hook id: fail 53 | - exit code: 1 54 | always fail 55 | 56 | .pre-commit-config.yaml 57 | "#); 58 | } 59 | -------------------------------------------------------------------------------- /tests/install.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::assert::OutputAssertExt; 2 | use assert_fs::assert::PathAssert; 3 | use assert_fs::fixture::{FileWriteStr, PathChild}; 4 | use insta::assert_snapshot; 5 | use predicates::prelude::predicate; 6 | 7 | use crate::common::{TestContext, cmd_snapshot}; 8 | 9 | mod common; 10 | 11 | #[test] 12 | fn install() -> anyhow::Result<()> { 13 | let context = TestContext::new(); 14 | context.init_project(); 15 | 16 | // Install `prefligit` hook. 17 | cmd_snapshot!(context.filters(), context.install(), @r#" 18 | success: true 19 | exit_code: 0 20 | ----- stdout ----- 21 | prefligit installed at .git/hooks/pre-commit 22 | 23 | ----- stderr ----- 24 | "#); 25 | 26 | insta::with_settings!( 27 | { filters => context.filters() }, 28 | { 29 | assert_snapshot!(context.read(".git/hooks/pre-commit"), @r##" 30 | #!/usr/bin/env bash 31 | # File generated by prefligit: https://github.com/j178/prefligit 32 | # ID: 182c10f181da4464a3eec51b83331688 33 | 34 | ARGS=(hook-impl --hook-type=pre-commit) 35 | 36 | HERE="$(cd "$(dirname "$0")" && pwd)" 37 | ARGS+=(--hook-dir "$HERE" -- "$@") 38 | PREFLIGIT="[CURRENT_EXE]" 39 | 40 | exec "$PREFLIGIT" "${ARGS[@]}" 41 | "##); 42 | } 43 | ); 44 | 45 | // Install `pre-commit` and `post-commit` hook. 46 | context 47 | .workdir() 48 | .child(".git/hooks/pre-commit") 49 | .write_str("#!/bin/sh\necho 'pre-commit'\n")?; 50 | 51 | cmd_snapshot!(context.filters(), context.install().arg("--hook-type").arg("pre-commit").arg("--hook-type").arg("post-commit"), @r#" 52 | success: true 53 | exit_code: 0 54 | ----- stdout ----- 55 | Hook already exists at .git/hooks/pre-commit, move it to .git/hooks/pre-commit.legacy. 56 | prefligit installed at .git/hooks/pre-commit 57 | prefligit installed at .git/hooks/post-commit 58 | 59 | ----- stderr ----- 60 | "#); 61 | insta::with_settings!( 62 | { filters => context.filters() }, 63 | { 64 | assert_snapshot!(context.read(".git/hooks/pre-commit"), @r##" 65 | #!/usr/bin/env bash 66 | # File generated by prefligit: https://github.com/j178/prefligit 67 | # ID: 182c10f181da4464a3eec51b83331688 68 | 69 | ARGS=(hook-impl --hook-type=pre-commit) 70 | 71 | HERE="$(cd "$(dirname "$0")" && pwd)" 72 | ARGS+=(--hook-dir "$HERE" -- "$@") 73 | PREFLIGIT="[CURRENT_EXE]" 74 | 75 | exec "$PREFLIGIT" "${ARGS[@]}" 76 | "##); 77 | } 78 | ); 79 | 80 | assert_snapshot!(context.read(".git/hooks/pre-commit.legacy"), @r##" 81 | #!/bin/sh 82 | echo 'pre-commit' 83 | "##); 84 | 85 | insta::with_settings!( 86 | { filters => context.filters() }, 87 | { 88 | assert_snapshot!(context.read(".git/hooks/post-commit"), @r##" 89 | #!/usr/bin/env bash 90 | # File generated by prefligit: https://github.com/j178/prefligit 91 | # ID: 182c10f181da4464a3eec51b83331688 92 | 93 | ARGS=(hook-impl --hook-type=post-commit) 94 | 95 | HERE="$(cd "$(dirname "$0")" && pwd)" 96 | ARGS+=(--hook-dir "$HERE" -- "$@") 97 | PREFLIGIT="[CURRENT_EXE]" 98 | 99 | exec "$PREFLIGIT" "${ARGS[@]}" 100 | "##); 101 | } 102 | ); 103 | 104 | // Overwrite existing hooks. 105 | cmd_snapshot!(context.filters(), context.install().arg("-t").arg("pre-commit").arg("--hook-type").arg("post-commit").arg("--overwrite"), @r#" 106 | success: true 107 | exit_code: 0 108 | ----- stdout ----- 109 | Overwriting existing hook at .git/hooks/pre-commit 110 | prefligit installed at .git/hooks/pre-commit 111 | Overwriting existing hook at .git/hooks/post-commit 112 | prefligit installed at .git/hooks/post-commit 113 | 114 | ----- stderr ----- 115 | "#); 116 | 117 | insta::with_settings!( 118 | { filters => context.filters() }, 119 | { 120 | assert_snapshot!(context.read(".git/hooks/pre-commit"), @r##" 121 | #!/usr/bin/env bash 122 | # File generated by prefligit: https://github.com/j178/prefligit 123 | # ID: 182c10f181da4464a3eec51b83331688 124 | 125 | ARGS=(hook-impl --hook-type=pre-commit) 126 | 127 | HERE="$(cd "$(dirname "$0")" && pwd)" 128 | ARGS+=(--hook-dir "$HERE" -- "$@") 129 | PREFLIGIT="[CURRENT_EXE]" 130 | 131 | exec "$PREFLIGIT" "${ARGS[@]}" 132 | "##); 133 | } 134 | ); 135 | insta::with_settings!( 136 | { filters => context.filters() }, 137 | { 138 | assert_snapshot!(context.read(".git/hooks/post-commit"), @r##" 139 | #!/usr/bin/env bash 140 | # File generated by prefligit: https://github.com/j178/prefligit 141 | # ID: 182c10f181da4464a3eec51b83331688 142 | 143 | ARGS=(hook-impl --hook-type=post-commit) 144 | 145 | HERE="$(cd "$(dirname "$0")" && pwd)" 146 | ARGS+=(--hook-dir "$HERE" -- "$@") 147 | PREFLIGIT="[CURRENT_EXE]" 148 | 149 | exec "$PREFLIGIT" "${ARGS[@]}" 150 | "##); 151 | } 152 | ); 153 | 154 | Ok(()) 155 | } 156 | 157 | #[test] 158 | fn uninstall() -> anyhow::Result<()> { 159 | let context = TestContext::new(); 160 | 161 | context.init_project(); 162 | 163 | // Hook does not exist. 164 | cmd_snapshot!(context.filters(), context.uninstall(), @r#" 165 | success: true 166 | exit_code: 0 167 | ----- stdout ----- 168 | 169 | ----- stderr ----- 170 | .git/hooks/pre-commit does not exist, skipping. 171 | "#); 172 | 173 | // Uninstall `pre-commit` hook. 174 | context.install().assert().success(); 175 | cmd_snapshot!(context.filters(), context.uninstall(), @r#" 176 | success: true 177 | exit_code: 0 178 | ----- stdout ----- 179 | Uninstalled pre-commit 180 | 181 | ----- stderr ----- 182 | "#); 183 | context 184 | .workdir() 185 | .child(".git/hooks/pre-commit") 186 | .assert(predicate::path::missing()); 187 | 188 | // Hook is not managed by `pre-commit`. 189 | context 190 | .workdir() 191 | .child(".git/hooks/pre-commit") 192 | .write_str("#!/bin/sh\necho 'pre-commit'\n")?; 193 | cmd_snapshot!(context.filters(), context.uninstall(), @r#" 194 | success: true 195 | exit_code: 0 196 | ----- stdout ----- 197 | 198 | ----- stderr ----- 199 | .git/hooks/pre-commit is not managed by prefligit, skipping. 200 | "#); 201 | 202 | // Restore previous hook. 203 | context.install().assert().success(); 204 | cmd_snapshot!(context.filters(), context.uninstall(), @r#" 205 | success: true 206 | exit_code: 0 207 | ----- stdout ----- 208 | Uninstalled pre-commit 209 | Restored previous hook to .git/hooks/pre-commit 210 | 211 | ----- stderr ----- 212 | "#); 213 | 214 | // Uninstall multiple hooks. 215 | context 216 | .install() 217 | .arg("-t") 218 | .arg("pre-commit") 219 | .arg("-t") 220 | .arg("post-commit") 221 | .assert() 222 | .success(); 223 | cmd_snapshot!(context.filters(), context.uninstall().arg("-t").arg("pre-commit").arg("-t").arg("post-commit"), @r#" 224 | success: true 225 | exit_code: 0 226 | ----- stdout ----- 227 | Uninstalled pre-commit 228 | Restored previous hook to .git/hooks/pre-commit 229 | Uninstalled post-commit 230 | 231 | ----- stderr ----- 232 | "#); 233 | 234 | Ok(()) 235 | } 236 | 237 | #[test] 238 | fn init_template_dir() { 239 | let context = TestContext::new(); 240 | context.init_project(); 241 | 242 | cmd_snapshot!(context.filters(), context.command().arg("init-templatedir").arg(".git"), @r#" 243 | success: true 244 | exit_code: 0 245 | ----- stdout ----- 246 | prefligit installed at .git/hooks/pre-commit 247 | 248 | ----- stderr ----- 249 | `init.templateDir` not set to the target directory 250 | try `git config --global init.templateDir '.git'`? 251 | "#); 252 | } 253 | -------------------------------------------------------------------------------- /tests/languages/docker.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{TestContext, cmd_snapshot}; 2 | 3 | /// GitHub Action only has docker for linux hosted runners. 4 | #[test] 5 | fn docker() { 6 | let context = TestContext::new(); 7 | context.init_project(); 8 | 9 | context.write_pre_commit_config(indoc::indoc! {r#" 10 | repos: 11 | - repo: https://github.com/j178/pre-commit-docker-hooks 12 | rev: master 13 | hooks: 14 | - id: hello-world 15 | entry: "echo Hello, world!" 16 | verbose: true 17 | always_run: true 18 | "#}); 19 | 20 | context.git_add("."); 21 | 22 | cmd_snapshot!(context.filters(), context.run(), @r#" 23 | success: true 24 | exit_code: 0 25 | ----- stdout ----- 26 | Hello World..............................................................Passed 27 | - hook id: hello-world 28 | - duration: [TIME] 29 | Hello, world! .pre-commit-config.yaml 30 | 31 | ----- stderr ----- 32 | "#); 33 | } 34 | -------------------------------------------------------------------------------- /tests/languages/docker_image.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use assert_cmd::Command; 3 | use assert_fs::fixture::{FileWriteStr, PathChild}; 4 | 5 | use crate::common::{TestContext, cmd_snapshot}; 6 | 7 | #[test] 8 | fn docker_image() -> Result<()> { 9 | let context = TestContext::new(); 10 | context.init_project(); 11 | 12 | let cwd = context.workdir(); 13 | // Test suit from https://github.com/super-linter/super-linter/tree/main/test/linters/gitleaks/bad 14 | cwd.child("gitleaks_bad_01.txt") 15 | .write_str(indoc::indoc! {r" 16 | aws_access_key_id = AROA47DSWDEZA3RQASWB 17 | aws_secret_access_key = wQwdsZDiWg4UA5ngO0OSI2TkM4kkYxF6d2S1aYWM 18 | "})?; 19 | 20 | Command::new("docker") 21 | .args(["pull", "zricethezav/gitleaks:v8.21.2"]) 22 | .assert() 23 | .success(); 24 | 25 | context.write_pre_commit_config(indoc::indoc! {r" 26 | repos: 27 | - repo: local 28 | hooks: 29 | - id: gitleaks-docker 30 | name: Detect hardcoded secrets 31 | language: docker_image 32 | entry: zricethezav/gitleaks:v8.21.2 git --pre-commit --redact --staged --verbose 33 | pass_filenames: false 34 | "}); 35 | context.git_add("."); 36 | 37 | let filters = context 38 | .filters() 39 | .into_iter() 40 | .chain([(r"\d\d?:\d\d(AM|PM)", "[TIME]")]) 41 | .collect::>(); 42 | 43 | cmd_snapshot!(filters, context.run(), @r#" 44 | success: false 45 | exit_code: 1 46 | ----- stdout ----- 47 | Detect hardcoded secrets.................................................Failed 48 | - hook id: gitleaks-docker 49 | - exit code: 1 50 | Finding: aws_access_key_id = REDACTED 51 | Secret: REDACTED 52 | RuleID: generic-api-key 53 | Entropy: 3.521928 54 | File: gitleaks_bad_01.txt 55 | Line: 1 56 | Fingerprint: gitleaks_bad_01.txt:generic-api-key:1 57 | 58 | Finding: aws_secret_access_key = REDACTED 59 | Secret: REDACTED 60 | RuleID: generic-api-key 61 | Entropy: 4.703056 62 | File: gitleaks_bad_01.txt 63 | Line: 2 64 | Fingerprint: gitleaks_bad_01.txt:generic-api-key:2 65 | 66 | 67 | ○ 68 | │╲ 69 | │ ○ 70 | ○ ░ 71 | ░ gitleaks 72 | 73 | [TIME] INF 1 commits scanned. 74 | [TIME] INF scan completed in [TIME] 75 | [TIME] WRN leaks found: 2 76 | 77 | ----- stderr ----- 78 | "#); 79 | Ok(()) 80 | } 81 | -------------------------------------------------------------------------------- /tests/languages/fail.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use assert_fs::prelude::*; 3 | 4 | use crate::common::{TestContext, cmd_snapshot}; 5 | 6 | /// GitHub Action only has docker for linux hosted runners. 7 | #[test] 8 | fn fail() -> Result<()> { 9 | let context = TestContext::new(); 10 | 11 | context.init_project(); 12 | 13 | let cwd = context.workdir(); 14 | cwd.child("changelog").create_dir_all()?; 15 | cwd.child("changelog/changelog.md").touch()?; 16 | 17 | context.write_pre_commit_config(indoc::indoc! {r" 18 | repos: 19 | - repo: local 20 | hooks: 21 | - id: changelogs-rst 22 | name: changelogs must be rst 23 | entry: changelog filenames must end in .rst 24 | language: fail 25 | files: 'changelog/.*(? anyhow::Result<()> { 9 | let context = TestContext::new(); 10 | 11 | // No files to validate. 12 | cmd_snapshot!(context.filters(), context.validate_config(), @r#" 13 | success: true 14 | exit_code: 0 15 | ----- stdout ----- 16 | 17 | ----- stderr ----- 18 | "#); 19 | 20 | context.write_pre_commit_config(indoc::indoc! {r" 21 | repos: 22 | - repo: https://github.com/pre-commit/pre-commit-hooks 23 | rev: v5.0.0 24 | hooks: 25 | - id: trailing-whitespace 26 | - id: end-of-file-fixer 27 | - id: check-json 28 | "}); 29 | // Validate one file. 30 | cmd_snapshot!(context.filters(), context.validate_config().arg(".pre-commit-config.yaml"), @r#" 31 | success: true 32 | exit_code: 0 33 | ----- stdout ----- 34 | 35 | ----- stderr ----- 36 | "#); 37 | 38 | context 39 | .workdir() 40 | .child("config-1.yaml") 41 | .write_str(indoc::indoc! {r" 42 | repos: 43 | - repo: https://github.com/pre-commit/pre-commit-hooks 44 | "})?; 45 | 46 | // Validate multiple files. 47 | cmd_snapshot!(context.filters(), context.validate_config().arg(".pre-commit-config.yaml").arg("config-1.yaml"), @r#" 48 | success: false 49 | exit_code: 1 50 | ----- stdout ----- 51 | 52 | ----- stderr ----- 53 | error: Failed to parse `config-1.yaml` 54 | caused by: repos: Invalid remote repo: missing field `rev` at line 2 column 3 55 | "#); 56 | 57 | Ok(()) 58 | } 59 | 60 | #[test] 61 | fn validate_manifest() -> anyhow::Result<()> { 62 | let context = TestContext::new(); 63 | 64 | // No files to validate. 65 | cmd_snapshot!(context.filters(), context.validate_manifest(), @r#" 66 | success: true 67 | exit_code: 0 68 | ----- stdout ----- 69 | 70 | ----- stderr ----- 71 | "#); 72 | 73 | context 74 | .workdir() 75 | .child(".pre-commit-hooks.yaml") 76 | .write_str(indoc::indoc! {r" 77 | - id: check-added-large-files 78 | name: check for added large files 79 | description: prevents giant files from being committed. 80 | entry: check-added-large-files 81 | language: python 82 | stages: [pre-commit, pre-push, manual] 83 | minimum_pre_commit_version: 3.2.0 84 | "})?; 85 | // Validate one file. 86 | cmd_snapshot!(context.filters(), context.validate_manifest().arg(".pre-commit-hooks.yaml"), @r#" 87 | success: true 88 | exit_code: 0 89 | ----- stdout ----- 90 | 91 | ----- stderr ----- 92 | "#); 93 | 94 | context 95 | .workdir() 96 | .child("hooks-1.yaml") 97 | .write_str(indoc::indoc! {r" 98 | - id: check-added-large-files 99 | name: check for added large files 100 | description: prevents giant files from being committed. 101 | language: python 102 | stages: [pre-commit, pre-push, manual] 103 | minimum_pre_commit_version: 3.2.0 104 | "})?; 105 | 106 | // Validate multiple files. 107 | cmd_snapshot!(context.filters(), context.validate_manifest().arg(".pre-commit-hooks.yaml").arg("hooks-1.yaml"), @r#" 108 | success: false 109 | exit_code: 1 110 | ----- stdout ----- 111 | 112 | ----- stderr ----- 113 | error: Failed to parse `hooks-1.yaml` 114 | caused by: .[0]: missing field `entry` at line 1 column 5 115 | "#); 116 | 117 | Ok(()) 118 | } 119 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | edn = "edn" 3 | styl = "styl" 4 | jod = "jod" 5 | --------------------------------------------------------------------------------