prek 0.5.5

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

use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use prek_consts::PREK_TOML;
use strum::IntoEnumIterator;
use tempfile::TempDir;
use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Value};

use crate::cli::run::Selectors;
use crate::cli::{ExitStatus, RunArgs, RunOptions};
use crate::config::{self, Stage};
use crate::git;
use crate::hooks::{BuiltinHooks, MetaHooks};
use crate::printer::Printer;
use crate::store::Store;
use crate::warn_user;

async fn get_head_rev(repo: &Path) -> Result<String> {
    let head_rev = git::git_cmd()?
        .arg("rev-parse")
        .arg("HEAD")
        .current_dir(repo)
        .output()
        .await?
        .stdout;
    let head_rev = str::from_utf8(&head_rev)?.trim().to_string();
    Ok(head_rev)
}

async fn clone_and_commit(repo_path: &Path, head_rev: &str, tmp_dir: &Path) -> Result<PathBuf> {
    let shadow = tmp_dir.join("shadow-repo");
    git::git_cmd()?
        .arg("clone")
        .arg(repo_path)
        .arg(&shadow)
        .output()
        .await?;
    git::git_cmd()?
        .arg("checkout")
        .arg(head_rev)
        .arg("-b")
        .arg("_prek_tmp")
        .current_dir(&shadow)
        .output()
        .await?;

    let index_path = shadow.join(".git/index");
    let objects_path = shadow.join(".git/objects");

    let staged_files = git::staged_files(repo_path, false).await?;
    if !staged_files.is_empty() {
        git::git_cmd()?
            .arg("add")
            .arg("--")
            .file_args(staged_files.iter().map(|file| &file.path))
            .current_dir(repo_path)
            .env("GIT_INDEX_FILE", &index_path)
            .env("GIT_OBJECT_DIRECTORY", &objects_path)
            .output()
            .await?;
    }

    let mut add_u_cmd = git::git_cmd()?;
    add_u_cmd
        .arg("add")
        .arg("--update") // Update tracked files
        .current_dir(repo_path)
        .env("GIT_INDEX_FILE", &index_path)
        .env("GIT_OBJECT_DIRECTORY", &objects_path)
        .output()
        .await?;

    git::git_cmd()?
        .arg("commit")
        .arg("-m")
        .arg("Temporary commit by prek try-repo")
        .arg("--no-gpg-sign")
        .arg("--no-edit")
        .arg("--no-verify")
        .current_dir(&shadow)
        .env("GIT_AUTHOR_NAME", "prek test")
        .env("GIT_AUTHOR_EMAIL", "test@example.com")
        .env("GIT_COMMITTER_NAME", "prek test")
        .env("GIT_COMMITTER_EMAIL", "test@example.com")
        .output()
        .await?;

    Ok(shadow)
}

struct PreparedRepo<'a> {
    runtime_source: Cow<'a, str>,
    display_source: Option<&'a str>,
    rev: String,
}

async fn prepare_repo<'a>(
    repo: &'a str,
    rev: Option<&str>,
    tmp_dir: &Path,
) -> Result<PreparedRepo<'a>> {
    let repo_path = Path::new(repo);
    let is_local = repo_path.is_dir();
    let runtime_source = if is_local {
        Cow::Owned(
            dunce::canonicalize(repo_path)?
                .to_string_lossy()
                .into_owned(),
        )
    } else {
        Cow::Borrowed(repo)
    };
    let repo_path = Path::new(runtime_source.as_ref());

    // If rev is provided, use it directly.
    if let Some(rev) = rev {
        return Ok(PreparedRepo {
            runtime_source,
            display_source: is_local.then_some(repo),
            rev: rev.to_string(),
        });
    }

    // Get HEAD revision
    let head_rev = if is_local {
        get_head_rev(repo_path).await?
    } else {
        // For remote repositories, use ls-remote
        let head_rev = git::git_cmd()?
            .arg("ls-remote")
            .arg("--exit-code")
            .arg(runtime_source.as_ref())
            .arg("HEAD")
            .output()
            .await?
            .stdout;
        let head_rev = head_rev
            .split(u8::is_ascii_whitespace)
            .find(|part| !part.is_empty())
            .context("Failed to parse HEAD revision from git ls-remote output")?;
        str::from_utf8(head_rev)?.to_string()
    };

    // If repo is a local repo with uncommitted changes, create a shadow repo to commit the changes.
    if is_local && git::has_diff("HEAD", repo_path).await? {
        warn_user!("Local repository has uncommitted changes. Creating a temporary copy...");
        let shadow = clone_and_commit(repo_path, &head_rev, tmp_dir).await?;
        let head_rev = get_head_rev(&shadow).await?;
        Ok(PreparedRepo {
            runtime_source: Cow::Owned(shadow.to_string_lossy().into_owned()),
            display_source: None,
            rev: head_rev,
        })
    } else {
        Ok(PreparedRepo {
            runtime_source,
            display_source: is_local.then_some(repo),
            rev: head_rev,
        })
    }
}

