prek 0.3.11

A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.
mod common;

use crate::common::{TestContext, cmd_snapshot};

use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};
use prek_consts::PRE_COMMIT_CONFIG_YAML;

#[test]
fn meta_hooks() -> anyhow::Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    cwd.child("file.txt").write_str("Hello, world!\n")?;
    cwd.child("valid.json").write_str("{}")?;
    cwd.child("invalid.json").write_str("{}")?;
    cwd.child("main.py").write_str(r#"print "abc"  "#)?;

    context.write_pre_commit_config(indoc::indoc! {r"
        repos:
          - repo: meta
            hooks:
              - id: check-hooks-apply
              - id: check-useless-excludes
              - id: identity
          - repo: local
            hooks:
              - id: match-no-files
                name: match no files
                language: system
                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'
                files: ^nonexistent$
              - id: useless-exclude
                name: useless exclude
                language: system
                entry: python3 -c 'import sys; sys.exit(0)'
                exclude: $nonexistent^
    "});
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @r"
    success: false
    exit_code: 1
    ----- stdout -----
    Check hooks apply........................................................Failed
    - hook id: check-hooks-apply
    - exit code: 1

      match-no-files does not apply to this repository
    Check useless excludes...................................................Failed
    - hook id: check-useless-excludes
    - exit code: 1

      The exclude pattern `regex: $nonexistent^` for `useless-exclude` does not match any files
    identity.................................................................Passed
    - hook id: identity
    - duration: [TIME]

      file.txt
      .pre-commit-config.yaml
      valid.json
      invalid.json
      main.py
    match no files.......................................(no files to check)Skipped
    useless exclude..........................................................Passed

    ----- stderr -----
    ");

    Ok(())
}

#[test]
fn meta_hooks_unknown_hook() {
    let context = TestContext::new();
    context.init_project();

    context.write_pre_commit_config(indoc::indoc! {r"
        repos:
          - repo: meta
            hooks:
              - id: this-hook-does-not-exist
    "});
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: Failed to parse `.pre-commit-config.yaml`
      caused by: error: line 4 column 9: unknown meta hook id `this-hook-does-not-exist`
     --> <input>:4:9
      |
    2 |   - repo: meta
    3 |     hooks:
    4 |       - id: this-hook-does-not-exist
      |         ^ unknown meta hook id `this-hook-does-not-exist`
    ");
}

#[test]
fn check_useless_excludes_remote() -> anyhow::Result<()> {
    let context = TestContext::new();
    context.init_project();

    // When checking useless excludes, remote hooks are not actually cloned,
    // so hook options defined from HookManifest are not used.
    // If applied, "types_or: [python, pyi]" from black-pre-commit-mirror
    // will filter out html files first, so the excludes would not be useless, and the test would fail.
    let pre_commit_config = indoc::formatdoc! {r"
    repos:
      - repo: https://github.com/psf/black-pre-commit-mirror
        rev: 25.1.0
        hooks:
          - id: black
            exclude: '^html/'
      - repo: local
        hooks:
          - id: echo
            name: echo
            entry: echo 'echoing'
            language: system
            exclude: '^useless/$'
      - repo: meta
        hooks:
            - id: check-useless-excludes
    "};
    context.work_dir().child("html").create_dir_all()?;
    context
        .work_dir()
        .child("html")
        .child("file1.html")
        .write_str("<!DOCTYPE html>")?;

    context.write_pre_commit_config(&pre_commit_config);
    context.git_add(".");
    cmd_snapshot!(context.filters(), context.run().arg("check-useless-excludes"), @r"
    success: false
    exit_code: 1
    ----- stdout -----
    Check useless excludes...................................................Failed
    - hook id: check-useless-excludes
    - exit code: 1

      The exclude pattern `regex: ^useless/$` for `echo` does not match any files

    ----- stderr -----
    ");

    Ok(())
}

#[test]
fn meta_hooks_workspace() -> anyhow::Result<()> {
    let context = TestContext::new();
    context.init_project();

    let app = context.work_dir().child("app");
    app.create_dir_all()?;
    app.child(PRE_COMMIT_CONFIG_YAML)
        .write_str(indoc::indoc! {r"
        repos:
          - repo: meta
            hooks:
              - id: check-hooks-apply
              - id: check-useless-excludes
              - id: identity
          - repo: local
            hooks:
              - id: match-no-files
                name: match no files
                language: system
                entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)'
                files: ^nonexistent$
              - id: useless-exclude
                name: useless exclude
                language: system
                entry: python3 -c 'import sys; sys.exit(0)'
                exclude: $nonexistent^
    "})?;

    app.child("file.txt").write_str("Hello, world!\n")?;
    app.child("valid.json").write_str("{}")?;
    app.child("invalid.json").write_str("{x}")?;
    app.child("main.py").write_str(r#"print "abc"  "#)?;

    context.write_pre_commit_config("repos: []");
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @r"
    success: false
    exit_code: 1
    ----- stdout -----
    Running hooks for `app`:
    Check hooks apply........................................................Failed
    - hook id: check-hooks-apply
    - exit code: 1

      match-no-files does not apply to this repository
    Check useless excludes...................................................Failed
    - hook id: check-useless-excludes
    - exit code: 1

      The exclude pattern `regex: $nonexistent^` for `useless-exclude` does not match any files
    identity.................................................................Passed
    - hook id: identity
    - duration: [TIME]

      file.txt
      .pre-commit-config.yaml
      valid.json
      invalid.json
      main.py
    match no files.......................................(no files to check)Skipped
    useless exclude..........................................................Passed

    ----- stderr -----
    ");

    Ok(())
}

#[test]
fn check_useless_excludes_workspace_paths_are_project_relative() -> anyhow::Result<()> {
    let context = TestContext::new();
    context.init_project();

    // Workspace layout:
    // - Root project has no hooks.
    // - Nested project `app/` runs `check-useless-excludes`.
    //
    // Regression: in workspace mode, `files`/`exclude` matching must use paths *relative to the
    // nested project root* (so anchored patterns like `^...$` work as expected).
    let app = context.work_dir().child("app");
    app.create_dir_all()?;
    app.child(PRE_COMMIT_CONFIG_YAML)
        .write_str(indoc::indoc! {r"
        exclude: '^global_excluded$'
        repos:
          - repo: meta
            hooks:
              - id: check-useless-excludes
          - repo: local
            hooks:
              - id: ok
                name: ok
                language: system
                entry: python3 -c 'import sys; sys.exit(0)'
                exclude: '^hook_excluded$'
        "})?;

    // These files exist specifically so the anchored patterns above are NOT useless.
    // If the meta hook mistakenly matches against `app/<name>` instead of `<name>`, it will fail.
    app.child("global_excluded").write_str("ignored\n")?;
    app.child("hook_excluded").write_str("ignored\n")?;

    context.write_pre_commit_config("repos: []");
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("check-useless-excludes"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Running hooks for `app`:
    Check useless excludes...................................................Passed

    ----- stderr -----
    ");

    Ok(())
}