diff --git a/crates/prek/src/cli/try_repo.rs b/crates/prek/src/cli/try_repo.rs index 390ca20fe..acf2ddb7e 100644 --- a/crates/prek/src/cli/try_repo.rs +++ b/crates/prek/src/cli/try_repo.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::fmt::Write; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use owo_colors::OwoColorize; use prek_consts::PREK_TOML; use tempfile::TempDir; @@ -13,10 +13,72 @@ use crate::cli::run::Selectors; use crate::config; use crate::git; use crate::git::GIT_ROOT; +use crate::hooks::{BuiltinHooks, HookRegistry, MetaHooks}; use crate::printer::Printer; use crate::store::Store; use crate::warn_user; +enum RepoType<'a> { + Git { repo: &'a str, rev: Option<&'a str> }, + Builtin, + Meta, +} + +struct TryRepoContext { + store: Store, + tmp_dir: TempDir, +} + +impl TryRepoContext { + fn new() -> Result { + 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()?; + Ok(Self { store, tmp_dir }) + } + + async fn run_with_config( + self, + config_str: &str, + run_args: crate::cli::RunArgs, + refresh: bool, + verbose: bool, + printer: Printer, + ) -> Result { + let config_file = self.tmp_dir.path().join(PREK_TOML); + fs_err::tokio::write(&config_file, config_str).await?; + + writeln!( + printer.stdout(), + "{}", + format!("Using generated `{PREK_TOML}`:").cyan().bold() + )?; + writeln!(printer.stdout(), "{}", config_str.dimmed())?; + + crate::cli::run( + &self.store, + Some(config_file), + vec![], + vec![], + run_args.stage, + run_args.from_ref, + run_args.to_ref, + run_args.all_files, + run_args.files, + run_args.directory, + run_args.last_commit, + run_args.show_diff_on_failure, + run_args.fail_fast, + run_args.dry_run, + refresh, + run_args.extra, + verbose, + printer, + ) + .await + } +} + async fn get_head_rev(repo: &Path) -> Result { let head_rev = git::git_cmd("get head rev")? .arg("rev-parse") @@ -25,8 +87,7 @@ async fn get_head_rev(repo: &Path) -> Result { .output() .await? .stdout; - let head_rev = String::from_utf8_lossy(&head_rev).trim().to_string(); - Ok(head_rev) + Ok(String::from_utf8_lossy(&head_rev).trim().to_string()) } async fn clone_and_commit(repo_path: &Path, head_rev: &str, tmp_dir: &Path) -> Result { @@ -62,10 +123,9 @@ async fn clone_and_commit(repo_path: &Path, head_rev: &str, tmp_dir: &Path) -> R .await?; } - let mut add_u_cmd = git::git_cmd("add unstaged to shadow")?; - add_u_cmd + git::git_cmd("add unstaged to shadow")? .arg("add") - .arg("--update") // Update tracked files + .arg("--update") .current_dir(repo_path) .env("GIT_INDEX_FILE", &index_path) .env("GIT_OBJECT_DIRECTORY", &objects_path) @@ -108,7 +168,7 @@ async fn prepare_repo_and_rev<'a>( get_head_rev(repo_path).await? } else { // For remote repositories, use ls-remote - let head_rev = git::git_cmd("get head rev")? + let output = git::git_cmd("get head rev")? .arg("ls-remote") .arg("--exit-code") .arg(repo) @@ -116,7 +176,7 @@ async fn prepare_repo_and_rev<'a>( .output() .await? .stdout; - String::from_utf8_lossy(&head_rev) + String::from_utf8_lossy(&output) .split_ascii_whitespace() .next() .ok_or_else(|| { @@ -125,7 +185,7 @@ async fn prepare_repo_and_rev<'a>( .to_string() }; - // If repo is a local repo with uncommitted changes, create a shadow repo to commit the changes. + // If repo is a local repo with uncommitted changes, create a shadow repo. if is_local && git::has_diff("HEAD", repo_path).await? { warn_user!("Creating temporary repo with uncommitted changes..."); let shadow = clone_and_commit(repo_path, &head_rev, tmp_dir).await?; @@ -136,11 +196,14 @@ async fn prepare_repo_and_rev<'a>( } } -fn render_repo_config_toml(repo_path: &str, rev: &str, hooks: Vec) -> String { +fn render_repo_config_toml(repo_path: &str, rev: Option<&str>, hooks: Vec) -> String { let mut doc = DocumentMut::new(); let mut repo_table = toml_edit::Table::new(); - repo_table["repo"] = toml_edit::value(repo_path); - repo_table["rev"] = toml_edit::value(rev); + // Normalize path separators so toml_edit produces consistent quoting across platforms + repo_table["repo"] = toml_edit::value(repo_path.replace('\\', "/")); + if let Some(rev) = rev { + repo_table["rev"] = toml_edit::value(rev); + } let mut hooks_array = Array::new(); hooks_array.set_trailing_comma(true); @@ -174,15 +237,76 @@ pub(crate) async fn try_repo( 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 repo_type = if repo.eq_ignore_ascii_case("builtin") { + if rev.is_some() { + warn_user!("`--ref` option is ignored for `builtin` repo"); + } + RepoType::Builtin + } else if repo.eq_ignore_ascii_case("meta") { + if rev.is_some() { + warn_user!("`--ref` option is ignored for `meta` repo"); + } + RepoType::Meta + } else { + RepoType::Git { + repo: &repo, + rev: rev.as_deref(), + } + }; - let (repo_path, rev) = prepare_repo_and_rev(&repo, rev.as_deref(), tmp_dir.path()) + match repo_type { + RepoType::Builtin => { + try_special_repo::(run_args, refresh, verbose, printer).await + } + RepoType::Meta => try_special_repo::(run_args, refresh, verbose, printer).await, + RepoType::Git { repo, rev } => { + try_git_repo(repo, rev, run_args, refresh, verbose, printer).await + } + } +} + +async fn try_special_repo( + run_args: crate::cli::RunArgs, + refresh: bool, + verbose: bool, + printer: Printer, +) -> Result { + let repo_name = H::REPO_NAME; + let ctx = TryRepoContext::new()?; + + let selectors = Selectors::load(&run_args.includes, &run_args.skips, GIT_ROOT.as_ref()?)?; + + let hook_ids: Vec<_> = H::all_ids() + .filter(|id| selectors.matches_hook_id(id)) + .collect(); + + if hook_ids.is_empty() { + bail!("No hooks matched the specified selectors for repo `{repo_name}`"); + } + + let hooks = hook_ids.into_iter().map(str::to_string).collect(); + let config_str = render_repo_config_toml(repo_name, None, hooks); + + ctx.run_with_config(&config_str, run_args, refresh, verbose, printer) + .await +} + +async fn try_git_repo( + repo: &str, + rev: Option<&str>, + run_args: crate::cli::RunArgs, + refresh: bool, + verbose: bool, + printer: Printer, +) -> Result { + let ctx = TryRepoContext::new()?; + + let (repo_path, rev) = prepare_repo_and_rev(repo, rev, ctx.tmp_dir.path()) .await .context("Failed to determine repository and revision")?; - let store = Store::from_path(tmp_dir.path()).init()?; - let repo_clone_path = store + let repo_clone_path = ctx + .store .clone_repo( &config::RemoteRepo::new(repo_path.to_string(), rev.clone(), vec![]), None, @@ -201,36 +325,12 @@ pub(crate) async fn try_repo( .map(|hook| hook.id) .collect::>(); - let config_str = render_repo_config_toml(&repo_path, &rev, hooks); - let config_file = tmp_dir.path().join(PREK_TOML); - fs_err::tokio::write(&config_file, &config_str).await?; - - writeln!( - printer.stdout(), - "{}", - format!("Using generated `{PREK_TOML}`:").cyan().bold() - )?; - writeln!(printer.stdout(), "{}", config_str.dimmed())?; - - crate::cli::run( - &store, - Some(config_file), - vec![], - vec![], - run_args.stage, - run_args.from_ref, - run_args.to_ref, - run_args.all_files, - run_args.files, - run_args.directory, - run_args.last_commit, - run_args.show_diff_on_failure, - run_args.fail_fast, - run_args.dry_run, - refresh, - run_args.extra, - verbose, - printer, - ) - .await + if hooks.is_empty() { + bail!("No hooks matched the specified selectors for repo `{repo_path}`"); + } + + let config_str = render_repo_config_toml(&repo_path, Some(&rev), hooks); + + ctx.run_with_config(&config_str, run_args, refresh, verbose, printer) + .await } diff --git a/crates/prek/src/hooks/builtin_hooks/mod.rs b/crates/prek/src/hooks/builtin_hooks/mod.rs index 92c0360a9..2475f7e9a 100644 --- a/crates/prek/src/hooks/builtin_hooks/mod.rs +++ b/crates/prek/src/hooks/builtin_hooks/mod.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::str::FromStr; use anyhow::Result; +use strum::{AsRefStr, Display, EnumIter, EnumString, IntoStaticStr}; use crate::cli::reporter::HookRunReporter; use crate::config::{BuiltinHook, HookOptions, Stage}; @@ -11,7 +12,9 @@ use crate::store::Store; mod check_json5; -#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::AsRefStr, strum::Display, strum::EnumString)] +#[derive( + Debug, Copy, Clone, PartialEq, Eq, AsRefStr, Display, EnumIter, EnumString, IntoStaticStr, +)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", schemars(rename_all = "kebab-case"))] #[strum(serialize_all = "kebab-case")] @@ -273,3 +276,20 @@ impl BuiltinHook { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use strum::IntoEnumIterator; + + #[test] + fn strum_derives_work() { + assert_eq!(BuiltinHooks::iter().count(), 16); + + for variant in BuiltinHooks::iter() { + let id: &'static str = variant.into(); + let parsed = BuiltinHooks::from_str(id).expect("roundtrip should work"); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/prek/src/hooks/meta_hooks.rs b/crates/prek/src/hooks/meta_hooks.rs index 16cbca32b..ebaedbccd 100644 --- a/crates/prek/src/hooks/meta_hooks.rs +++ b/crates/prek/src/hooks/meta_hooks.rs @@ -5,6 +5,7 @@ use std::str::FromStr; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::CONFIG_FILENAMES; +use strum::{AsRefStr, Display, EnumIter, EnumString, IntoStaticStr}; use crate::cli::reporter::HookRunReporter; use crate::cli::run::{CollectOptions, FileFilter, collect_files}; @@ -20,7 +21,9 @@ use crate::workspace::Project; // When matching files (files or exclude), we need to match against the filenames // relative to the project root. -#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::AsRefStr, strum::Display, strum::EnumString)] +#[derive( + Debug, Copy, Clone, PartialEq, Eq, AsRefStr, Display, EnumIter, EnumString, IntoStaticStr, +)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", schemars(rename_all = "kebab-case"))] #[strum(serialize_all = "kebab-case")] @@ -291,4 +294,17 @@ mod tests { assert!(identity.options.files.is_none()); assert_eq!(identity.options.verbose, Some(true)); } + + #[test] + fn strum_derives_work() { + use strum::IntoEnumIterator; + + assert_eq!(MetaHooks::iter().count(), 3); + + for variant in MetaHooks::iter() { + let id: &'static str = variant.into(); + let parsed = MetaHooks::from_str(id).expect("roundtrip should work"); + assert_eq!(parsed, variant); + } + } } diff --git a/crates/prek/src/hooks/mod.rs b/crates/prek/src/hooks/mod.rs index 79fa8068d..997f270cc 100644 --- a/crates/prek/src/hooks/mod.rs +++ b/crates/prek/src/hooks/mod.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use std::sync::LazyLock; use prek_consts::env_vars::EnvVars; +use strum::IntoEnumIterator; use crate::cli::reporter::HookRunReporter; use crate::hook::{Hook, Repo}; @@ -16,6 +17,25 @@ mod builtin_hooks; mod meta_hooks; mod pre_commit_hooks; +/// Trait for special hook registries (builtin and meta). +pub(crate) trait HookRegistry: IntoEnumIterator + Into<&'static str> + Copy { + /// The repo name used in config files (e.g., "builtin", "meta"). + const REPO_NAME: &'static str; + + /// Returns all hook IDs in this registry. + fn all_ids() -> impl Iterator { + Self::iter().map(Into::into) + } +} + +impl HookRegistry for BuiltinHooks { + const REPO_NAME: &'static str = "builtin"; +} + +impl HookRegistry for MetaHooks { + const REPO_NAME: &'static str = "meta"; +} + static NO_FAST_PATH: LazyLock = LazyLock::new(|| EnvVars::is_set(EnvVars::PREK_NO_FAST_PATH)); /// Returns true if the hook has a builtin Rust implementation. diff --git a/crates/prek/tests/try_repo.rs b/crates/prek/tests/try_repo.rs index 9585af091..40801af3c 100644 --- a/crates/prek/tests/try_repo.rs +++ b/crates/prek/tests/try_repo.rs @@ -1,130 +1,97 @@ mod common; + use anyhow::Result; use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::ChildPath; use assert_fs::prelude::*; use std::path::PathBuf; use crate::common::{TestContext, cmd_snapshot, git_cmd}; -use assert_fs::fixture::ChildPath; use prek_consts::PRE_COMMIT_HOOKS_YAML; -fn create_hook_repo(context: &TestContext, repo_name: &str) -> Result { - let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}")); +/// Initialize a git repository at the given path with a commit. +fn init_git_repo(repo_dir: &ChildPath, manifest: &str, include_setup_py: bool) -> Result<()> { repo_dir.create_dir_all()?; + git_cmd(repo_dir).arg("init").assert().success(); - git_cmd(&repo_dir).arg("init").assert().success(); + repo_dir.child(PRE_COMMIT_HOOKS_YAML).write_str(manifest)?; - // Configure the author specifically for this hook repository - git_cmd(&repo_dir) - .arg("config") - .arg("user.name") - .arg("Prek Test") - .assert() - .success(); - git_cmd(&repo_dir) - .arg("config") - .arg("user.email") - .arg("test@prek.dev") - .assert() - .success(); - // Disable autocrlf for test consistency - git_cmd(&repo_dir) - .arg("config") - .arg("core.autocrlf") - .arg("false") - .assert() - .success(); + if include_setup_py { + repo_dir + .child("setup.py") + .write_str("from setuptools import setup; setup(name='dummy-pkg', version='0.0.1')")?; + } - repo_dir - .child(PRE_COMMIT_HOOKS_YAML) - .write_str(indoc::indoc! {r#" - - id: test-hook - name: Test Hook - entry: echo - language: system - files: "\\.txt$" - - id: another-hook - name: Another Hook - entry: python3 -c "print('hello')" - language: python - "#})?; - - // Add a dummy setup.py to make it an installable Python package - repo_dir - .child("setup.py") - .write_str("from setuptools import setup; setup(name='dummy-pkg', version='0.0.1')")?; - - git_cmd(&repo_dir).arg("add").arg(".").assert().success(); - - git_cmd(&repo_dir) + git_cmd(repo_dir).arg("add").arg(".").assert().success(); + git_cmd(repo_dir) .arg("commit") .arg("-m") .arg("Initial commit") .assert() .success(); + Ok(()) +} + +/// Create a standard hook repository with test-hook and another-hook. +fn create_hook_repo(context: &TestContext, repo_name: &str) -> Result { + let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}")); + init_git_repo( + &repo_dir, + indoc::indoc! {r#" + - id: test-hook + name: Test Hook + entry: echo + language: system + files: "\\.txt$" + - id: another-hook + name: Another Hook + entry: python3 -c "print('hello')" + language: python + "#}, + true, // include setup.py for python hook + )?; Ok(repo_dir.to_path_buf()) } -// Helper for a repo with a hook that is designed to fail fn create_failing_hook_repo(context: &TestContext, repo_name: &str) -> Result { let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}")); - repo_dir.create_dir_all()?; - - git_cmd(&repo_dir).arg("init").assert().success(); - git_cmd(&repo_dir) - .arg("config") - .arg("user.name") - .arg("Prek Test") - .assert() - .success(); - git_cmd(&repo_dir) - .arg("config") - .arg("user.email") - .arg("test@prek.dev") - .assert() - .success(); - // Disable autocrlf for test consistency - git_cmd(&repo_dir) - .arg("config") - .arg("core.autocrlf") - .arg("false") - .assert() - .success(); - - repo_dir - .child(PRE_COMMIT_HOOKS_YAML) - .write_str(indoc::indoc! {r#" - - id: failing-hook - name: Always Fail - entry: "false" - language: system - "#})?; - - git_cmd(&repo_dir).arg("add").arg(".").assert().success(); + init_git_repo( + &repo_dir, + indoc::indoc! {r#" + - id: failing-hook + name: Always Fail + entry: "false" + language: system + "#}, + false, + )?; + Ok(repo_dir.to_path_buf()) +} - git_cmd(&repo_dir) - .arg("commit") - .arg("-m") - .arg("Initial commit") - .assert() - .success(); +fn default_filters(context: &TestContext) -> Vec<(&str, &str)> { + let mut filters = context.filters(); + filters.push((r"[a-f0-9]{40}", "[COMMIT_SHA]")); + filters +} - Ok(repo_dir.to_path_buf()) +fn setup_basic_context() -> Result { + let context = TestContext::new(); + context.init_project(); + context.work_dir().child("test.txt").write_str("hello\n")?; + context.git_add("."); + Ok(context) } #[test] fn try_repo_basic() -> Result<()> { let context = TestContext::new(); context.init_project(); - context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let repo_path = create_hook_repo(&context, "try-repo-basic")?; - - let mut filters = context.filters(); - filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\"")]); + let filters = default_filters(&context); cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("--skip").arg("another-hook"), @r#" success: true @@ -150,14 +117,11 @@ fn try_repo_basic() -> Result<()> { fn try_repo_failing_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); - context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let repo_path = create_failing_hook_repo(&context, "try-repo-failing")?; - - let mut filters = context.filters(); - filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\"")]); + let filters = default_filters(&context); cmd_snapshot!(filters, context.try_repo().arg(&repo_path), @r#" success: false @@ -185,14 +149,12 @@ fn try_repo_failing_hook() -> Result<()> { fn try_repo_specific_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); - let repo_path = create_hook_repo(&context, "try-repo-specific-hook")?; context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); - let mut filters = context.filters(); - filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\"")]); + let filters = default_filters(&context); cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("another-hook"), @r#" success: true @@ -218,7 +180,6 @@ fn try_repo_specific_hook() -> Result<()> { fn try_repo_specific_rev() -> Result<()> { let context = TestContext::new(); context.init_project(); - context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); @@ -231,14 +192,14 @@ fn try_repo_specific_rev() -> Result<()> { .stdout; let initial_rev = String::from_utf8_lossy(&initial_rev).trim().to_string(); - // Make a new commit + // Make a new commit with different hooks ChildPath::new(&repo_path) .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - - id: new-hook - name: New Hook - entry: echo new - language: system + - id: new-hook + name: New Hook + entry: echo new + language: system "})?; git_cmd(&repo_path).arg("add").arg(".").assert().success(); git_cmd(&repo_path) @@ -248,16 +209,10 @@ fn try_repo_specific_rev() -> Result<()> { .assert() .success(); - let mut filters = context.filters(); - filters.extend([ - (r"[a-f0-9]{40}", "[COMMIT_SHA]"), - (&initial_rev, "[COMMIT_SHA]"), - ("'", "\""), - ]); - - cmd_snapshot!(filters, context.try_repo().arg(&repo_path) - .arg("--ref") - .arg(&initial_rev), @r#" + let mut filters = default_filters(&context); + filters.push((initial_rev.as_str(), "[COMMIT_SHA]")); + + cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("--ref").arg(&initial_rev), @r#" success: true exit_code: 0 ----- stdout ----- @@ -286,14 +241,14 @@ fn try_repo_uncommitted_changes() -> Result<()> { let repo_path = create_hook_repo(&context, "try-repo-uncommitted")?; - // Make uncommitted changes + // Make uncommitted changes to the hook repo ChildPath::new(&repo_path) .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - - id: uncommitted-hook - name: Uncommitted Hook - entry: echo uncommitted - language: system + - id: uncommitted-hook + name: Uncommitted Hook + entry: echo uncommitted + language: system "})?; ChildPath::new(&repo_path) .child("new-file.txt") @@ -307,12 +262,8 @@ fn try_repo_uncommitted_changes() -> Result<()> { context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); - let mut filters = context.filters(); - filters.extend([ - (r"try-repo-[^/\\]+", "[REPO]"), - (r"[a-f0-9]{40}", "[COMMIT_SHA]"), - ("'", "\""), - ]); + let mut filters = default_filters(&context); + filters.push((r"try-repo-[^/\\]+", "[REPO]")); cmd_snapshot!(filters, context.try_repo().arg(&repo_path), @r#" success: true @@ -339,17 +290,15 @@ fn try_repo_uncommitted_changes() -> Result<()> { fn try_repo_relative_path() -> Result<()> { let context = TestContext::new(); context.init_project(); - context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let _repo_path = create_hook_repo(&context, "try-repo-relative")?; - let relative_path = "../home/test-repos/try-repo-relative".to_string(); + let relative_path = "../home/test-repos/try-repo-relative"; - let mut filters = context.filters(); - filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]")]); + let filters = default_filters(&context); - cmd_snapshot!(filters, context.try_repo().arg(&relative_path), @r#" + cmd_snapshot!(filters, context.try_repo().arg(relative_path), @r#" success: true exit_code: 0 ----- stdout ----- @@ -370,3 +319,446 @@ fn try_repo_relative_path() -> Result<()> { Ok(()) } + +#[test] +fn try_repo_git_no_matching_hooks() -> Result<()> { + let context = setup_basic_context()?; + let repo_path = create_hook_repo(&context, "try-repo-no-matching")?; + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("nonexistent-hook").arg("-a"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No hooks matched the specified selectors for repo `[HOME]/test-repos/try-repo-no-matching` + "); + + Ok(()) +} + +#[test] +fn try_repo_config_warning() -> Result<()> { + let context = setup_basic_context()?; + let repo_path = create_hook_repo(&context, "try-repo-config-warning")?; + + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("--config").arg("other.yaml").arg("-a"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "[HOME]/test-repos/try-repo-config-warning" + rev = "[COMMIT_SHA]" + hooks = [ + { id = "test-hook" }, + { id = "another-hook" }, + ] + + Test Hook................................................................Passed + Another Hook.............................................................Passed + + ----- stderr ----- + warning: `--config` option is ignored when using `try-repo` + "#); + + Ok(()) +} + +#[test] +fn try_repo_builtin() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + // Create a file with trailing whitespace + context + .work_dir() + .child("test.txt") + .write_str("hello world \n")?; + context.git_add("."); + + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("builtin").arg("trailing-whitespace").arg("-a"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "builtin" + hooks = [ + { id = "trailing-whitespace" }, + ] + + trim trailing whitespace.................................................Failed + - hook id: trailing-whitespace + - exit code: 1 + - files were modified by this hook + + Fixing test.txt + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_builtin_multiple_hooks() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + context + .work_dir() + .child("test.json") + .write_str("{\"valid\": true}")?; + context + .work_dir() + .child("test.txt") + .write_str("hello world\n")?; + context.git_add("."); + + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("builtin").arg("check-json").arg("trailing-whitespace").arg("-a"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "builtin" + hooks = [ + { id = "check-json" }, + { id = "trailing-whitespace" }, + ] + + check json...............................................................Passed + trim trailing whitespace.................................................Passed + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_builtin_all_hooks() -> Result<()> { + let context = setup_basic_context()?; + + let output = context.try_repo().arg("builtin").arg("-a").output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + // no-commit-to-branch fails on the default branch, so we don't assert success + assert!(stdout.contains("Using generated `prek.toml`:")); + assert!(stdout.contains(r#"repo = "builtin""#)); + assert!(stdout.contains(r#"{ id = "check-added-large-files" }"#)); + assert!(stdout.contains(r#"{ id = "trailing-whitespace" }"#)); + + // Verify builtin hooks are included (exact count is validated by unit tests) + let hook_count = stdout.matches("{ id =").count(); + assert!( + hook_count >= 10, + "Expected at least 10 builtin hooks, found {hook_count}" + ); + + Ok(()) +} + +#[test] +fn try_repo_builtin_skip() -> Result<()> { + let context = setup_basic_context()?; + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("builtin").arg("check-json").arg("check-merge-conflict").arg("--skip").arg("check-json").arg("-a"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "builtin" + hooks = [ + { id = "check-merge-conflict" }, + ] + + check for merge conflicts................................................Passed + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_meta_identity() -> Result<()> { + let context = setup_basic_context()?; + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("meta").arg("identity").arg("-a"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "meta" + hooks = [ + { id = "identity" }, + ] + + identity.................................................................Passed + - hook id: identity + - duration: [TIME] + + test.txt + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_meta_all_hooks() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + // Create a prek config file for meta hooks that check config + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: trailing-whitespace + "}); + + context.work_dir().child("test.txt").write_str("hello\n")?; + context.git_add("."); + + let output = context.try_repo().arg("meta").arg("-a").output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Using generated `prek.toml`:")); + assert!(stdout.contains(r#"repo = "meta""#)); + + // Verify meta hooks are included (exact count is validated by unit tests) + let hook_count = stdout.matches("{ id =").count(); + assert!( + hook_count >= 3, + "Expected at least 3 meta hooks, found {hook_count}" + ); + + Ok(()) +} + +#[test] +fn try_repo_meta_skip() -> Result<()> { + let context = setup_basic_context()?; + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("meta").arg("identity").arg("check-hooks-apply").arg("--skip").arg("check-hooks-apply").arg("-a"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "meta" + hooks = [ + { id = "identity" }, + ] + + identity.................................................................Passed + - hook id: identity + - duration: [TIME] + + test.txt + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_meta_check_hooks_apply() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + // Create a config with a hook that applies to .rs files (which we don't have) + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: rust-only + name: Rust Only + entry: echo + language: system + files: '\\.rs$' + "}); + + context.work_dir().child("test.txt").write_str("hello\n")?; + context.git_add("."); + + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("meta").arg("check-hooks-apply").arg("-a"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "meta" + hooks = [ + { id = "check-hooks-apply" }, + ] + + Check hooks apply........................................................Failed + - hook id: check-hooks-apply + - exit code: 1 + + rust-only does not apply to this repository + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_meta_check_useless_excludes() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + // Create a config with an exclude pattern that doesn't match anything + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: test-hook + name: Test Hook + entry: echo + language: system + exclude: '\\.nonexistent$' + "}); + + context.work_dir().child("test.txt").write_str("hello\n")?; + context.git_add("."); + + let filters = default_filters(&context); + + cmd_snapshot!(filters, context.try_repo().arg("meta").arg("check-useless-excludes").arg("-a"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + Using generated `prek.toml`: + [[repos]] + repo = "meta" + hooks = [ + { id = "check-useless-excludes" }, + ] + + Check useless excludes...................................................Failed + - hook id: check-useless-excludes + - exit code: 1 + + The exclude pattern `regex: \/.nonexistent$` for `test-hook` does not match any files + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn try_repo_special_case_insensitive() -> Result<()> { + let context = setup_basic_context()?; + + for (repo, hook, casings) in [ + ( + "builtin", + "check-merge-conflict", + &["BUILTIN", "Builtin", "BuiltIn", "bUILTIN"] as &[&str], + ), + ("meta", "identity", &["META", "Meta", "mETA", "MeTa"]), + ] { + for casing in casings { + let output = context + .try_repo() + .arg(casing) + .arg(hook) + .arg("-a") + .output()?; + assert!( + output.status.success(), + "{casing} should be recognized as {repo} repo" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!(r#"repo = "{repo}""#)), + "{casing} should produce repo = \"{repo}\" in config" + ); + } + } + + Ok(()) +} + +#[test] +fn try_repo_special_ref_warning() -> Result<()> { + let context = setup_basic_context()?; + + for (repo, hook) in [("builtin", "check-merge-conflict"), ("meta", "identity")] { + let output = context + .try_repo() + .arg(repo) + .arg("--ref") + .arg("v1.0.0") + .arg(hook) + .arg("-a") + .output()?; + assert!( + output.status.success(), + "try-repo {repo} with --ref should still succeed" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!(r#"repo = "{repo}""#)), + "{repo} should appear in generated config" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("`--ref` option is ignored for `{repo}` repo")), + "{repo} should warn about --ref being ignored" + ); + } + + Ok(()) +} + +#[test] +fn try_repo_special_invalid_hook() -> Result<()> { + let context = setup_basic_context()?; + + for repo in ["builtin", "meta"] { + let output = context + .try_repo() + .arg(repo) + .arg("nonexistent-hook") + .arg("-a") + .output()?; + assert!( + !output.status.success(), + "try-repo {repo} with invalid hook should fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!( + "No hooks matched the specified selectors for repo `{repo}`" + )), + "{repo} should report no matching hooks" + ); + } + + Ok(()) +}