Skip to content

Commit f70af0c

Browse files
committed
feat(try-repo): add support for builtin and meta repos
Add `try-repo builtin` and `try-repo meta` commands to test built-in and meta hooks without needing a git repository. Changes: - Add strum crate for enum derives (EnumIter, EnumString, IntoStaticStr) - Replace manual FromStr implementations with strum derives - Add HookRegistry trait to abstract over BuiltinHooks and MetaHooks - Implement try_special_repo<H: HookRegistry>() for generic handling - Add case-insensitive matching for "builtin" and "meta" keywords - Warn when --ref is used with special repos (ignored) - Add empty hooks check to try_git_repo for consistency - Add comprehensive integration tests for all new functionality
1 parent 1c1af94 commit f70af0c

File tree

8 files changed

+594
-69
lines changed

8 files changed

+594
-69
lines changed

Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ rustix = { version = "1.0.8", features = ["pty", "process", "fs", "termios"] }
7272
same-file = { version = "1.0.6" }
7373
semver = { version = "1.0.24", features = ["serde"] }
7474
serde = { version = "1.0.210", features = ["derive"] }
75+
strum = "0.26"
7576
serde_json = { version = "1.0.132", features = ["unbounded_depth"] }
7677
serde_stacker = { version = "0.1.12" }
7778
serde-saphyr = { version = "0.0.17", default-features = false }

crates/prek/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ serde_stacker = { workspace = true }
7979
serde-saphyr = { workspace = true, default-features = false }
8080
shlex = { workspace = true }
8181
smallvec = { workspace = true }
82+
strum = { workspace = true, features = ["derive"] }
8283
target-lexicon = { workspace = true }
8384
tempfile = { workspace = true }
8485
thiserror = { workspace = true }

crates/prek/src/cli/try_repo.rs

Lines changed: 137 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::borrow::Cow;
22
use std::fmt::Write;
33
use std::path::{Path, PathBuf};
44

5-
use anyhow::{Context, Result};
5+
use anyhow::{Context, Result, bail};
66
use itertools::Itertools;
77
use owo_colors::OwoColorize;
88
use tempfile::TempDir;
@@ -12,10 +12,21 @@ use crate::cli::run::Selectors;
1212
use crate::config;
1313
use crate::git;
1414
use crate::git::GIT_ROOT;
15+
use crate::hooks::{BuiltinHooks, HookRegistry, MetaHooks};
1516
use crate::printer::Printer;
1617
use crate::store::Store;
1718
use crate::warn_user;
1819

