prek 0.5.5

A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.
use std::fmt::Write as _;
use std::path::Path;

use owo_colors::OwoColorize;

use crate::git;
use crate::hook::Hook;
use crate::hooks::HookOutput;
use crate::hooks::pre_commit_hooks::shebangs::{
    file_has_shebang, git_index_stage_output, matching_git_index_paths_by_executable_bit,
};
use crate::hooks::pre_commit_hooks::{
    FilenamesArgs, hook_filenames, parse_hook_args, run_blocking_file_checks,
};

/// Runs the `check-executables-have-shebangs` hook.
pub(crate) async fn run(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput, anyhow::Error> {
    let args: FilenamesArgs = parse_hook_args(hook)?;
    let filenames = hook_filenames(&args.filenames, filenames).collect::<Vec<_>>();
    if filenames.is_empty() {
        return Ok(HookOutput::unchanged(0, Vec::new()));
    }

    let mut cmd = git::git_cmd()?;
    let output = cmd
        .arg("config")
        .arg("core.fileMode")
        .check(false)
        .output()
        .await?;

    let tracks_executable_bit = if output.status.code() == Some(1) {
        // Git returns 1 when the setting is absent; core.fileMode defaults to true.
        true
    } else {
        let output = cmd.check_output(output)?;
        std::str::from_utf8(&output.stdout)?.trim() != "false"
    };
    let file_base = hook.project().relative_path();

    if tracks_executable_bit {
        // core.fileMode=true means the platform honors the executable bit, so trust the FS metadata.
        // The `executables-have-shebangs` hook already restricts inputs to executable text files (`types: [text, executable]`).
        check_shebangs(file_base, &filenames).await
    } else {
        // If on win32 use git to check executable bit
        git_check_shebangs(file_base, &filenames).await
    }
}

async fn check_shebangs(file_base: &Path, paths: &[&Path]) -> Result<HookOutput, anyhow::Error> {
    run_blocking_file_checks(file_base, &[], paths, |file_path, file| {
        if file_has_shebang(file_path)? {
            Ok(HookOutput::unchanged(0, Vec::new()))
        } else {
            let msg = build_missing_shebang_warning(file)?;
            Ok(HookOutput::unchanged(1, msg.into_bytes()))
        }
    })
    .await
}

fn build_missing_shebang_warning(path: &Path) -> Result<String, std::fmt::Error> {
    let path_str = path.display();
    let mut warning = String::new();
    writeln!(
        warning,
        "{}",
        format!(
            "{} marked executable but has no (or invalid) shebang!",
            path_str.yellow()
        )
        .bold()
    )?;
    writeln!(
        warning,
        "{}",
        format!("  If it isn't supposed to be executable, try: 'chmod -x {path_str}'").dimmed()
    )?;
    writeln!(
        warning,
        "{}",
        format!("  If on Windows, you may also need to: 'git add --chmod=-x {path_str}'").dimmed()
    )?;
    writeln!(
        warning,
        "{}",
        "  If it is supposed to be executable, double-check its shebang.".dimmed()
    )?;
    Ok(warning)
}

async fn git_check_shebangs(
    file_base: &Path,
    filenames: &[&Path],
) -> Result<HookOutput, anyhow::Error> {
    let stdout = git_index_stage_output(file_base).await?;
    let entries = matching_git_index_paths_by_executable_bit(&stdout, file_base, filenames, true)
        .collect::<Vec<_>>();
    check_shebangs(file_base, &entries).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;

    #[tokio::test]
    async fn test_check_shebangs_with_shebang() -> Result<(), anyhow::Error> {
        let file = NamedTempFile::new()?;
        fs_err::tokio::write(file.path(), b"#!/bin/bash\necho ok\n").await?;
        let files = vec![file.path()];
        let result = check_shebangs(Path::new(""), &files).await?;
        assert_eq!(result.exit_status, 0);
        assert_eq!(result.output, b"");

        Ok(())
    }

    #[tokio::test]
    async fn test_check_shebangs_without_shebang() -> Result<(), anyhow::Error> {
        let file = NamedTempFile::new()?;
        fs_err::tokio::write(file.path(), b"echo ok\n").await?;
        let files = vec![file.path()];
        let result = check_shebangs(Path::new(""), &files).await?;
        assert_eq!(result.exit_status, 1);
        assert!(
            String::from_utf8_lossy(&result.output)
                .contains("marked executable but has no (or invalid) shebang!")
        );
        Ok(())
    }

    #[tokio::test]
    async fn test_check_shebangs_empty_input() -> Result<(), anyhow::Error> {
        let result = check_shebangs(Path::new(""), &[]).await?;
        assert_eq!(result.exit_status, 0);
        assert_eq!(result.output, b"");
        Ok(())
    }
}