use std::borrow::Cow;
use std::fmt::Display;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use anyhow::Result;
use ignore::WalkState;
use owo_colors::OwoColorize;
use prek_consts::CONFIG_FILENAMES;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, error, instrument, trace};
use crate::cli::run::{ConfiguredHook, GroupFilters, Selectors};
use crate::config::{self, Config, read_config};
use crate::fs::{PathClean, Simplified};
use crate::hook::HookSpec;
use crate::hook::{self, Hook, Repo};
use crate::store::{CacheBucket, Store};
use crate::{git, store, warn_user};
#[derive(Error, Debug)]
pub(crate) enum Error {
#[error(transparent)]
Config(#[from] config::Error),
#[error(transparent)]
Hook(#[from] hook::Error),
#[error(transparent)]
Git(#[from] anyhow::Error),
#[error(
"No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.\n\n{} If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.",
"hint:".yellow().bold(),
)]
MissingConfigFile,
#[error(
"Hook `{hook}` was not found in repository `{repo}`. Check the hook ID or choose a `rev` that includes it"
)]
HookNotFound { hook: String, repo: String },
#[error(transparent)]
Store(#[from] store::Error),
}
pub(crate) trait HookInitReporter {
fn on_clone_start(&self, repo: &str) -> usize;
fn on_clone_complete(&self, id: usize);
fn on_complete(&self);
}
#[derive(Clone, Copy, Default)]
pub(crate) struct HookInitFilters<'a> {
selectors: Option<&'a Selectors>,
group_filters: Option<&'a GroupFilters>,
}
impl<'a> HookInitFilters<'a> {
pub(crate) fn new(
selectors: Option<&'a Selectors>,
group_filters: Option<&'a GroupFilters>,
) -> Self {
Self {
selectors,
group_filters,
}
}
pub(crate) fn none() -> Self {
Self::default()
}
fn keeps_remote_repo(self, project: &Project, repo: &config::RemoteRepo) -> bool {
repo.hooks.iter().any(|hook| {
let hook = ConfiguredHook::new(
project.relative_path(),
&hook.id,
hook.options.alias.as_deref(),
hook.groups.as_deref(),
);
self.keeps_configured_hook(&hook)
})
}
fn keeps_configured_hook(self, hook: &ConfiguredHook<'_>) -> bool {
if self
.selectors
.is_some_and(|selectors| selectors.excludes_configured_hook(hook))
{
return false;
}
if let Some(group_filters) = self.group_filters {
return group_filters.matches_configured_hook(hook);
}
true
}
}
struct ProjectInitPlan<'a> {
project: &'a Arc<Project>,
repo_configs: Vec<&'a config::Repo>,
}
impl<'a> ProjectInitPlan<'a> {
fn new(project: &'a Arc<Project>, filters: HookInitFilters<'_>) -> Self {
let mut repo_configs = Vec::with_capacity(project.config.repos.len());
for repo_config in &project.config.repos {
if let config::Repo::Remote(repo) = repo_config {
if !filters.keeps_remote_repo(project, repo) {
continue;
}
}
repo_configs.push(repo_config);
}
Self {
project,
repo_configs,
}
}
}
fn remote_configs_to_clone<'a>(projects: &[ProjectInitPlan<'a>]) -> Vec<&'a config::RemoteRepo> {
let mut seen = FxHashSet::default();
let mut remote_configs = Vec::new();
for project in projects {
for &repo_config in &project.repo_configs {
let config::Repo::Remote(repo) = repo_config else {
continue;
};
if seen.insert(repo.key()) {
remote_configs.push(repo);
}
}
}
remote_configs
}
async fn init_remote_repos<'a>(
store: &Store,
remote_configs: Vec<&'a config::RemoteRepo>,
reporter: Option<&dyn HookInitReporter>,
) -> Result<FxHashMap<config::RemoteRepoKey<'a>, Arc<Repo>>, Error> {
let cloned_repos = store.clone_repos(remote_configs, reporter).await?;
cloned_repos
.into_iter()
.map(|(key, path)| {
let repo = Arc::new(Repo::remote(
key.source().to_string(),
key.rev().to_string(),
path,
)?);
Ok((key, repo))
})
.collect()
}
pub(crate) struct Project {
root: PathBuf,
config_path: PathBuf,
relative_path: PathBuf,
idx: usize,
config: Config,
}
impl std::fmt::Debug for Project {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Project")
.field("relative_path", &self.relative_path)
.field("idx", &self.idx)
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl Display for Project {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_root() {
write!(f, ".")
} else {
write!(f, "{}", self.relative_path.display())
}
}
}
impl PartialEq for Project {
fn eq(&self, other: &Self) -> bool {
self.config_path == other.config_path
}
}
impl Eq for Project {}
impl Hash for Project {
fn hash<H: Hasher>(&self, state: &mut H) {
self.config_path.hash(state);
}
}
impl Project {
pub(crate) fn from_config_file(
config_path: Cow<'_, Path>,
root: Option<PathBuf>,
) -> Result<Self, Error> {
debug!(
path = %config_path.user_display(),
"Loading project configuration"
);
let config_path = std::path::absolute(config_path).map_err(config::Error::from)?;
let config = read_config(&config_path)?;
let config_dir = config_path
.parent()
.expect("config file must have a parent");
let root = root.unwrap_or_else(|| config_dir.to_path_buf());
Ok(Self {
root,
config,
config_path,
idx: 0,
relative_path: PathBuf::new(),
})
}
pub(crate) fn find_config(path: &Path) -> Option<PathBuf> {
for name in CONFIG_FILENAMES {
let file = path.join(name);
if file.is_file() {
return Some(file);
}
}
None
}
fn find_all_configs(path: &Path) -> Vec<(&'static str, PathBuf)> {
let mut configs = Vec::new();
for &name in CONFIG_FILENAMES {
let file = path.join(name);
if file.is_file() {
configs.push((name, file));
}
}
configs
}
pub(crate) fn from_directory(path: &Path) -> Result<Self, Error> {
let present = Self::find_all_configs(path);
let Some((_, selected)) = present.first() else {
return Err(Error::MissingConfigFile);
};
if present.len() > 1 {
let found = present
.iter()
.map(|(name, _)| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
warn_user!(
"Multiple configuration files found ({found}); using `{selected}`",
found = found,
selected = selected.display(),
);
}
Self::from_config_file(Cow::Borrowed(selected), None)
}
pub(crate) fn discover(config_file: Option<&Path>, dir: &Path) -> Result<Project, Error> {
let git_root = git::root().map_err(|e| Error::Git(e.into()))?;
if let Some(config) = config_file {
return Project::from_config_file(config.into(), Some(git_root.to_path_buf()));
}
let workspace_root = Workspace::find_root(None, dir)?;
debug!("Found project root at `{}`", workspace_root.user_display());
Project::from_directory(&workspace_root)
}
pub(crate) fn with_relative_path(&mut self, relative_path: PathBuf) {
self.relative_path = relative_path;
}
fn with_idx(&mut self, idx: usize) {
self.idx = idx;
}
pub(crate) fn config(&self) -> &Config {
&self.config
}
pub(crate) fn config_file(&self) -> &Path {
&self.config_path
}
pub(crate) fn path(&self) -> &Path {
&self.root
}
pub(crate) fn relative_path(&self) -> &Path {
&self.relative_path
}
pub(crate) fn is_root(&self) -> bool {
self.relative_path.as_os_str().is_empty()
}
pub(crate) fn display_name(&self) -> String {
if self.is_root() {
"<workspace>".to_string()
} else {
self.to_string()
}
}
pub(crate) fn depth(&self) -> usize {
self.relative_path.components().count()
}
pub(crate) fn idx(&self) -> usize {
self.idx
}
pub(crate) async fn init_hooks(
self,
store: &Store,
filters: HookInitFilters<'_>,
reporter: Option<&dyn HookInitReporter>,
) -> Result<Vec<Hook>, Error> {
let project = Arc::new(self);
let plan = ProjectInitPlan::new(&project, filters);
let remote_configs = remote_configs_to_clone(std::slice::from_ref(&plan));
let remote_repos = init_remote_repos(store, remote_configs, reporter).await?;
Project::build_hooks(plan, &remote_repos).await
}
async fn build_hooks(
plan: ProjectInitPlan<'_>,
remote_repos: &FxHashMap<config::RemoteRepoKey<'_>, Arc<Repo>>,
) -> Result<Vec<Hook>, Error> {
let project = Arc::clone(plan.project);
let mut hooks = Vec::new();
let mut push_hook = async |repo: &Arc<Repo>, hook_spec: HookSpec| {
let hook = Hook::from_spec(
Arc::clone(&project),
Arc::clone(repo),
hook_spec,
hooks.len(),
)
.await?;
hooks.push(hook);
Ok::<_, Error>(())
};
for repo_config in plan.repo_configs {
let repo = match repo_config {
config::Repo::Remote(repo_config) => {
let repo = remote_repos
.get(&repo_config.key())
.expect("remote repo should have been initialized");
Arc::clone(repo)
}
config::Repo::Local(_) => Arc::new(Repo::Local),
config::Repo::Meta(_) => Arc::new(Repo::Meta),
config::Repo::Builtin(_) => Arc::new(Repo::Builtin),
};
match repo_config {
config::Repo::Remote(repo_config) => {
for hook_config in &repo_config.hooks {
let Some(manifest_hook) = repo.manifest_hook(&hook_config.id) else {
return Err(Error::HookNotFound {
hook: hook_config.id.clone(),
repo: repo.to_string(),
});
};
let hook_spec = HookSpec::from_remote(manifest_hook.clone(), hook_config);
push_hook(&repo, hook_spec).await?;
}
}
config::Repo::Local(repo_config) => {
for hook_config in &repo_config.hooks {
let hook_spec = HookSpec::from(hook_config.clone());
push_hook(&repo, hook_spec).await?;
}
}
config::Repo::Meta(repo_config) => {
for hook_config in &repo_config.hooks {
let hook_spec = HookSpec::from(hook_config.clone());
push_hook(&repo, hook_spec).await?;
}
}
config::Repo::Builtin(repo_config) => {
for hook_config in &repo_config.hooks {
let hook_spec = HookSpec::from(hook_config.clone());
push_hook(&repo, hook_spec).await?;
}
}
}
}
Ok(hooks)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedConfigFile {
path: PathBuf,
modified: SystemTime,
size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WorkspaceCache {
version: u32,
workspace_root: PathBuf,
created_at: SystemTime,
config_files: Vec<CachedConfigFile>,
}
impl WorkspaceCache {
const CURRENT_VERSION: u32 = 1;
const MAX_CACHE_AGE: u64 = 60 * 60;
fn new(workspace_root: PathBuf, projects: &[Project]) -> Self {
let mut config_files = Vec::new();
for project in projects {
if let Ok(metadata) = fs_err::metadata(&project.config_path) {
config_files.push(CachedConfigFile {
path: project.config_path.clone(),
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
size: metadata.len(),
});
}
}
Self {
version: Self::CURRENT_VERSION,
created_at: SystemTime::now(),
workspace_root,
config_files,
}
}
fn is_valid(&self) -> bool {
if let Ok(elapsed) = self.created_at.elapsed() {
if elapsed.as_secs() > Self::MAX_CACHE_AGE {
debug!(
"Cache is too old ({}s > {}s), invalidating",
elapsed.as_secs(),
Self::MAX_CACHE_AGE
);
return false;
}
}
for cached_file in &self.config_files {
if let Ok(metadata) = fs_err::metadata(&cached_file.path) {
let current_modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
let current_size = metadata.len();
if current_modified != cached_file.modified || current_size != cached_file.size {
debug!(
path = %cached_file.path.display(),
"Config file changed, invalidating cache"
);
return false;
}
} else {
debug!(
path = %cached_file.path.display(),
"Config file no longer exists, invalidating cache"
);
return false;
}
}
if !self.workspace_root.exists() {
debug!("Workspace root no longer exists, invalidating cache");
return false;
}
true
}
fn cache_path(store: &Store, workspace_root: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
workspace_root.hash(&mut hasher);
let digest = hex::encode(hasher.finish().to_le_bytes());
store
.cache_path(CacheBucket::Prek)
.join("workspace")
.join(digest)
}
fn load(store: &Store, workspace_root: &Path, refresh: bool) -> Option<Self> {
if refresh {
return None;
}
let cache_path = Self::cache_path(store, workspace_root);
match fs_err::read_to_string(&cache_path) {
Ok(content) => match serde_json::from_str::<Self>(&content) {
Ok(cache) => {
if cache.version == Self::CURRENT_VERSION && cache.is_valid() {
Some(cache)
} else {
let _ = fs_err::remove_file(&cache_path);
None
}
}
Err(e) => {
debug!("Failed to deserialize cache: {}", e);
let _ = fs_err::remove_file(&cache_path);
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
debug!("Failed to read cache file: {}", e);
None
}
}
}
fn save(&self, store: &Store) -> Result<()> {
let cache_path = Self::cache_path(store, &self.workspace_root);
if let Some(parent) = cache_path.parent() {
fs_err::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs_err::write(&cache_path, content)?;
Ok(())
}
}
pub(crate) struct Workspace {
root: PathBuf,
projects: Vec<Arc<Project>>,
all_projects: Vec<Arc<Project>>,
}
impl Workspace {
pub(crate) fn invalidate_cache(store: &Store, root: &Path) -> Result<()> {
let cache_path = WorkspaceCache::cache_path(store, root);
match fs_err::remove_file(cache_path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
pub(crate) fn find_root(config_file: Option<&Path>, dir: &Path) -> Result<PathBuf, Error> {
let git_root = git::root().map_err(|e| Error::Git(e.into()))?;
if config_file.is_some() {
return Ok(git_root.to_path_buf());
}
let workspace_root = dir
.ancestors()
.take_while(|p| git_root.parent().map(|root| *p != root).unwrap_or(true))
.find(|p| Project::find_config(p).is_some())
.ok_or(Error::MissingConfigFile)?
.to_path_buf();
debug!("Found workspace root at `{}`", workspace_root.display());
Ok(workspace_root)
}
#[instrument(level = "trace", skip(store, selectors))]
pub(crate) fn discover(
store: &Store,
root: PathBuf,
config: Option<PathBuf>,
selectors: Option<&Selectors>,
refresh: bool,
) -> Result<Self, Error> {
if let Some(config) = config {
let project = Arc::new(Project::from_config_file(
config.into(),
Some(root.clone()),
)?);
return Ok(Self {
root,
projects: vec![Arc::clone(&project)],
all_projects: vec![project],
});
}
let projects = if let Some(cache) = WorkspaceCache::load(store, &root, refresh) {
debug!("Loaded workspace from cache");
let projects: Result<Vec<_>, _> = cache
.config_files
.into_iter()
.map(
|config_file| match Project::from_config_file(config_file.path.into(), None) {
Ok(mut project) => {
let relative_path = project
.config_file()
.parent()
.and_then(|p| p.strip_prefix(&root).ok())
.expect("Entry path should be relative to the root")
.to_path_buf();
project.with_relative_path(relative_path);
Ok(project)
}
Err(e) => {
debug!("Failed to load cached project config: {}", e);
Err(e)
}
},
)
.collect();
match projects {
Ok(projects) if !projects.is_empty() => Some(projects),
_ => {
debug!("Cache invalid or empty, performing fresh discovery");
None
}
}
} else {
None
};
let mut all_projects = if let Some(projects) = projects {
projects
} else {
debug!("Performing fresh workspace discovery");
let projects = Self::discover_fresh(&root, selectors)?;
let cache = WorkspaceCache::new(root.clone(), &projects);
if let Err(e) = cache.save(store) {
debug!("Failed to save workspace cache: {}", e);
}
projects
};
Self::sort_and_index_projects(&mut all_projects);
let all_projects = all_projects.into_iter().map(Arc::new).collect::<Vec<_>>();
let projects = if let Some(selectors) = selectors {
let selected = all_projects
.iter()
.filter(|p| selectors.matches_path(p.relative_path()))
.cloned()
.collect::<Vec<_>>();
if selected.is_empty() {
return Err(Error::MissingConfigFile);
}
selected
} else {
all_projects.clone()
};
if projects.is_empty() {
return Err(Error::MissingConfigFile);
}
Ok(Self {
root,
projects,
all_projects,
})
}
fn discover_fresh(root: &Path, selectors: Option<&Selectors>) -> Result<Vec<Project>, Error> {
let projects = Mutex::new(Ok(Vec::new()));
let git_root = git::root().map_err(|e| Error::Git(e.into()))?;
let submodules = git::list_submodules(git_root).unwrap_or_else(|e| {
error!("Failed to list git submodules: {e}");
Vec::new()
});
ignore::WalkBuilder::new(root)
.follow_links(false)
.add_custom_ignore_filename(".prekignore")
.build_parallel()
.run(|| {
Box::new(|result| {
let Ok(entry) = result else {
return WalkState::Continue;
};
let Some(file_type) = entry.file_type() else {
return WalkState::Continue;
};
if !file_type.is_dir() {
return WalkState::Continue;
}
if entry.file_name().to_str().is_some_and(|filename| {
filename.starts_with("{{")
&& filename.ends_with("}}")
&& filename.contains("cookiecutter")
}) {
trace!(
path = %entry.path().user_display(),
"Skipping cookiecutter template directory"
);
return WalkState::Skip;
}
if submodules
.iter()
.any(|submodule| entry.path().starts_with(submodule))
{
trace!(
path = %entry.path().user_display(),
"Skipping git submodule"
);
return WalkState::Skip;
}
match Project::from_directory(entry.path()) {
Ok(mut project) => {
let relative_path = entry
.into_path()
.strip_prefix(root)
.expect("Entry path should be relative to the root")
.to_path_buf();
project.with_relative_path(relative_path);
if let Ok(projects) = projects.lock().unwrap().as_mut() {
projects.push(project);
}
}
Err(Error::MissingConfigFile) => {}
Err(e) => {
if let Some(selectors) = selectors {
let relative_path = entry
.path()
.strip_prefix(root)
.expect("Entry path should be relative to the root");
if selectors.matches_path(relative_path) {
*projects.lock().unwrap() = Err(e);
return WalkState::Quit;
}
}
error!(
path = %entry.path().user_display(),
"Skipping project due to error: {e}"
);
return WalkState::Skip;
}
}
WalkState::Continue
})
});
let projects = projects.into_inner().unwrap()?;
if projects.is_empty() {
return Err(Error::MissingConfigFile);
}
Ok(projects)
}
fn sort_and_index_projects(projects: &mut [Project]) {
projects.sort_by(|a, b| {
b.depth()
.cmp(&a.depth())
.then_with(|| a.relative_path.cmp(&b.relative_path))
});
for (idx, project) in projects.iter_mut().enumerate() {
project.with_idx(idx);
}
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn projects(&self) -> &[Arc<Project>] {
&self.projects
}
pub(crate) fn config_files(&self) -> impl Iterator<Item = &Path> {
self.projects.iter().map(|project| project.config_file())
}
pub(crate) fn all_projects(&self) -> &[Arc<Project>] {
&self.all_projects
}
pub(crate) async fn init_hooks(
&self,
store: &Store,
filters: HookInitFilters<'_>,
reporter: Option<&dyn HookInitReporter>,
) -> Result<Vec<Hook>, Error> {
let plans = self
.projects
.iter()
.map(|project| ProjectInitPlan::new(project, filters))
.collect::<Vec<_>>();
let remote_configs = remote_configs_to_clone(&plans);
let remote_repos = init_remote_repos(store, remote_configs, reporter).await?;
let mut hooks = Vec::new();
for plan in plans {
let project_hooks = Project::build_hooks(plan, &remote_repos).await?;
hooks.extend(project_hooks);
}
reporter.map(HookInitReporter::on_complete);
Ok(hooks)
}
pub(crate) fn check_configs_staged(&self, unstaged: &[PathBuf]) -> Result<()> {
let config_files = self
.config_files()
.map(Path::clean)
.collect::<FxHashSet<_>>();
let unstaged_configs = unstaged
.iter()
.filter(|path| config_files.contains(*path))
.collect::<Vec<_>>();
match unstaged_configs.as_slice() {
[] => Ok(()),
[filename] => anyhow::bail!(
"Configuration file `{}` is not staged. Stage it with `git add` and try again",
filename.user_display().cyan()
),
filenames => anyhow::bail!(
"The following configuration files are not staged. Stage them with `git add` and try again:\n{}",
filenames
.iter()
.map(|p| format!(" - `{}`", p.user_display()))
.collect::<Vec<_>>()
.join("\n")
),
}
}
}