20+
/// Categorizes the repo argument into different types.
21+
enum RepoType<'a> {
22+
/// A git repository (local path or remote URL)
23+
Git { repo: &'a str, rev: Option<&'a str> },
24+
/// The special "builtin" keyword for builtin hooks
25+
Builtin,
26+
/// The special "meta" keyword for meta hooks
27+
Meta,
28+
}
29+
1930
async fn get_head_rev(repo: &Path) -> Result<String> {
2031
let head_rev = git::git_cmd("get head rev")?
2132
.arg("rev-parse")
@@ -148,51 +159,91 @@ pub(crate) async fn try_repo(
148159
warn_user!("`--config` option is ignored when using `try-repo`");
149160
}
150161

151-
let store = Store::from_settings()?;
152-
let tmp_dir = TempDir::with_prefix_in("try-repo-", store.scratch_path())?;
162+
// Categorize the repo argument (case-insensitive for special keywords)
163+
let repo_type = if repo.eq_ignore_ascii_case("builtin") {
164+
if rev.is_some() {
165+
warn_user!("`--ref` option is ignored for `builtin` repo");
166+
}
167+
RepoType::Builtin
168+
} else if repo.eq_ignore_ascii_case("meta") {
169+
if rev.is_some() {
170+
warn_user!("`--ref` option is ignored for `meta` repo");
171+
}
172+
RepoType::Meta
173+
} else {
174+
RepoType::Git {
175+
repo: &repo,
176+
rev: rev.as_deref(),
177+
}
178+
};
153179

154-
let (repo_path, rev) = prepare_repo_and_rev(&repo, rev.as_deref(), tmp_dir.path())
155-
.await
156-
.context("Failed to determine repository and revision")?;
180+
match repo_type {
181+
RepoType::Builtin => {
182+
try_special_repo::<BuiltinHooks>(run_args, refresh, verbose, printer).await
183+
}
184+
RepoType::Meta => try_special_repo::<MetaHooks>(run_args, refresh, verbose, printer).await,
185+
RepoType::Git { repo, rev } => {
186+
try_git_repo(repo, rev, run_args, refresh, verbose, printer).await
187+
}
188+
}
189+
}
190+
191+
/// Try hooks from a special repository (builtin or meta).
192+
async fn try_special_repo<H: HookRegistry>(
193+
run_args: crate::cli::RunArgs,
194+
refresh: bool,
195+
verbose: bool,
196+
printer: Printer,
197+
) -> Result<ExitStatus> {
198+
let repo_name = H::REPO_NAME;
157199

200+
let store = Store::from_settings()?;
201+
let tmp_dir = TempDir::with_prefix_in("try-repo-", store.scratch_path())?;
158202
let store = Store::from_path(tmp_dir.path()).init()?;
159-
let repo_clone_path = store
160-
.clone_repo(
161-
&config::RemoteRepo::new(repo_path.to_string(), rev.clone(), vec![]),
162-
None,
163-
)
164-
.await?;
165203

166204
let selectors = Selectors::load(&run_args.includes, &run_args.skips, GIT_ROOT.as_ref()?)?;
167205

168-
let manifest = config::read_manifest(&repo_clone_path.join(prek_consts::MANIFEST_FILE))?;
169-
let hooks_str = manifest
170-
.hooks
171-
.into_iter()
172-
.filter(|hook| selectors.matches_hook_id(&hook.id))
173-
.map(|hook| format!("{}- id: {}", " ".repeat(6), hook.id))
206+
// Filter hook IDs based on selectors
207+
let hook_ids: Vec<_> = H::all_ids()
208+
.filter(|id| selectors.matches_hook_id(id))
209+
.collect();
210+
211+
if hook_ids.is_empty() {
212+
bail!("No hooks matched the specified selectors for repo `{repo_name}`");
213+
}
214+
215+
let hooks_str = hook_ids
216+
.iter()
217+
.map(|id| format!("{}- id: {}", " ".repeat(6), id))
174218
.join("\n");
175219

176220
let config_str = indoc::formatdoc! {r"
177221
repos:
178-
- repo: {repo_path}
179-
rev: {rev}
222+
- repo: {repo_name}
180223
hooks:
181224
{hooks_str}
182-
",
183-
repo_path = repo_path,
184-
rev = rev,
185-
hooks_str = hooks_str,
186-
};
225+
"};
187226

188227
let config_file = tmp_dir.path().join(prek_consts::CONFIG_FILE);
189228
fs_err::tokio::write(&config_file, &config_str).await?;
190229

191230
writeln!(printer.stdout(), "{}", "Using config:".cyan().bold())?;
192231
write!(printer.stdout(), "{}", config_str.dimmed())?;
193232

233+
invoke_run(&store, config_file, run_args, refresh, verbose, printer).await
234+
}
235+
236+
/// Helper to call `crate::cli::run` with common arguments.
237+
async fn invoke_run(
238+
store: &Store,
239+
config_file: PathBuf,
240+
run_args: crate::cli::RunArgs,
241+
refresh: bool,
242+
verbose: bool,
243+
printer: Printer,
244+
) -> Result<ExitStatus> {
194245
crate::cli::run(
195-
&store,
246+
store,
196247
Some(config_file),
197248
vec![],
198249
vec![],
@@ -213,3 +264,63 @@ pub(crate) async fn try_repo(
213264
)
214265
.await
215266
}
267+
268+
/// Try hooks from a git repository (local path or remote URL).
269+
async fn try_git_repo(
270+
repo: &str,
271+
rev: Option<&str>,
272+
run_args: crate::cli::RunArgs,
273+
refresh: bool,
274+
verbose: bool,
275+
printer: Printer,
276+
) -> Result<ExitStatus> {
277+
let store = Store::from_settings()?;
278+
let tmp_dir = TempDir::with_prefix_in("try-repo-", store.scratch_path())?;
279+
280+
let (repo_path, rev) = prepare_repo_and_rev(repo, rev, tmp_dir.path())
281+
.await
282+
.context("Failed to determine repository and revision")?;
283+
284+
let store = Store::from_path(tmp_dir.path()).init()?;
285+
let repo_clone_path = store
286+
.clone_repo(
287+
&config::RemoteRepo::new(repo_path.to_string(), rev.clone(), vec![]),
288+
None,
289+
)
290+
.await?;
291+
292+
let selectors = Selectors::load(&run_args.includes, &run_args.skips, GIT_ROOT.as_ref()?)?;
293+
294+
let manifest = config::read_manifest(&repo_clone_path.join(prek_consts::MANIFEST_FILE))?;
295+
let hook_ids: Vec<_> = manifest
296+
.hooks
297+
.into_iter()
298+
.filter(|hook| selectors.matches_hook_id(&hook.id))
299+
.map(|hook| hook.id)
300+
.collect();
301+
302+
if hook_ids.is_empty() {
303+
bail!("No hooks matched the specified selectors for repo");
304+
}
305+
306+
let hooks_str = hook_ids
307+
.iter()
308+
.map(|id| format!("{}- id: {}", " ".repeat(6), id))
309+
.join("\n");
310+
311+
let config_str = indoc::formatdoc! {r"
312+
repos:
313+
- repo: {repo_path}
314+
rev: {rev}
315+
hooks:
316+
{hooks_str}
317+
"};
318+
319+
let config_file = tmp_dir.path().join(prek_consts::CONFIG_FILE);
320+
fs_err::tokio::write(&config_file, &config_str).await?;
321+
322+
writeln!(printer.stdout(), "{}", "Using config:".cyan().bold())?;
323+
write!(printer.stdout(), "{}", config_str.dimmed())?;
324+
325+
invoke_run(&store, config_file, run_args, refresh, verbose, printer).await
326+
}

crates/prek/src/hooks/builtin_hooks/mod.rs

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::path::Path;
22
use std::str::FromStr;
33

44
use anyhow::Result;
5+
use strum::{EnumIter, EnumString, IntoStaticStr};
56

67
use crate::cli::reporter::HookRunReporter;
78
use crate::config::{BuiltinHook, HookOptions, Stage};
@@ -11,9 +12,10 @@ use crate::store::Store;
1112

1213
mod check_json5;
1314

14-
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
15+
#[derive(Debug, Copy, Clone, PartialEq, Eq, EnumIter, EnumString, IntoStaticStr)]
1516
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1617
#[cfg_attr(feature = "schemars", schemars(rename_all = "kebab-case"))]
18+
#[strum(serialize_all = "kebab-case")]
1719
pub(crate) enum BuiltinHooks {
1820
CheckAddedLargeFiles,
1921
CheckCaseConflict,
@@ -33,32 +35,6 @@ pub(crate) enum BuiltinHooks {
3335
TrailingWhitespace,
3436
}
3537

36-
impl FromStr for BuiltinHooks {
37-
type Err = ();
38-
39-
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
40-
match s {
41-
"check-added-large-files" => Ok(Self::CheckAddedLargeFiles),
42-
"check-case-conflict" => Ok(Self::CheckCaseConflict),
43-
"check-executables-have-shebangs" => Ok(Self::CheckExecutablesHaveShebangs),
44-
"check-json" => Ok(Self::CheckJson),
45-
"check-json5" => Ok(Self::CheckJson5),
46-
"check-merge-conflict" => Ok(Self::CheckMergeConflict),
47-
"check-symlinks" => Ok(Self::CheckSymlinks),
48-
"check-toml" => Ok(Self::CheckToml),
49-
"check-xml" => Ok(Self::CheckXml),
50-
"check-yaml" => Ok(Self::CheckYaml),
51-
"detect-private-key" => Ok(Self::DetectPrivateKey),
52-
"end-of-file-fixer" => Ok(Self::EndOfFileFixer),
53-
"fix-byte-order-marker" => Ok(Self::FixByteOrderMarker),
54-
"mixed-line-ending" => Ok(Self::MixedLineEnding),
55-
"no-commit-to-branch" => Ok(Self::NoCommitToBranch),
56-
"trailing-whitespace" => Ok(Self::TrailingWhitespace),
57-
_ => Err(()),
58-
}
59-
}
60-
}
61-
6238
impl BuiltinHooks {
6339
pub(crate) async fn run(
6440
self,
@@ -103,7 +79,7 @@ impl BuiltinHooks {
10379

10480
impl BuiltinHook {
10581
pub(crate) fn from_id(id: &str) -> Result<Self, ()> {
106-
let hook_id = BuiltinHooks::from_str(id)?;
82+
let hook_id = BuiltinHooks::from_str(id).map_err(|_| ())?;
10783
Ok(match hook_id {
10884
BuiltinHooks::CheckAddedLargeFiles => BuiltinHook {
10985
id: "check-added-large-files".to_string(),
@@ -298,3 +274,22 @@ impl BuiltinHook {
298274
})
299275
}
300276
}
277+
278+
#[cfg(test)]
279+
mod tests {
280+
use super::*;
281+
use strum::IntoEnumIterator;
282+
283+
#[test]
284+
fn strum_derives_work() {
285+
// Verify iteration produces the expected count
286+
assert_eq!(BuiltinHooks::iter().count(), 16);
287+
288+
// Verify FromStr roundtrip works for all variants
289+
for variant in BuiltinHooks::iter() {
290+
let id: &'static str = variant.into();
291+
let parsed = BuiltinHooks::from_str(id).expect("roundtrip should work");
292+
assert_eq!(parsed, variant);
293+
}
294+
}
295+
}

0 commit comments

Comments
 (0)