use std::borrow::Cow;
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use anyhow::{Context, Result};
use prek_consts::PRE_COMMIT_HOOKS_YAML;
use prek_identify::{TagSet, tags};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
use thiserror::Error;
use tracing::trace;
use crate::config::{
self, BuiltinHook, Config, FilePattern, HookOptions, Language, LocalHook, ManifestHook,
MetaHook, PassFilenames, RemoteHook, Stages, read_manifest,
};
use crate::hook_entry::HookEntry;
use crate::languages::version::LanguageRequest;
use crate::languages::{ShellSupport, extract_metadata};
use crate::store::Store;
use crate::workspace::Project;
#[derive(Error, Debug)]
pub(crate) enum Error {
#[error(transparent)]
Config(#[from] config::Error),
#[error("Invalid hook `{hook}`")]
Hook {
hook: String,
#[source]
error: anyhow::Error,
},
#[error("Failed to read manifest of `{repo}`")]
Manifest {
repo: String,
#[source]
error: config::Error,
},
#[error("Failed to create directory for hook environment")]
TmpDir(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub(crate) struct HookSpec {
pub id: String,
pub name: String,
pub entry: String,
pub language: Language,
pub priority: Option<u32>,
pub options: HookOptions,
}
impl HookSpec {
pub(crate) fn apply_remote_hook_overrides(&mut self, config: &RemoteHook) {
if let Some(name) = &config.name {
self.name.clone_from(name);
}
if let Some(entry) = &config.entry {
self.entry.clone_from(entry);
}
if let Some(language) = &config.language {
self.language.clone_from(language);
}
if let Some(priority) = config.priority {
self.priority = Some(priority);
}
self.options.update(&config.options);
}
pub(crate) fn apply_project_defaults(&mut self, config: &Config) {
let language = self.language;
if self.options.language_version.is_none() {
self.options.language_version = config
.default_language_version
.as_ref()
.and_then(|v| v.get(&language).cloned());
}
if self
.options
.stages
.as_ref()
.is_none_or(|stages| stages.is_empty())
{
self.options.stages = Some(config.default_stages.unwrap_or(Stages::ALL));
}
}
}
impl From<ManifestHook> for HookSpec {
fn from(hook: ManifestHook) -> Self {
Self {
id: hook.id,
name: hook.name,
entry: hook.entry,
language: hook.language,
priority: None,
options: hook.options,
}
}
}
impl From<LocalHook> for HookSpec {
fn from(hook: LocalHook) -> Self {
Self {
id: hook.id,
name: hook.name,
entry: hook.entry,
language: hook.language,
priority: hook.priority,
options: hook.options,
}
}
}
impl From<MetaHook> for HookSpec {
fn from(hook: MetaHook) -> Self {
Self {
id: hook.id,
name: hook.name,
entry: String::new(),
language: Language::System,
priority: hook.priority,
options: hook.options,
}
}
}
impl From<BuiltinHook> for HookSpec {
fn from(hook: BuiltinHook) -> Self {
Self {
id: hook.id,
name: hook.name,
entry: hook.entry,
language: Language::System,
priority: hook.priority,
options: hook.options,
}
}
}
#[derive(Debug, Clone)]
pub(crate) enum Repo {
Remote {
path: PathBuf,
url: String,
rev: String,
hooks: Vec<HookSpec>,
},
Local {
hooks: Vec<HookSpec>,
},
Meta {
hooks: Vec<HookSpec>,
},
Builtin {
hooks: Vec<HookSpec>,
},
}
impl Repo {
pub(crate) fn remote(url: String, rev: String, path: PathBuf) -> Result<Self, Error> {
let manifest =
read_manifest(&path.join(PRE_COMMIT_HOOKS_YAML)).map_err(|e| Error::Manifest {
repo: url.clone(),
error: e,
})?;
let hooks = manifest.hooks.into_iter().map(Into::into).collect();
Ok(Self::Remote {
path,
url,
rev,
hooks,
})
}
pub(crate) fn local(hooks: Vec<LocalHook>) -> Self {
Self::Local {
hooks: hooks.into_iter().map(Into::into).collect(),
}
}
pub(crate) fn meta(hooks: Vec<MetaHook>) -> Self {
Self::Meta {
hooks: hooks.into_iter().map(Into::into).collect(),
}
}
pub(crate) fn builtin(hooks: Vec<BuiltinHook>) -> Self {
Self::Builtin {
hooks: hooks.into_iter().map(Into::into).collect(),
}
}
pub(crate) fn path(&self) -> Option<&Path> {
match self {
Repo::Remote { path, .. } => Some(path),
_ => None,
}
}
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,
};
hooks.iter().find(|hook| hook.id == id)
}
}
impl Display for Repo {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Repo::Remote { url, rev, .. } => write!(f, "{url}@{rev}"),
Repo::Local { .. } => write!(f, "local"),
Repo::Meta { .. } => write!(f, "meta"),
Repo::Builtin { .. } => write!(f, "builtin"),
}
}
}
pub(crate) struct HookBuilder {
project: Arc<Project>,
repo: Arc<Repo>,
hook_spec: HookSpec,
idx: usize,
}
impl HookBuilder {
pub(crate) fn new(
project: Arc<Project>,
repo: Arc<Repo>,
hook_spec: HookSpec,
idx: usize,
) -> Self {
Self {
project,
repo,
hook_spec,
idx,
}
}
fn check(&self) -> Result<(), Error> {
let language = self.hook_spec.language;
let HookOptions {
language_version,
additional_dependencies,
shell,
..
} = &self.hook_spec.options;
let additional_dependencies = additional_dependencies
.as_ref()
.map_or(&[][..], |deps| deps.as_slice());
if !additional_dependencies.is_empty() {
if !language.supports_install_env() {
return Err(Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(
"Hook specified `additional_dependencies: {}` but the language `{}` does not install an environment",
additional_dependencies.join(", "),
language,
),
});
}
if !language.supports_dependency() {
return Err(Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(
"Hook specified `additional_dependencies: {}` but the language `{}` does not support installing dependencies for now",
additional_dependencies.join(", "),
language,
),
});
}
}
if !language.supports_language_version() {
if let Some(language_version) = language_version
&& language_version != "default"
{
return Err(Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(
"Hook specified `language_version: {language_version}` but the language `{language}` does not support toolchain installation for now",
),
});
}
}
if shell.is_some() {
match self.repo.as_ref() {
Repo::Meta { .. } => {
return Err(Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(
"Hook specified `shell` but meta hooks do not support shell execution",
),
});
}
Repo::Builtin { .. } => {
return Err(Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(
"Hook specified `shell` but builtin hooks do not support shell execution",
),
});
}
Repo::Remote { .. } | Repo::Local { .. } => {}
}
if let ShellSupport::Unsupported(reason) = language.shell_support() {
return Err(Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(
"Hook specified `shell` but the language `{language}` does not support shell execution: {reason}",
),
});
}
}
Ok(())
}
pub(crate) async fn build(mut self) -> Result<Hook, Error> {
self.hook_spec.apply_project_defaults(self.project.config());
self.check()?;
let options = self.hook_spec.options;
let language_version = options.language_version.unwrap_or_default();
let alias = options.alias.unwrap_or_default();
let args = options.args.unwrap_or_default();
let env = options.env.unwrap_or_default();
let types = options.types.unwrap_or(tags::TAG_SET_FILE);
let types_or = options.types_or.unwrap_or_default();
let exclude_types = options.exclude_types.unwrap_or_default();
let always_run = options.always_run.unwrap_or(false);
let fail_fast = options.fail_fast.unwrap_or(false);
let pass_filenames = options.pass_filenames.unwrap_or(PassFilenames::All);
let require_serial = options.require_serial.unwrap_or(false);
let verbose = options.verbose.unwrap_or(false);
let stages = options.stages.unwrap_or(Stages::ALL);
let shell = options.shell;
let additional_dependencies = options
.additional_dependencies
.unwrap_or_default()
.into_iter()
.collect::<FxHashSet<_>>();
let language_request = LanguageRequest::parse(self.hook_spec.language, &language_version)
.map_err(|e| Error::Hook {
hook: self.hook_spec.id.clone(),
error: anyhow::anyhow!(e),
})?;
let entry = HookEntry::new(self.hook_spec.id.clone(), self.hook_spec.entry, shell);
let priority = self
.hook_spec
.priority
.unwrap_or(u32::try_from(self.idx).expect("idx too large"));
let mut hook = Hook {
dependencies: OnceLock::new(),
project: self.project,
repo: self.repo,
idx: self.idx,
id: self.hook_spec.id,
name: self.hook_spec.name,
language: self.hook_spec.language,
priority,
entry,
stages,
language_request,
additional_dependencies,
alias,
types,
types_or,
exclude_types,
args,
env,
always_run,
fail_fast,
pass_filenames,
require_serial,
verbose,
files: options.files,
exclude: options.exclude,
description: options.description,
log_file: options.log_file,
minimum_prek_version: options.minimum_prek_version,
};
if let Err(err) = extract_metadata(&mut hook).await {
if err
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() != std::io::ErrorKind::NotFound)
{
trace!("Failed to extract metadata from entry for hook `{hook}`: {err}");
}
}
Ok(hook)
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct Hook {
project: Arc<Project>,
repo: Arc<Repo>,
dependencies: OnceLock<FxHashSet<String>>,
pub idx: usize,
pub id: String,
pub name: String,
pub entry: HookEntry,
pub language: Language,
pub alias: String,
pub files: Option<FilePattern>,
pub exclude: Option<FilePattern>,
pub types: TagSet,
pub types_or: TagSet,
pub exclude_types: TagSet,
pub additional_dependencies: FxHashSet<String>,
pub args: Vec<String>,
pub env: FxHashMap<String, String>,
pub always_run: bool,
pub fail_fast: bool,
pub pass_filenames: PassFilenames,
pub description: Option<String>,
pub language_request: LanguageRequest,
pub log_file: Option<String>,
pub require_serial: bool,
pub stages: Stages,
pub verbose: bool,
pub minimum_prek_version: Option<String>,
pub priority: u32,
}
impl Display for Hook {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if f.alternate() {
write!(f, "{}:{}", self.repo, self.id)
} else {
write!(f, "{}", self.id)
}
}
}
impl Hook {
pub(crate) fn project(&self) -> &Project {
&self.project
}
pub(crate) fn repo(&self) -> &Repo {
&self.repo
}
pub(crate) fn repo_path(&self) -> Option<&Path> {
self.repo.path()
}
pub(crate) fn full_id(&self) -> String {
let path = self.project.relative_path();
if path.as_os_str().is_empty() {
format!(".:{}", self.id)
} else {
format!("{}:{}", path.display(), self.id)
}
}
pub(crate) fn work_dir(&self) -> &Path {
self.project.path()
}
pub(crate) fn is_remote(&self) -> bool {
matches!(&*self.repo, Repo::Remote { .. })
}
pub(crate) fn env_key_dependencies(&self) -> &FxHashSet<String> {
if !self.is_remote() {
return &self.additional_dependencies;
}
self.dependencies.get_or_init(|| {
env_key_dependencies(&self.additional_dependencies, Some(&self.repo.to_string()))
})
}
pub(crate) fn env_key(&self) -> Option<HookEnvKeyRef<'_>> {
if !self.language.supports_install_env() {
return None;
}
Some(HookEnvKeyRef {
language: self.language,
dependencies: self.env_key_dependencies(),
language_request: &self.language_request,
})
}
pub(crate) fn install_dependencies(&self) -> Cow<'_, FxHashSet<String>> {
if let Some(repo_path) = self.repo_path() {
let mut deps = self.additional_dependencies.clone();
deps.insert(repo_path.to_string_lossy().to_string());
Cow::Owned(deps)
} else {
Cow::Borrowed(&self.additional_dependencies)
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct HookEnvKey {
pub(crate) language: Language,
pub(crate) dependencies: FxHashSet<String>,
pub(crate) language_request: LanguageRequest,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HookEnvKeyRef<'a> {
pub(crate) language: Language,
pub(crate) dependencies: &'a FxHashSet<String>,
pub(crate) language_request: &'a LanguageRequest,
}
fn env_key_dependencies(
additional_dependencies: &FxHashSet<String>,
remote_repo_dependency: Option<&str>,
) -> FxHashSet<String> {
let mut deps = FxHashSet::with_capacity_and_hasher(
additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()),
FxBuildHasher,
);
deps.extend(additional_dependencies.iter().cloned());
if let Some(dep) = remote_repo_dependency {
deps.insert(dep.to_string());
}
deps
}
fn matches_install_info(
language: Language,
dependencies: &FxHashSet<String>,
language_request: &LanguageRequest,
info: &InstallInfo,
) -> bool {
info.language == language
&& info.dependencies == *dependencies
&& language_request.satisfied_by(info)
}
impl HookEnvKey {
pub(crate) fn from_hook_spec(
config: &Config,
mut hook_spec: HookSpec,
remote_repo_dependency: Option<&str>,
) -> Result<Option<Self>> {
let language = hook_spec.language;
if !language.supports_install_env() {
return Ok(None);
}
hook_spec.apply_project_defaults(config);
hook_spec.options.language_version.get_or_insert_default();
hook_spec
.options
.additional_dependencies
.get_or_insert_default();
let request = hook_spec.options.language_version.as_deref().unwrap_or("");
let language_request = LanguageRequest::parse(language, request).with_context(|| {
format!(
"Invalid language_version `{request}` for hook `{}`",
hook_spec.id
)
})?;
let additional_dependencies: FxHashSet<String> = hook_spec
.options
.additional_dependencies
.as_ref()
.map_or_else(FxHashSet::default, |deps| deps.iter().cloned().collect());
let dependencies = env_key_dependencies(&additional_dependencies, remote_repo_dependency);
Ok(Some(Self {
language,
dependencies,
language_request,
}))
}
pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool {
matches_install_info(
self.language,
&self.dependencies,
&self.language_request,
info,
)
}
}
impl HookEnvKeyRef<'_> {
pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool {
matches_install_info(
self.language,
self.dependencies,
self.language_request,
info,
)
}
}
#[derive(Debug, Clone)]
pub(crate) enum InstalledHook {
Installed {
hook: Arc<Hook>,
info: Arc<InstallInfo>,
},
NoNeedInstall(Arc<Hook>),
}
impl Deref for InstalledHook {
type Target = Hook;
fn deref(&self) -> &Self::Target {
match self {
InstalledHook::Installed { hook, .. } => hook,
InstalledHook::NoNeedInstall(hook) => hook,
}
}
}
impl Display for InstalledHook {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.deref().fmt(f)
}
}
pub(crate) const HOOK_MARKER: &str = ".prek-hook.json";
impl InstalledHook {
pub(crate) fn env_path(&self) -> Option<&Path> {
match self {
InstalledHook::Installed { info, .. } => Some(&info.env_path),
InstalledHook::NoNeedInstall(_) => None,
}
}
pub(crate) fn toolchain_dir(&self) -> Option<&Path> {
match self {
InstalledHook::Installed { info, .. } => info.toolchain.parent(),
InstalledHook::NoNeedInstall(_) => None,
}
}
pub(crate) fn install_info(&self) -> Option<&InstallInfo> {
match self {
InstalledHook::Installed { info, .. } => Some(info),
InstalledHook::NoNeedInstall(_) => None,
}
}
pub(crate) async fn mark_as_installed(&self, _store: &Store) -> Result<()> {
let Some(info) = self.install_info() else {
return Ok(());
};
let content =
serde_json::to_string_pretty(info).context("Failed to serialize install info")?;
fs_err::tokio::write(info.env_path.join(HOOK_MARKER), content)
.await
.context("Failed to write install info")?;
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct InstallInfo {
pub(crate) language: Language,
pub(crate) language_version: semver::Version,
pub(crate) dependencies: FxHashSet<String>,
pub(crate) env_path: PathBuf,
pub(crate) toolchain: PathBuf,
extra: FxHashMap<String, String>,
#[serde(skip, default)]
temp_dir: Option<TempDir>,
}
impl Clone for InstallInfo {
fn clone(&self) -> Self {
Self {
language: self.language,
language_version: self.language_version.clone(),
dependencies: self.dependencies.clone(),
env_path: self.env_path.clone(),
toolchain: self.toolchain.clone(),
extra: self.extra.clone(),
temp_dir: None,
}
}
}
impl InstallInfo {
pub(crate) fn new(
language: Language,
dependencies: FxHashSet<String>,
hooks_dir: &Path,
) -> Result<Self, Error> {
let env_path = tempfile::Builder::new()
.prefix(&format!("{language}-"))
.rand_bytes(20)
.tempdir_in(hooks_dir)?;
Ok(Self {
language,
dependencies,
env_path: env_path.path().to_path_buf(),
language_version: semver::Version::new(0, 0, 0),
toolchain: PathBuf::new(),
extra: FxHashMap::default(),
temp_dir: Some(env_path),
})
}
pub(crate) fn persist_env_path(&mut self) {
if let Some(temp_dir) = self.temp_dir.take() {
self.env_path = temp_dir.keep();
}
}
pub(crate) async fn from_env_path(path: &Path) -> Result<Self> {
let content = fs_err::tokio::read_to_string(path.join(HOOK_MARKER)).await?;
let info: InstallInfo = serde_json::from_str(&content)?;
Ok(info)
}
pub(crate) async fn check_health(&self) -> Result<()> {
self.language.check_health(self).await
}
pub(crate) fn with_language_version(&mut self, version: semver::Version) -> &mut Self {
self.language_version = version;
self
}
pub(crate) fn with_toolchain(&mut self, toolchain: PathBuf) -> &mut Self {
self.toolchain = toolchain;
self
}
pub(crate) fn with_extra(&mut self, key: &str, value: &str) -> &mut Self {
self.extra.insert(key.to_string(), value.to_string());
self
}
pub(crate) fn get_extra(&self, key: &str) -> Option<&String> {
self.extra.get(key)
}
pub(crate) fn matches(&self, hook: &Hook) -> bool {
hook.env_key()
.is_some_and(|key| key.matches_install_info(self))
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use prek_consts::PRE_COMMIT_CONFIG_YAML;
use prek_identify::tags;
use rustc_hash::FxHashMap;
use crate::config::{
Config, HookOptions, Language, PassFilenames, RemoteHook, Shell, Stage, Stages,
};
use crate::hook::HookSpec;
use crate::languages::version::LanguageRequest;
use crate::workspace::Project;
use super::{Hook, HookBuilder, Repo};
#[tokio::test]
async fn hook_builder_build_fills_and_merges_attributes() -> Result<()> {
let temp = tempfile::tempdir()?;
let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);
fs_err::write(
&config_path,
indoc::indoc! {r"
repos: []
default_language_version:
python: python3.12
default_stages: [manual]
"},
)?;
let project = Arc::new(Project::from_config_file(
Cow::Borrowed(&config_path),
None,
)?);
let repo = Arc::new(Repo::Local { hooks: vec![] });
let mut base_env = FxHashMap::default();
base_env.insert("BASE".to_string(), "1".to_string());
let mut hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "original-name".to_string(),
entry: "python3 -c 'print(1)'".to_string(),
language: Language::Python,
priority: None,
options: HookOptions {
env: Some(base_env),
shell: Some(Shell::Sh),
..Default::default()
},
};
let mut override_env = FxHashMap::default();
override_env.insert("OVERRIDE".to_string(), "2".to_string());
let hook_override = RemoteHook {
id: "test-hook".to_string(),
name: Some("override-name".to_string()),
entry: Some("python3 -c 'print(2)'".to_string()),
language: None,
priority: Some(42),
options: HookOptions {
alias: Some("alias-1".to_string()),
types: Some(tags::TAG_SET_TEXT),
args: Some(vec!["--flag".to_string()]),
env: Some(override_env),
always_run: Some(true),
pass_filenames: Some(PassFilenames::None),
verbose: Some(true),
description: Some("desc".to_string()),
shell: Some(Shell::Bash),
..Default::default()
},
};
hook_spec.apply_remote_hook_overrides(&hook_override);
hook_spec.apply_project_defaults(project.config());
let builder = HookBuilder::new(project.clone(), repo, hook_spec, 7);
let hook = builder.build().await?;
insta::assert_debug_snapshot!(hook, @r#"
Hook {
project: Project {
relative_path: "",
idx: 0,
config: Config {
repos: [],
default_install_hook_types: None,
default_language_version: Some(
{
Python: "python3.12",
},
),
default_stages: Some(
Stages(manual),
),
files: None,
exclude: None,
fail_fast: None,
minimum_prek_version: None,
orphan: None,
_unused_keys: {},
},
repos: [],
..
},
repo: Local {
hooks: [],
},
dependencies: OnceLock(
<uninit>,
),
idx: 7,
id: "test-hook",
name: "override-name",
entry: Shell(
ShellHookEntry {
hook: "test-hook",
entry: "python3 -c 'print(2)'",
shell: Bash,
},
),
language: Python,
alias: "alias-1",
files: None,
exclude: None,
types: [
"text",
],
types_or: [],
exclude_types: [],
additional_dependencies: {},
args: [
"--flag",
],
env: {
"BASE": "1",
"OVERRIDE": "2",
},
always_run: true,
fail_fast: false,
pass_filenames: None,
description: Some(
"desc",
),
language_request: Python(
MajorMinor(
3,
12,
),
),
log_file: None,
require_serial: false,
stages: Stages(manual),
verbose: true,
minimum_prek_version: None,
priority: 42,
}
"#);
Ok(())
}
#[tokio::test]
async fn hook_builder_empty_hook_stages_inherit_default_stages() -> Result<()> {
let temp = tempfile::tempdir()?;
let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);
fs_err::write(&config_path, "repos: []\ndefault_stages: [manual]\n")?;
let project = Arc::new(Project::from_config_file(
Cow::Borrowed(&config_path),
None,
)?);
let repo = Arc::new(Repo::Local { hooks: vec![] });
let hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "test-hook".to_string(),
entry: "python3 -c 'print(1)'".to_string(),
language: Language::Python,
priority: None,
options: HookOptions {
stages: Some(Stages::from([])),
..Default::default()
},
};
let hook = HookBuilder::new(project, repo, hook_spec, 0)
.build()
.await?;
assert_eq!(hook.stages, Stages::from([Stage::Manual]));
Ok(())
}
#[test]
fn hook_spec_apply_project_defaults_sets_explicit_all_when_default_stages_missing() {
let config: Config = serde_saphyr::from_str("repos: []\n").expect("config should parse");
let mut hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "test-hook".to_string(),
entry: "python3 -c 'print(1)'".to_string(),
language: Language::Python,
priority: None,
options: HookOptions::default(),
};
hook_spec.apply_project_defaults(&config);
assert_eq!(hook_spec.options.stages, Some(Stages::ALL));
}
#[tokio::test]
async fn hook_builder_preserves_explicit_empty_default_stages() -> Result<()> {
let temp = tempfile::tempdir()?;
let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);
fs_err::write(&config_path, "repos: []\ndefault_stages: []\n")?;
let project = Arc::new(Project::from_config_file(
Cow::Borrowed(&config_path),
None,
)?);
let repo = Arc::new(Repo::Local { hooks: vec![] });
let hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "test-hook".to_string(),
entry: "python3 -c 'print(1)'".to_string(),
language: Language::Python,
priority: None,
options: HookOptions::default(),
};
let hook = HookBuilder::new(project, repo, hook_spec, 0)
.build()
.await?;
assert_eq!(hook.stages, Stages::from([]));
Ok(())
}
#[tokio::test]
async fn hook_builder_defaults_to_all_when_stages_and_default_stages_missing() -> Result<()> {
let temp = tempfile::tempdir()?;
let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);
fs_err::write(&config_path, "repos: []\n")?;
let project = Arc::new(Project::from_config_file(
Cow::Borrowed(&config_path),
None,
)?);
let repo = Arc::new(Repo::Local { hooks: vec![] });
let hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "test-hook".to_string(),
entry: "python3 -c 'print(1)'".to_string(),
language: Language::Python,
priority: None,
options: HookOptions::default(),
};
let hook = HookBuilder::new(project, repo, hook_spec, 0)
.build()
.await?;
assert_eq!(hook.stages, Stages::ALL);
Ok(())
}
#[tokio::test]
async fn hook_builder_empty_hook_stages_default_to_all_when_default_stages_missing()
-> Result<()> {
let temp = tempfile::tempdir()?;
let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);
fs_err::write(&config_path, "repos: []\n")?;
let project = Arc::new(Project::from_config_file(
Cow::Borrowed(&config_path),
None,
)?);
let repo = Arc::new(Repo::Local { hooks: vec![] });
let hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "test-hook".to_string(),
entry: "python3 -c 'print(1)'".to_string(),
language: Language::Python,
priority: None,
options: HookOptions {
stages: Some(Stages::from([])),
..Default::default()
},
};
let hook = HookBuilder::new(project, repo, hook_spec, 0)
.build()
.await?;
assert_eq!(hook.stages, Stages::ALL);
Ok(())
}
fn setup_python_hook_test() -> Result<(tempfile::TempDir, Arc<Project>)> {
let temp = tempfile::tempdir()?;
let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML);
fs_err::write(&config_path, "repos: []\n")?;
let project = Arc::new(Project::from_config_file(
Cow::Borrowed(&config_path),
None,
)?);
let repo_path = temp.path().join("remote-repo");
fs_err::create_dir_all(&repo_path)?;
Ok((temp, project))
}
async fn build_python_hook(
project: Arc<Project>,
repo_path: PathBuf,
language_version: Option<&str>,
) -> Result<Hook> {
let repo = Arc::new(Repo::Remote {
path: repo_path,
url: "https://example.invalid/hooks".to_string(),
rev: "v0.1.0".to_string(),
hooks: vec![],
});
let hook_spec = HookSpec {
id: "test-hook".to_string(),
name: "test-hook".to_string(),
entry: "./hook.py".to_string(),
language: Language::Python,
priority: None,
options: HookOptions {
language_version: language_version.map(str::to_string),
..Default::default()
},
};
Ok(HookBuilder::new(project, repo, hook_spec, 0)
.build()
.await?)
}
static PEP723_SCRIPT: &str = indoc::indoc! {r#"
# /// script
# requires-python = ">=3.11"
# ///
print("hello")
"#};
#[tokio::test]
async fn hook_builder_python_pep723_overrides_user_and_pyproject() -> Result<()> {
let (temp, project) = setup_python_hook_test()?;
let repo_path = temp.path().join("remote-repo");
fs_err::write(
repo_path.join("pyproject.toml"),
"[project]\nrequires-python = \">=3.8\"\n",
)?;
fs_err::write(repo_path.join("hook.py"), PEP723_SCRIPT)?;
let hook = build_python_hook(project, repo_path, Some("3.9")).await?;
assert_eq!(
hook.language_request,
LanguageRequest::parse(Language::Python, ">=3.11")?
);
Ok(())
}
#[tokio::test]
async fn hook_builder_python_user_language_version_overrides_pyproject() -> Result<()> {
let (temp, project) = setup_python_hook_test()?;
let repo_path = temp.path().join("remote-repo");
fs_err::write(
repo_path.join("pyproject.toml"),
"[project]\nrequires-python = \">=3.11\"\n",
)?;
fs_err::write(repo_path.join("hook.py"), "print(\"hello\")\n")?;
let hook = build_python_hook(project, repo_path, Some("3.9")).await?;
assert_eq!(
hook.language_request,
LanguageRequest::parse(Language::Python, "3.9")?
);
Ok(())
}
#[tokio::test]
async fn hook_builder_python_pep723_overrides_pyproject_without_user_version() -> Result<()> {
let (temp, project) = setup_python_hook_test()?;
let repo_path = temp.path().join("remote-repo");
fs_err::write(
repo_path.join("pyproject.toml"),
"[project]\nrequires-python = \">=3.8\"\n",
)?;
fs_err::write(repo_path.join("hook.py"), PEP723_SCRIPT)?;
let hook = build_python_hook(project, repo_path, None).await?;
assert_eq!(
hook.language_request,
LanguageRequest::parse(Language::Python, ">=3.11")?
);
Ok(())
}
#[tokio::test]
async fn hook_builder_python_defaults_to_any_without_version_sources() -> Result<()> {
let (temp, project) = setup_python_hook_test()?;
let repo_path = temp.path().join("remote-repo");
fs_err::write(repo_path.join("hook.py"), "print(\"hello\")\n")?;
let hook = build_python_hook(project, repo_path, None).await?;
assert!(hook.language_request.is_any());
Ok(())
}
#[tokio::test]
async fn hook_builder_python_pyproject_provides_version_when_no_other_source() -> Result<()> {
let (temp, project) = setup_python_hook_test()?;
let repo_path = temp.path().join("remote-repo");
fs_err::write(
repo_path.join("pyproject.toml"),
"[project]\nrequires-python = \">=3.10\"\n",
)?;
fs_err::write(repo_path.join("hook.py"), "print(\"hello\")\n")?;
let hook = build_python_hook(project, repo_path, None).await?;
assert_eq!(
hook.language_request,
LanguageRequest::parse(Language::Python, ">=3.10")?
);
Ok(())
}
}