diff --git a/crates/prek/src/cli/auto_update.rs b/crates/prek/src/cli/auto_update.rs index 9c31509c..1cfca728 100644 --- a/crates/prek/src/cli/auto_update.rs +++ b/crates/prek/src/cli/auto_update.rs @@ -517,7 +517,7 @@ fn render_updated_toml_config( .and_then(|value| value.as_str()) .unwrap_or_default(); - if matches!(repo_value, "local" | "meta" | "builtin") { + if matches!(repo_value, "local" | "meta" | "builtin" | "self") { continue; } diff --git a/crates/prek/src/cli/cache_clean.rs b/crates/prek/src/cli/cache_clean.rs index 488ce601..d7b67f44 100644 --- a/crates/prek/src/cli/cache_clean.rs +++ b/crates/prek/src/cli/cache_clean.rs @@ -16,7 +16,9 @@ pub(crate) fn cache_clean(store: &Store, printer: Printer) -> Result return Ok(ExitStatus::Success); } - if let Err(e) = fix_permissions(store.cache_path(CacheBucket::Go)) { + if let Err(e) = fix_permissions(store.cache_path(CacheBucket::Go)) + && e.kind() != io::ErrorKind::NotFound + { error!("Failed to fix permissions: {}", e); } diff --git a/crates/prek/src/cli/cache_gc.rs b/crates/prek/src/cli/cache_gc.rs index c885f69f..31ed1a54 100644 --- a/crates/prek/src/cli/cache_gc.rs +++ b/crates/prek/src/cli/cache_gc.rs @@ -147,7 +147,7 @@ pub(crate) async fn cache_gc( return Ok(ExitStatus::Success); } - let mut kept_configs: FxHashSet<&Path> = FxHashSet::default(); + let mut kept_configs: FxHashSet = FxHashSet::default(); let mut used_repo_keys: FxHashSet = FxHashSet::default(); let mut used_hook_env_dirs: FxHashSet = FxHashSet::default(); let mut used_tools: FxHashSet = FxHashSet::default(); @@ -172,13 +172,13 @@ pub(crate) async fn cache_gc( continue; } err => { - warn!(path = %config_path.display(), %err, "Failed to parse config, skipping for GC"); - kept_configs.insert(config_path); + debug!(path = %config_path.display(), %err, "Failed to parse config, skipping for GC"); + kept_configs.insert(config_path.clone()); continue; } }, }; - kept_configs.insert(config_path); + kept_configs.insert(config_path.clone()); used_env_keys.extend(hook_env_keys_from_config(store, &config)); @@ -218,7 +218,6 @@ pub(crate) async fn cache_gc( // Update tracking file to drop configs that no longer exist. if !dry_run && kept_configs.len() != tracked_configs.len() { - let kept_configs = kept_configs.into_iter().map(Path::to_path_buf).collect(); store.update_tracked_configs(&kept_configs)?; } @@ -392,7 +391,7 @@ fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec {} // Meta repos and builtin repos do not have hook envs. + _ => {} // Self, meta, and builtin repos do not have cached hook envs. } } diff --git a/crates/prek/src/cli/completion.rs b/crates/prek/src/cli/completion.rs index 58aaea87..e37e467e 100644 --- a/crates/prek/src/cli/completion.rs +++ b/crates/prek/src/cli/completion.rs @@ -178,6 +178,11 @@ fn all_hooks(proj: &Project) -> Vec<(String, Option)> { out.push((h.id.clone(), Some(h.name.clone()))); } } + config::Repo::SelfRepo(cfg) => { + for h in &cfg.hooks { + out.push((h.id.clone(), h.name.as_ref().map(ToString::to_string))); + } + } } } out diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index d4619090..69919e06 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -1,8 +1,8 @@ use std::fmt::Write as _; use std::io::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::sync::{Arc, LazyLock}; +use std::sync::{Arc, LazyLock, Mutex}; use anyhow::{Context, Result}; use futures::stream::{FuturesUnordered, StreamExt}; @@ -160,58 +160,148 @@ pub(crate) async fn run( filtered_hooks.iter().map(|h| &h.id).collect::>() ); let reporter = HookInstallReporter::new(printer); - let installed_hooks = install_hooks(filtered_hooks, store, &reporter).await?; + let self_repo_env_tracker = SelfRepoEnvTracker::default(); + let _self_repo_env_cleanup_guard = SelfRepoEnvCleanupGuard::new(self_repo_env_tracker.clone()); + let installed_hooks = install_hooks_with_tracker( + filtered_hooks, + store, + &reporter, + Some(&self_repo_env_tracker), + ) + .await?; + + async { + // Release the store lock. + drop(lock); + + // Clear any unstaged changes from the git working directory. + let mut _guard = None; + if should_stash { + _guard = Some( + WorkTreeKeeper::clean(store, workspace.root()) + .await + .context("Failed to clean work tree")?, + ); + } + + set_env_vars(from_ref.as_ref(), to_ref.as_ref(), &extra_args); + + let filenames = collect_files( + workspace.root(), + CollectOptions { + hook_stage, + from_ref, + to_ref, + all_files, + files, + directories, + commit_msg_filename: extra_args.commit_msg_filename, + }, + ) + .await + .context("Failed to collect files")?; + + // Change to the workspace root directory. + std::env::set_current_dir(workspace.root()).with_context(|| { + format!( + "Failed to change directory to `{}`", + workspace.root().display() + ) + })?; + + run_hooks( + &workspace, + &installed_hooks, + filenames, + store, + show_diff_on_failure, + fail_fast, + dry_run, + verbose, + printer, + ) + .await + } + .await +} - // Release the store lock. - drop(lock); +fn dedupe_paths(paths: impl IntoIterator) -> Vec { + let mut seen = FxHashSet::default(); + paths + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() +} - // Clear any unstaged changes from the git working directory. - let mut _guard = None; - if should_stash { - _guard = Some( - WorkTreeKeeper::clean(store, workspace.root()) - .await - .context("Failed to clean work tree")?, - ); +fn cleanup_self_repo_env_paths(paths: &[PathBuf]) { + for path in paths { + match fs_err::remove_dir_all(path) { + Ok(()) => { + debug!("Removed ephemeral self-repo hook env `{}`", path.display()); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + warn!( + "Failed to remove ephemeral self-repo hook env `{}`: {err}", + path.display() + ); + } + } } +} - set_env_vars(from_ref.as_ref(), to_ref.as_ref(), &extra_args); - - let filenames = collect_files( - workspace.root(), - CollectOptions { - hook_stage, - from_ref, - to_ref, - all_files, - files, - directories, - commit_msg_filename: extra_args.commit_msg_filename, - }, - ) - .await - .context("Failed to collect files")?; +struct SelfRepoEnvCleanupGuard { + tracker: SelfRepoEnvTracker, +} - // Change to the workspace root directory. - std::env::set_current_dir(workspace.root()).with_context(|| { - format!( - "Failed to change directory to `{}`", - workspace.root().display() - ) - })?; +impl SelfRepoEnvCleanupGuard { + fn new(tracker: SelfRepoEnvTracker) -> Self { + Self { tracker } + } +} - run_hooks( - &workspace, - &installed_hooks, - filenames, - store, - show_diff_on_failure, - fail_fast, - dry_run, - verbose, - printer, - ) - .await +impl Drop for SelfRepoEnvCleanupGuard { + fn drop(&mut self) { + let paths = self.tracker.deduped_paths(); + cleanup_self_repo_env_paths(&paths); + } +} + +#[derive(Clone, Default)] +struct SelfRepoEnvTracker { + paths: Arc>>, +} + +impl SelfRepoEnvTracker { + fn record_path(&self, path: &Path) { + let mut paths = match self.paths.lock() { + Ok(paths) => paths, + Err(poisoned) => { + warn!("Self-repo env tracker lock poisoned while recording path"); + poisoned.into_inner() + } + }; + paths.push(path.to_path_buf()); + } + + fn record_from_hook(&self, hook: &InstalledHook) { + if matches!(hook.repo(), Repo::SelfRepo { .. }) + && let Some(path) = hook.env_path() + { + self.record_path(path); + } + } + + fn deduped_paths(&self) -> Vec { + let paths = match self.paths.lock() { + Ok(paths) => paths.clone(), + Err(poisoned) => { + warn!("Self-repo env tracker lock poisoned while collecting paths"); + poisoned.into_inner().clone() + } + }; + dedupe_paths(paths) + } } // `pre-commit` sets these environment variables for other git hooks. @@ -308,6 +398,15 @@ pub async fn install_hooks( hooks: Vec>, store: &Store, reporter: &HookInstallReporter, +) -> Result> { + install_hooks_with_tracker(hooks, store, reporter, None).await +} + +async fn install_hooks_with_tracker( + hooks: Vec>, + store: &Store, + reporter: &HookInstallReporter, + self_repo_env_tracker: Option<&SelfRepoEnvTracker>, ) -> Result> { let num_hooks = hooks.len(); let mut result = Vec::with_capacity(hooks.len()); @@ -371,7 +470,7 @@ pub async fn install_hooks( } } - if matched_info.is_none() { + if matched_info.is_none() && !matches!(hook.repo(), Repo::SelfRepo { .. }) { for env in store_hooks.iter() { if env.matches(&hook) { if env.ensure_healthy().await { @@ -387,7 +486,11 @@ pub async fn install_hooks( "Found installed environment for hook `{hook}` at `{}`", info.env_path.display() ); - hook_envs.push(InstalledHook::Installed { hook, info }); + let installed_hook = InstalledHook::Installed { hook, info }; + if let Some(tracker) = self_repo_env_tracker { + tracker.record_from_hook(&installed_hook); + } + hook_envs.push(installed_hook); continue; } @@ -398,11 +501,18 @@ pub async fn install_hooks( .install(hook.clone(), store, reporter) .await .with_context(|| format!("Failed to install hook `{hook}`"))?; + if let Some(tracker) = self_repo_env_tracker { + tracker.record_from_hook(&installed_hook); + } - installed_hook - .mark_as_installed(store) - .await - .with_context(|| format!("Failed to mark hook `{hook}` as installed"))?; + if !matches!(hook.repo(), Repo::SelfRepo { .. }) { + installed_hook + .mark_as_installed(store) + .await + .with_context(|| { + format!("Failed to mark hook `{hook}` as installed") + })?; + } match &installed_hook { InstalledHook::Installed { info, .. } => { @@ -1064,3 +1174,62 @@ async fn run_hook( output: hook_output, }) } + +#[cfg(test)] +mod tests { + use super::{SelfRepoEnvCleanupGuard, SelfRepoEnvTracker, dedupe_paths}; + use assert_fs::fixture::TempDir; + use std::path::PathBuf; + + #[test] + fn dedupe_paths_keeps_first_seen_order() { + let a = PathBuf::from("/tmp/a"); + let b = PathBuf::from("/tmp/b"); + let c = PathBuf::from("/tmp/c"); + + let deduped = dedupe_paths(vec![ + a.clone(), + b.clone(), + a.clone(), + c.clone(), + b.clone(), + c.clone(), + ]); + + assert_eq!(deduped, vec![a, b, c]); + } + + #[test] + fn cleanup_guard_removes_paths_on_drop() { + let temp = TempDir::new().expect("create temp dir"); + let env_path = temp.path().join("hooks/self-repo-env"); + fs_err::create_dir_all(&env_path).expect("create self-repo env dir"); + fs_err::write(env_path.join("marker.txt"), b"hello").expect("create marker file"); + let tracker = SelfRepoEnvTracker::default(); + tracker.record_path(&env_path); + + { + let _guard = SelfRepoEnvCleanupGuard::new(tracker); + } + + assert!(!env_path.exists()); + } + + #[test] + fn cleanup_guard_runs_on_panic_unwind() { + let temp = TempDir::new().expect("create temp dir"); + let env_path = temp.path().join("hooks/self-repo-env"); + fs_err::create_dir_all(&env_path).expect("create self-repo env dir"); + fs_err::write(env_path.join("marker.txt"), b"hello").expect("create marker file"); + let tracker = SelfRepoEnvTracker::default(); + tracker.record_path(&env_path); + + let panic_result = std::panic::catch_unwind(|| { + let _guard = SelfRepoEnvCleanupGuard::new(tracker); + panic!("trigger unwind"); + }); + + assert!(panic_result.is_err()); + assert!(!env_path.exists()); + } +} diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index eccca9bf..722f18f6 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -16,7 +16,9 @@ use crate::fs::Simplified; use crate::identify; use crate::install_source::InstallSource; #[cfg(feature = "schemars")] -use crate::schema::{schema_repo_builtin, schema_repo_local, schema_repo_meta, schema_repo_remote}; +use crate::schema::{ + schema_repo_builtin, schema_repo_local, schema_repo_meta, schema_repo_remote, schema_repo_self, +}; use crate::version; use crate::warn_user; use crate::warn_user_once; @@ -672,12 +674,31 @@ pub(crate) struct BuiltinRepo { _unused_keys: BTreeMap, } +#[derive(Debug, Clone, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub(crate) struct SelfRepo { + #[cfg_attr(feature = "schemars", schemars(schema_with = "schema_repo_self"))] + pub repo: String, + #[serde(skip_serializing)] + pub hooks: Vec, + + #[serde(skip_serializing, flatten)] + _unused_keys: BTreeMap, +} + +impl Display for SelfRepo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("self") + } +} + #[derive(Debug, Clone)] pub(crate) enum Repo { Remote(RemoteRepo), Local(LocalRepo), Meta(MetaRepo), Builtin(BuiltinRepo), + SelfRepo(SelfRepo), } impl<'de> Deserialize<'de> for Repo { @@ -797,6 +818,22 @@ impl<'de> Deserialize<'de> for Repo { _unused_keys: unused, })) } + "self" => { + if rev.is_some() { + return Err(M::Error::custom("`rev` is not allowed for self repos")); + } + let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? { + HooksValue::Remote(hooks) => hooks, + HooksValue::Local(_) | HooksValue::Meta(_) | HooksValue::Builtin(_) => { + return Err(M::Error::custom("invalid hooks for self repo")); + } + }; + Ok(Repo::SelfRepo(SelfRepo { + repo: "self".to_string(), + hooks, + _unused_keys: unused, + })) + } _ => { let rev = rev.ok_or_else(|| M::Error::missing_field("rev"))?; let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? { @@ -949,6 +986,10 @@ fn collect_unused_paths(config: &Config) -> Vec { &builtin._unused_keys, Box::new(builtin.hooks.iter().map(|h| &h.options)), ), + Repo::SelfRepo(self_repo) => ( + &self_repo._unused_keys, + Box::new(self_repo.hooks.iter().map(|h| &h.options)), + ), }; push_unused_paths( @@ -1967,4 +2008,76 @@ mod tests { let config = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(config); } + + #[test] + fn parse_self_repo() { + let yaml = indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: my-hook + "}; + let result = serde_saphyr::from_str::(yaml).unwrap(); + insta::assert_debug_snapshot!(result); + } + + #[test] + fn parse_self_repo_with_rev() { + let yaml = indoc::indoc! {r" + repos: + - repo: self + rev: v1.0.0 + hooks: + - id: my-hook + "}; + let err = serde_saphyr::from_str::(yaml).unwrap_err(); + insta::assert_snapshot!(err, @r" + error: line 2 column 5: `rev` is not allowed for self repos at line 2, column 5 + --> :2:5 + | + 1 | repos: + 2 | - repo: self + | ^ `rev` is not allowed for self repos at line 2, column 5 + 3 | rev: v1.0.0 + 4 | hooks: + | + "); + } + + #[test] + fn parse_self_repo_with_overrides() { + let yaml = indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: my-hook + args: [--flag] + files: ^src/ + "}; + let result = serde_saphyr::from_str::(yaml).unwrap(); + insta::assert_debug_snapshot!(result); + } + + #[test] + fn parse_self_repo_rev_before_repo() { + let yaml = indoc::indoc! {r" + repos: + - rev: v1.0.0 + repo: self + hooks: + - id: my-hook + "}; + let err = serde_saphyr::from_str::(yaml).unwrap_err(); + insta::assert_snapshot!(err, @r" + error: line 2 column 5: `rev` is not allowed for self repos at line 2, column 5 + --> :2:5 + | + 1 | repos: + 2 | - rev: v1.0.0 + | ^ `rev` is not allowed for self repos at line 2, column 5 + 3 | repo: self + 4 | hooks: + | + "); + } } diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 82e8cd72..a2e819fe 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -161,6 +161,12 @@ pub(crate) enum Repo { Builtin { hooks: Vec, }, + #[expect(clippy::enum_variant_names)] + SelfRepo { + /// Path to the project root containing the manifest. + path: PathBuf, + hooks: Vec, + }, } impl Repo { @@ -202,10 +208,25 @@ impl Repo { } } - /// Get the path to the cloned repo if it is a remote repo. + /// Construct a self repo from the project root, reading its manifest. + pub(crate) fn self_repo(project_root: PathBuf) -> Result { + let manifest_path = project_root.join(PRE_COMMIT_HOOKS_YAML); + let manifest = read_manifest(&manifest_path).map_err(|e| Error::Manifest { + repo: "self".to_string(), + error: e, + })?; + let hooks = manifest.hooks.into_iter().map(Into::into).collect(); + + Ok(Self::SelfRepo { + path: project_root, + hooks, + }) + } + + /// Get the path to the repo if it is a remote or self repo. pub(crate) fn path(&self) -> Option<&Path> { match self { - Repo::Remote { path, .. } => Some(path), + Repo::Remote { path, .. } | Repo::SelfRepo { path, .. } => Some(path), _ => None, } } @@ -213,10 +234,11 @@ impl Repo { /// Get a hook by id. pub(crate) fn get_hook(&self, id: &str) -> Option<&HookSpec> { let hooks = match self { - Repo::Remote { hooks, .. } => hooks, - Repo::Local { hooks } => hooks, - Repo::Meta { hooks } => hooks, - Repo::Builtin { hooks } => hooks, + Repo::Remote { hooks, .. } + | Repo::SelfRepo { hooks, .. } + | Repo::Local { hooks } + | Repo::Meta { hooks } + | Repo::Builtin { hooks } => hooks, }; hooks.iter().find(|hook| hook.id == id) } @@ -229,6 +251,7 @@ impl Display for Repo { Repo::Local { .. } => write!(f, "local"), Repo::Meta { .. } => write!(f, "meta"), Repo::Builtin { .. } => write!(f, "builtin"), + Repo::SelfRepo { .. } => write!(f, "self"), } } } @@ -557,14 +580,21 @@ impl Hook { /// Dependencies used to identify whether an existing hook environment can be reused. /// /// For remote hooks, the repo URL is included to avoid reusing an environment created - /// from a different remote repository. + /// from a different remote repository. For self-repo hooks, the project path is included for + /// within-run environment identity and partitioning. pub(crate) fn env_key_dependencies(&self) -> &FxHashSet { - if !self.is_remote() { - return &self.additional_dependencies; + match &*self.repo { + Repo::Remote { .. } => self.dependencies.get_or_init(|| { + env_key_dependencies(&self.additional_dependencies, Some(&self.repo.to_string())) + }), + Repo::SelfRepo { path, .. } => self.dependencies.get_or_init(|| { + env_key_dependencies( + &self.additional_dependencies, + Some(&self_repo_dependency(path)), + ) + }), + _ => &self.additional_dependencies, } - self.dependencies.get_or_init(|| { - env_key_dependencies(&self.additional_dependencies, Some(&self.repo.to_string())) - }) } /// Returns a lightweight view of the hook environment identity used for reusing installs. @@ -632,6 +662,14 @@ fn env_key_dependencies( deps } +/// Build the dependency token used to isolate self-repo hook environments per project. +pub(crate) fn self_repo_dependency(project_root: &Path) -> String { + fs_err::canonicalize(project_root) + .unwrap_or_else(|_| project_root.to_path_buf()) + .to_string_lossy() + .to_string() +} + /// Shared matching logic between a computed hook env key (owned or borrowed) and an installed /// environment described by [`InstallInfo`]. fn matches_install_info( diff --git a/crates/prek/src/hooks/meta_hooks.rs b/crates/prek/src/hooks/meta_hooks.rs index 16cbca32..955bded7 100644 --- a/crates/prek/src/hooks/meta_hooks.rs +++ b/crates/prek/src/hooks/meta_hooks.rs @@ -203,6 +203,7 @@ pub(crate) async fn check_useless_excludes( config::Repo::Local(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), config::Repo::Meta(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), config::Repo::Builtin(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), + config::Repo::SelfRepo(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), }; for (hook_id, opts) in hooks_iter { diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index 9043e195..e69fc9a8 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -282,7 +282,7 @@ impl Language { return hooks::run_fast_path(store, hook, filenames, reporter).await; } } - Repo::Local { .. } => {} + Repo::Local { .. } | Repo::SelfRepo { .. } => {} } match self { diff --git a/crates/prek/src/schema.rs b/crates/prek/src/schema.rs index 6aaaf337..91122f19 100644 --- a/crates/prek/src/schema.rs +++ b/crates/prek/src/schema.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use crate::config::{ BuiltinHook, BuiltinRepo, FilePattern, LocalRepo, MetaHook, MetaRepo, RemoteHook, RemoteRepo, - Repo, + Repo, SelfRepo, }; impl schemars::JsonSchema for FilePattern { @@ -141,15 +141,23 @@ pub(crate) fn schema_repo_builtin( }) } +pub(crate) fn schema_repo_self(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "const": "self", + "description": "Must be `self`. References the project's own `.pre-commit-hooks.yaml`.", + }) +} + pub(crate) fn schema_repo_remote( _gen: &mut schemars::generate::SchemaGenerator, ) -> schemars::Schema { schemars::json_schema!({ "type": "string", "not": { - "enum": ["local", "meta", "builtin"], + "enum": ["local", "meta", "builtin", "self"], }, - "description": "Remote repository location. Must not be `local`, `meta`, or `builtin`.", + "description": "Remote repository location. Must not be `local`, `meta`, `builtin`, or `self`.", }) } @@ -163,15 +171,17 @@ impl schemars::JsonSchema for Repo { let local_schema = r#gen.subschema_for::(); let meta_schema = r#gen.subschema_for::(); let builtin_schema = r#gen.subschema_for::(); + let self_schema = r#gen.subschema_for::(); schemars::json_schema!({ "type": "object", - "description": "A repository of hooks, which can be remote, local, meta, or builtin.", + "description": "A repository of hooks, which can be remote, local, meta, builtin, or self.", "oneOf": [ remote_schema, local_schema, meta_schema, builtin_schema, + self_schema, ], }) } diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_self_repo.snap b/crates/prek/src/snapshots/prek__config__tests__parse_self_repo.snap new file mode 100644 index 00000000..301abfd0 --- /dev/null +++ b/crates/prek/src/snapshots/prek__config__tests__parse_self_repo.snap @@ -0,0 +1,54 @@ +--- +source: crates/prek/src/config.rs +expression: result +--- +Config { + repos: [ + SelfRepo( + SelfRepo { + repo: "self", + hooks: [ + RemoteHook { + id: "my-hook", + name: None, + entry: None, + language: None, + priority: None, + options: HookOptions { + alias: None, + files: None, + exclude: None, + types: None, + types_or: None, + exclude_types: None, + additional_dependencies: None, + args: None, + env: None, + always_run: None, + fail_fast: None, + pass_filenames: None, + description: None, + language_version: None, + log_file: None, + require_serial: None, + stages: None, + verbose: None, + minimum_prek_version: None, + _unused_keys: {}, + }, + }, + ], + _unused_keys: {}, + }, + ), + ], + default_install_hook_types: None, + default_language_version: None, + default_stages: None, + files: None, + exclude: None, + fail_fast: None, + minimum_prek_version: None, + orphan: None, + _unused_keys: {}, +} diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_self_repo_with_overrides.snap b/crates/prek/src/snapshots/prek__config__tests__parse_self_repo_with_overrides.snap new file mode 100644 index 00000000..7f62d352 --- /dev/null +++ b/crates/prek/src/snapshots/prek__config__tests__parse_self_repo_with_overrides.snap @@ -0,0 +1,62 @@ +--- +source: crates/prek/src/config.rs +expression: result +--- +Config { + repos: [ + SelfRepo( + SelfRepo { + repo: "self", + hooks: [ + RemoteHook { + id: "my-hook", + name: None, + entry: None, + language: None, + priority: None, + options: HookOptions { + alias: None, + files: Some( + Regex( + ^src/, + ), + ), + exclude: None, + types: None, + types_or: None, + exclude_types: None, + additional_dependencies: None, + args: Some( + [ + "--flag", + ], + ), + env: None, + always_run: None, + fail_fast: None, + pass_filenames: None, + description: None, + language_version: None, + log_file: None, + require_serial: None, + stages: None, + verbose: None, + minimum_prek_version: None, + _unused_keys: {}, + }, + }, + ], + _unused_keys: {}, + }, + ), + ], + default_install_hook_types: None, + default_language_version: None, + default_stages: None, + files: None, + exclude: None, + fail_fast: None, + minimum_prek_version: None, + orphan: None, + _unused_keys: {}, +} diff --git a/crates/prek/src/store.rs b/crates/prek/src/store.rs index 4c9989ed..05cefa79 100644 --- a/crates/prek/src/store.rs +++ b/crates/prek/src/store.rs @@ -7,13 +7,14 @@ use anyhow::Result; use etcetera::BaseStrategy; use futures::StreamExt; use rustc_hash::FxHashSet; +use serde::Deserialize; use thiserror::Error; use tracing::{debug, warn}; use prek_consts::env_vars::EnvVars; use crate::config::RemoteRepo; -use crate::fs::LockedFile; +use crate::fs::{CWD, LockedFile}; use crate::git::clone_repo; use crate::hook::InstallInfo; use crate::run::CONCURRENCY; @@ -43,6 +44,18 @@ fn expand_tilde(path: PathBuf) -> PathBuf { pub(crate) const REPO_MARKER: &str = ".prek-repo.json"; +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum TrackedConfigsFile { + Entries(Vec), + LegacyPaths(Vec), +} + +#[derive(Debug, Deserialize)] +struct TrackedConfigEntry { + path: PathBuf, +} + /// A store for managing repos. #[derive(Debug)] pub struct Store { @@ -151,7 +164,7 @@ impl Store { let info = match InstallInfo::from_env_path(&path).await { Ok(info) => info, Err(err) => { - warn!(%err, path = %path.display(), "Skipping invalid installed hook"); + debug!(%err, path = %path.display(), "Skipping invalid installed hook"); return None; } }; @@ -237,10 +250,19 @@ impl Store { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), Ok(content) => { - let tracked = serde_json::from_str(&content).unwrap_or_else(|e| { - warn!("Failed to parse config tracking file: {e}, resetting"); - FxHashSet::default() - }); + let tracked = match serde_json::from_str::(&content) { + Ok(TrackedConfigsFile::Entries(entries)) => entries + .into_iter() + .map(|entry| normalize_path(entry.path)) + .collect(), + Ok(TrackedConfigsFile::LegacyPaths(paths)) => { + paths.into_iter().map(normalize_path).collect() + } + Err(e) => { + warn!("Failed to parse config tracking file: {e}, resetting"); + FxHashSet::default() + } + }; return Ok(tracked); } } @@ -249,6 +271,10 @@ impl Store { if cached.is_empty() { return Ok(FxHashSet::default()); } + let cached = cached + .into_iter() + .map(normalize_path) + .collect::>(); debug!( count = cached.len(), @@ -266,7 +292,7 @@ impl Store { ) -> Result<(), Error> { let mut tracked = self.tracked_configs()?; for config_path in config_paths { - tracked.insert(config_path.to_path_buf()); + tracked.insert(normalize_path(config_path.to_path_buf())); } let tracking_file = self.config_tracking_file(); @@ -312,3 +338,10 @@ pub(crate) enum CacheBucket { fn to_hex(num: u64) -> String { hex::encode(num.to_le_bytes()) } + +fn normalize_path(mut path: PathBuf) -> PathBuf { + if path.is_relative() { + path = CWD.join(path); + } + fs_err::canonicalize(&path).unwrap_or(path) +} diff --git a/crates/prek/src/workspace.rs b/crates/prek/src/workspace.rs index e1a51e7e..bd53ff8f 100644 --- a/crates/prek/src/workspace.rs +++ b/crates/prek/src/workspace.rs @@ -18,7 +18,7 @@ use tracing::{debug, error, instrument, trace}; use crate::cli::run::Selectors; use crate::config::{self, Config, read_config}; -use crate::fs::Simplified; +use crate::fs::{CWD, Simplified}; use crate::git::GIT_ROOT; use crate::hook::HookSpec; use crate::hook::{self, Hook, HookBuilder, Repo}; @@ -116,6 +116,14 @@ impl Project { config_path: Cow<'_, Path>, root: Option, ) -> Result { + let mut config_path = config_path.into_owned(); + if config_path.is_relative() { + config_path = CWD.join(config_path); + } + if let Ok(canonical) = fs_err::canonicalize(&config_path) { + config_path = canonical; + } + debug!( path = %config_path.user_display(), "Loading project configuration" @@ -147,11 +155,12 @@ impl Project { } let root = root.unwrap_or_else(|| config_dir.to_path_buf()); + let root = fs_err::canonicalize(&root).unwrap_or(root); Ok(Self { root, config, - config_path: config_path.into_owned(), + config_path, idx: 0, relative_path: PathBuf::new(), repos: Vec::with_capacity(size), @@ -344,6 +353,10 @@ impl Project { let repo = Repo::builtin(repo.hooks.clone()); repos.push(Arc::new(repo)); } + config::Repo::SelfRepo(_) => { + let repo = Repo::self_repo(self.root.clone())?; + repos.push(Arc::new(repo)); + } } } @@ -424,6 +437,29 @@ impl Project { ); let hook = builder.build().await?; + hooks.push(hook); + } + } + config::Repo::SelfRepo(repo_config) => { + for hook_config in &repo_config.hooks { + let Some(manifest_hook) = repo.get_hook(&hook_config.id) else { + return Err(Error::HookNotFound { + hook: hook_config.id.clone(), + repo: repo.to_string(), + }); + }; + + let mut hook_spec = manifest_hook.clone(); + hook_spec.apply_remote_hook_overrides(hook_config); + + let builder = HookBuilder::new( + self.clone(), + Arc::clone(repo), + hook_spec, + hooks.len(), + ); + let hook = builder.build().await?; + hooks.push(hook); } } @@ -992,6 +1028,10 @@ impl Workspace { let repo = Repo::builtin(repo.hooks.clone()); repos.push(Arc::new(repo)); } + config::Repo::SelfRepo(_) => { + let repo = Repo::self_repo(project.root.clone())?; + repos.push(Arc::new(repo)); + } } } diff --git a/crates/prek/tests/cache.rs b/crates/prek/tests/cache.rs index 315657f8..df471e12 100644 --- a/crates/prek/tests/cache.rs +++ b/crates/prek/tests/cache.rs @@ -673,7 +673,7 @@ fn cache_gc_drops_missing_tracked_config() -> anyhow::Result<()> { // Tracking file should be updated to drop the missing config. let content = fs_err::read_to_string(home.child("config-tracking.json").path())?; - let tracked: Vec = serde_json::from_str(&content)?; + let tracked: Vec = serde_json::from_str(&content)?; assert!(tracked.is_empty()); // Scratch and patches are always cleared when GC runs. @@ -714,7 +714,7 @@ fn cache_gc_keeps_tracked_config_on_parse_error() -> anyhow::Result<()> { // Parse errors should not drop the config from tracking. let content = fs_err::read_to_string(home.child("config-tracking.json").path())?; - let tracked: Vec = serde_json::from_str(&content)?; + let tracked: Vec = serde_json::from_str(&content)?; assert_eq!(tracked.len(), 1); Ok(()) diff --git a/crates/prek/tests/common/mod.rs b/crates/prek/tests/common/mod.rs index e8d07785..029e4fdd 100644 --- a/crates/prek/tests/common/mod.rs +++ b/crates/prek/tests/common/mod.rs @@ -400,8 +400,8 @@ pub const INSTA_FILTERS: &[(&str, &str)] = &[ (r"\\([\w\d]|\.\.|\.)", "/$1"), // The exact message is host language dependent ( - r"Caused by: .* \(os error 2\)", - "Caused by: No such file or directory (os error 2)", + r"The system cannot find the file specified\. \(os error 2\)", + "No such file or directory (os error 2)", ), // Time seconds (r"\b(\d+\.)?\d+(ms|s)\b", "[TIME]"), diff --git a/crates/prek/tests/self_repo.rs b/crates/prek/tests/self_repo.rs new file mode 100644 index 00000000..65a08601 --- /dev/null +++ b/crates/prek/tests/self_repo.rs @@ -0,0 +1,425 @@ +mod common; + +use std::path::Path; + +use crate::common::{TestContext, cmd_snapshot, git_cmd}; + +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; +use prek_consts::env_vars::EnvVars; + +#[test] +fn self_repo_system_hook() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + cwd.child(".pre-commit-hooks.yaml") + .write_str("- id: echo-hook\n name: echo hook\n entry: echo\n language: system\n files: \"\\\\.(txt|md)$\"\n")?; + + cwd.child("hello.txt").write_str("Hello\n")?; + cwd.child("ignored.rs").write_str("fn main() {}\n")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: echo-hook + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + echo hook................................................................Passed + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn self_repo_with_overrides() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + cwd.child(".pre-commit-hooks.yaml") + .write_str(indoc::indoc! {r" + - id: echo-hook + name: echo hook + entry: echo + language: system + "})?; + + cwd.child("hello.txt").write_str("Hello\n")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: echo-hook + name: overridden name + args: [--verbose] + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + overridden name..........................................................Passed + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn self_repo_missing_manifest() { + let context = TestContext::new(); + context.init_project(); + + context + .work_dir() + .child("hello.txt") + .write_str("Hello\n") + .unwrap(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: my-hook + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to init hooks + caused by: Failed to read manifest of `self` + caused by: failed to open file `[TEMP_DIR]/.pre-commit-hooks.yaml`: No such file or directory (os error 2) + "); +} + +#[test] +fn self_repo_unknown_hook_id() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + cwd.child(".pre-commit-hooks.yaml") + .write_str(indoc::indoc! {r" + - id: real-hook + name: real hook + entry: echo + language: system + "})?; + + cwd.child("hello.txt").write_str("Hello\n")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: nonexistent-hook + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to init hooks + caused by: Hook `nonexistent-hook` not present in repo `self` + "); + + Ok(()) +} + +#[test] +fn self_repo_refreshes_after_source_change_between_runs() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + cwd.child(".pre-commit-hooks.yaml") + .write_str(indoc::indoc! {r" + - id: showver + name: show version + entry: showver + language: python + pass_filenames: false + "})?; + cwd.child("setup.py").write_str(indoc::indoc! {r" + from setuptools import setup + + setup( + name='showver', + version='0.0.1', + packages=['hookpkg'], + entry_points={'console_scripts': ['showver=hookpkg.cli:main']}, + ) + "})?; + cwd.child("hookpkg").create_dir_all()?; + cwd.child("hookpkg/__init__.py").write_str("")?; + cwd.child("hookpkg/cli.py") + .write_str("def main():\n print('HOOK_VERSION=v1')\n raise SystemExit(1)\n")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: showver + "}); + cwd.child("file.txt").write_str("hello\n")?; + context.git_add("."); + + let output_v1 = context + .command() + .args(["run", "--all-files", "--verbose"]) + .output()?; + assert!(!output_v1.status.success(), "expected first run to fail"); + let stdout_v1 = String::from_utf8_lossy(&output_v1.stdout); + assert!(stdout_v1.contains("HOOK_VERSION=v1")); + + cwd.child("hookpkg/cli.py") + .write_str("def main():\n print('HOOK_VERSION=v2')\n raise SystemExit(1)\n")?; + + let output_v2 = context + .command() + .args(["run", "--all-files", "--verbose"]) + .output()?; + assert!(!output_v2.status.success(), "expected second run to fail"); + let stdout_v2 = String::from_utf8_lossy(&output_v2.stdout); + assert!(stdout_v2.contains("HOOK_VERSION=v2")); + assert!(!stdout_v2.contains("HOOK_VERSION=v1")); + + assert_eq!( + python_env_count(context.home_dir().child("hooks").path())?, + 0 + ); + + Ok(()) +} + +#[test] +fn self_repo_does_not_persist_env_across_runs() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + cwd.child(".pre-commit-hooks.yaml") + .write_str(indoc::indoc! {r#" + - id: self-python + name: Self Python Hook + entry: python -c "print('ok')" + language: python + pass_filenames: false + "#})?; + cwd.child("setup.py") + .write_str("from setuptools import setup; setup(name='dummy', version='0.0.1')")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: self-python + "}); + + cwd.child("file.txt").write_str("Hello\n")?; + context.git_add("."); + + context + .command() + .args(["run", "--all-files"]) + .assert() + .success(); + assert_eq!( + python_env_count(context.home_dir().child("hooks").path())?, + 0 + ); + + context + .command() + .args(["run", "--all-files"]) + .assert() + .success(); + assert_eq!( + python_env_count(context.home_dir().child("hooks").path())?, + 0 + ); + + Ok(()) +} + +#[test] +fn self_repo_partial_install_failure_does_not_leak_envs() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + cwd.child(".pre-commit-hooks.yaml") + .write_str(indoc::indoc! {r#" + - id: ok + name: ok + entry: python -c "print('ok')" + language: python + pass_filenames: false + - id: badver + name: badver + entry: python -c "print('badver')" + language: python + pass_filenames: false + "#})?; + cwd.child("setup.py") + .write_str("from setuptools import setup; setup(name='selfhooks', version='0.0.1')")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: ok + - id: badver + language_version: python9.9 + "}); + cwd.child("file.txt").write_str("Hello\n")?; + context.git_add("."); + + let output = context.command().args(["run", "--all-files"]).output()?; + assert!( + !output.status.success(), + "run should fail due to invalid language_version" + ); + + assert_eq!( + python_env_count(context.home_dir().child("hooks").path())?, + 0, + "ephemeral self-repo envs should be removed on install failure", + ); + + Ok(()) +} + +/// Two projects sharing the same `PREK_HOME` should both run successfully +/// without relying on persisted self-repo environments. +#[test] +fn self_repo_cross_project_env_isolation() -> anyhow::Result<()> { + // Use one TestContext for the shared PREK_HOME. + let context = TestContext::new(); + let home = context.home_dir(); + + // Set up two independent projects under the context root. + let project_a = context.work_dir().child("project_a"); + let project_b = context.work_dir().child("project_b"); + project_a.create_dir_all()?; + project_b.create_dir_all()?; + + for (project, label) in [(&project_a, "a"), (&project_b, "b")] { + git_cmd(project) + .arg("-c") + .arg("init.defaultBranch=master") + .arg("init") + .assert() + .success(); + + project.child(".pre-commit-hooks.yaml").write_str(&format!( + indoc::indoc! {r#" + - id: greet + name: greet + entry: python -c "print('from-{label}')" + language: python + "#}, + label = label, + ))?; + + project.child("setup.py").write_str(&format!( + "from setuptools import setup; setup(name='project-{label}', version='0.0.1')", + ))?; + + project + .child(".pre-commit-config.yaml") + .write_str(indoc::indoc! {r" + repos: + - repo: self + hooks: + - id: greet + "})?; + + project.child("file.txt").write_str("Hello\n")?; + + git_cmd(project).args(["add", "."]).assert().success(); + } + + let prek_bin = EnvVars::var_os("NEXTEST_BIN_EXE_prek") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(assert_cmd::cargo::cargo_bin!("prek"))); + + let output_a = std::process::Command::new(&prek_bin) + .arg("run") + .current_dir(&*project_a) + .env(EnvVars::PREK_HOME, &**home) + .env(EnvVars::PREK_INTERNAL__SORT_FILENAMES, "1") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "core.autocrlf") + .env("GIT_CONFIG_VALUE_0", "false") + .output()?; + + let stdout_a = String::from_utf8_lossy(&output_a.stdout); + assert!( + output_a.status.success(), + "project A failed:\nstdout: {stdout_a}\nstderr: {}", + String::from_utf8_lossy(&output_a.stderr), + ); + assert!(stdout_a.contains("Passed"), "project A hook did not pass"); + + let output_b = std::process::Command::new(&prek_bin) + .arg("run") + .current_dir(&*project_b) + .env(EnvVars::PREK_HOME, &**home) + .env(EnvVars::PREK_INTERNAL__SORT_FILENAMES, "1") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "core.autocrlf") + .env("GIT_CONFIG_VALUE_0", "false") + .output()?; + + let stdout_b = String::from_utf8_lossy(&output_b.stdout); + assert!( + output_b.status.success(), + "project B failed:\nstdout: {stdout_b}\nstderr: {}", + String::from_utf8_lossy(&output_b.stderr), + ); + assert!(stdout_b.contains("Passed"), "project B hook did not pass"); + + assert_eq!(python_env_count(home.child("hooks").path())?, 0); + + Ok(()) +} + +fn python_env_count(hooks_dir: &Path) -> anyhow::Result { + let mut count = 0; + for entry in fs_err::read_dir(hooks_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + + if entry.file_name().to_string_lossy().starts_with("python-") { + count += 1; + } + } + Ok(count) +} diff --git a/docs/configuration.md b/docs/configuration.md index f9b9c899..8bd51409 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,6 +41,7 @@ Notable differences (when using YAML): - **Workspace mode** is a `prek` feature that can discover multiple projects; upstream `pre-commit` is single-project. - `files` / `exclude` can be written as **glob mappings** in `prek` (in addition to regex), which is not supported by upstream `pre-commit`. - `repo: builtin` adds fast built-in hooks in `prek`. +- `repo: self` lets a project consume its own `.pre-commit-hooks.yaml` hooks. - Upstream `pre-commit` uses `minimum_pre_commit_version`, while `prek` uses `minimum_prek_version` and intentionally ignores `minimum_pre_commit_version`. ### Prek-only extensions @@ -53,6 +54,7 @@ They work in both YAML and TOML, but they only matter for compatibility if you s - [`orphan`](#prek-only-orphan) - Repo type: - [`repo: builtin`](#prek-only-repo-builtin) + - [`repo: self`](#prek-only-repo-self) - Hook-level: - [`env`](#prek-only-env) - [`priority`](#prek-only-priority) @@ -279,6 +281,7 @@ Each entry is one of: - `repo: local` for hooks defined directly in your repository - `repo: meta` for built-in meta hooks - `repo: builtin` for `prek`'s built-in fast hooks +- `repo: self` for consuming hooks from the project's own `.pre-commit-hooks.yaml` See [Repo entries](#repo-entries). @@ -749,7 +752,54 @@ Example: - id: check-yaml ``` -For the list of available built-in hooks and the “automatic fast path” behavior, see [Built-in Fast Hooks](builtin.md). +For the list of available built-in hooks and the "automatic fast path" behavior, see [Built-in Fast Hooks](builtin.md). + +#### `repo: self` + + + +!!! note "prek-only" + + `repo: self` is specific to `prek` and is not compatible with upstream `pre-commit`. + +For projects that publish hooks via a `.pre-commit-hooks.yaml` manifest, `repo: self` +lets you consume those same hooks without duplicating their definitions as `repo: local`. + +`prek` reads `.pre-commit-hooks.yaml` from the project root and resolves hooks by `id`. +Only `id` is required — all other fields are optional overrides, the same as remote hooks. + +`rev` is not allowed — the manifest is always read from the current project directory. + +If `.pre-commit-hooks.yaml` does not exist in the project root, `prek` reports an error +at hook initialization time. + +Hook environments for `repo: self` are ephemeral per run. `prek` may reuse them within the +same invocation, but it does not reuse them across separate runs. This favors correctness and +ensures source changes in the current project are picked up on the next run. + +Example: + +=== "prek.toml" + + ```toml + [[repos]] + repo = "self" + hooks = [ + { id = "format-json" }, + { id = "lint-shell", args = ["--severity=error"] }, + ] + ``` + +=== ".pre-commit-config.yaml" + + ```yaml + repos: + - repo: self + hooks: + - id: format-json + - id: lint-shell + args: [--severity=error] + ``` ### Hook entries @@ -784,7 +834,7 @@ You can optionally provide `name` and normal hook options (filters, stages, etc) ### Common hook options -These keys can appear on hooks (remote/local/builtin/meta), subject to the restrictions above. +These keys can appear on hooks (remote/local/builtin/meta/self), subject to the restrictions above. #### `id` diff --git a/docs/diff.md b/docs/diff.md index fc4ce95d..ffec84bf 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -5,6 +5,7 @@ - `prek` supports both `.pre-commit-config.yaml` and `.pre-commit-config.yml` configuration files. - `prek` implements some common hooks from `pre-commit-hooks` in Rust for better performance. - `prek` supports `repo: builtin` for offline, zero-setup hooks. +- `prek` supports `repo: self` for projects that publish hooks via `.pre-commit-hooks.yaml` to consume their own hooks (using ephemeral per-run hook environments). - `prek` uses `~/.cache/prek` as the default cache directory for repos, environments and toolchains. - `prek` decoupled hook environment from their repositories, allowing shared toolchains and environments across hooks. - `prek` supports `language_version` as a semver specifier and automatically installs the required toolchains. diff --git a/prek.schema.json b/prek.schema.json index 8b0427ca..5c3935e6 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -157,7 +157,7 @@ "additionalProperties": true, "definitions": { "Repo": { - "description": "A repository of hooks, which can be remote, local, meta, or builtin.", + "description": "A repository of hooks, which can be remote, local, meta, builtin, or self.", "type": "object", "oneOf": [ { @@ -171,6 +171,9 @@ }, { "$ref": "#/definitions/BuiltinRepo" + }, + { + "$ref": "#/definitions/SelfRepo" } ] }, @@ -178,13 +181,14 @@ "type": "object", "properties": { "repo": { - "description": "Remote repository location. Must not be `local`, `meta`, or `builtin`.", + "description": "Remote repository location. Must not be `local`, `meta`, `builtin`, or `self`.", "type": "string", "not": { "enum": [ "local", "meta", - "builtin" + "builtin", + "self" ] } }, @@ -1221,6 +1225,28 @@ "trailing-whitespace" ] }, + "SelfRepo": { + "type": "object", + "properties": { + "repo": { + "description": "Must be `self`. References the project's own `.pre-commit-hooks.yaml`.", + "type": "string", + "const": "self" + }, + "hooks": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteHook" + }, + "writeOnly": true + } + }, + "required": [ + "repo", + "hooks" + ], + "additionalProperties": true + }, "HookType": { "type": "string", "enum": [