fn render_repo_config_toml(repo_path: &str, rev: Option<&str>, hooks: &[String]) -> String {
    let mut doc = DocumentMut::new();
    let mut repo_table = toml_edit::Table::new();
    repo_table["repo"] = toml_edit::value(repo_path);
    if let Some(rev) = rev {
        repo_table["rev"] = toml_edit::value(rev);
    }

    let mut hooks_array = Array::new();
    hooks_array.set_trailing_comma(true);
    hooks_array.set_trailing("\n");
    for hook_id in hooks {
        let mut hook_table = InlineTable::new();
        hook_table.insert("id", hook_id.into());
        let mut value = Value::InlineTable(hook_table);
        value.decor_mut().set_prefix("\n  ");
        hooks_array.push(value);
    }
    repo_table.insert("hooks", Item::Value(Value::Array(hooks_array)));

    let mut repos = ArrayOfTables::new();
    repos.push(repo_table);
    doc["repos"] = Item::ArrayOfTables(repos);

    doc.to_string()
}

fn select_predefined_hooks<H>(selectors: &Selectors) -> Vec<String>
where
    H: AsRef<str> + IntoEnumIterator,
{
    H::iter()
        .filter(|hook| selectors.matches_hook_id(hook.as_ref()))
        .map(|hook| hook.as_ref().to_string())
        .collect()
}

pub(crate) async fn try_repo(
    config: Option<PathBuf>,
    repo: String,
    rev: Option<String>,
    run_args: RunOptions,
    stage: Option<Stage>,
    refresh: bool,
    verbose: bool,
    printer: Printer,
) -> Result<ExitStatus> {
    if config.is_some() {
        warn_user!("`--config` option is ignored when using `try-repo`");
    }

    let store = Store::from_settings()?;
    let tmp_dir = TempDir::with_prefix_in("try-repo-", store.scratch_path())?;

    let store = Store::from_path(tmp_dir.path())?.init()?;
    let selectors = Selectors::load(&run_args.includes, &run_args.skips, git::root()?)?;

    let predefined_hooks = match repo.as_str() {
        "builtin" => Some(select_predefined_hooks::<BuiltinHooks>(&selectors)),
        "meta" => Some(select_predefined_hooks::<MetaHooks>(&selectors)),
        _ => None,
    };

    let (config_str, display_config_str) = if let Some(hooks) = predefined_hooks {
        if rev.is_some() {
            warn_user!("`--rev` option is ignored for the `{repo}` repo");
        }
        (render_repo_config_toml(&repo, None, &hooks), None)
    } else {
        let prepared = prepare_repo(&repo, rev.as_deref(), tmp_dir.path())
            .await
            .context("Failed to determine repository and revision")?;

        let repo_config = config::RemoteRepo::new(
            prepared.runtime_source.to_string(),
            prepared.rev.clone(),
            vec![],
        );
        let repo_clone_path = store.clone_repo(&repo_config, None).await?;
        let manifest =
            config::read_manifest(&repo_clone_path.join(prek_consts::PRE_COMMIT_HOOKS_YAML))?;
        let hooks = manifest
            .hooks
            .into_iter()
            .filter(|hook| selectors.matches_hook_id(&hook.id))
            .map(|hook| hook.id)
            .collect::<Vec<_>>();

        // The scratch config needs the resolved source, while the displayed config should preserve
        // the user's path so it remains meaningful when copied into their project.
        let config_str =
            render_repo_config_toml(&prepared.runtime_source, Some(&prepared.rev), &hooks);
        let display_config_str = prepared
            .display_source
            .map(|source| render_repo_config_toml(source, Some(&prepared.rev), &hooks));
        (config_str, display_config_str)
    };
    let config_file = tmp_dir.path().join(PREK_TOML);
    fs_err::tokio::write(&config_file, &config_str).await?;

    let display_config_str = display_config_str.as_deref().unwrap_or(&config_str);

    writeln!(
        printer.stdout(),
        "{}",
        format!("Using generated `{PREK_TOML}`:").cyan().bold()
    )?;
    writeln!(printer.stdout(), "{}", display_config_str.dimmed())?;

    let mut run_args = run_args;
    // The generated config already contains only the hooks selected above.
    run_args.includes.clear();
    run_args.skips.clear();

    crate::cli::run(
        &store,
        Some(config_file),
        RunArgs {
            options: run_args,
            stage,
            ..RunArgs::default()
        },
        refresh,
        verbose,
        printer,
    )
    .await
}