Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions crates/prek/src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ use same_file::is_same_file;
use crate::cli::reporter::{HookInitReporter, HookInstallReporter};
use crate::cli::run;
use crate::cli::run::InstallCache;
use crate::cli::run::{SelectorSource, Selectors};
use crate::cli::run::{ConfiguredHook, SelectorSource, Selectors};
use crate::cli::{ExitStatus, HookType};
use crate::config::load_config;
use crate::config::{Repo, Stage, Stages, load_config};
use crate::fs::{CWD, Simplified};
use crate::git::{GIT_ROOT, git_cmd};
use crate::printer::Printer;
Expand Down Expand Up @@ -105,6 +105,17 @@ pub(crate) async fn install(
None
};

if project.is_some() {
tip_unmatched_hook_stages(
store,
config.as_deref(),
selectors.as_ref(),
refresh,
&hook_types,
printer,
)?;
}

for hook_type in hook_types {
install_hook_script(
project.as_ref(),
Expand Down Expand Up @@ -202,6 +213,122 @@ fn get_hook_types(
hook_types
}

/// Return whether the selectors persisted into the installed hook scripts will run this hook.
///
/// Mirrors `install_hook_script`: every include is persisted, but only CLI-flag skips are
/// (env-var skips are ignored at install time). An include that only matches a remote hook
/// through its manifest alias can't be recognized from the config alone, so such a hook is
/// treated as not selected.
fn shim_selects_hook(selectors: Option<&Selectors>, hook: &ConfiguredHook<'_>) -> bool {
let Some(selectors) = selectors else {
return true;
};

if selectors
.skips()
.iter()
.filter(|skip| matches!(skip.source(), SelectorSource::CliFlag(_)))
.any(|skip| skip.matches_configured_hook(hook))
{
return false;
}

let includes = selectors.includes();
includes.is_empty()
|| includes
.iter()
.any(|include| include.matches_configured_hook(hook))
}

/// Print a tip about configured hooks whose stages aren't covered by the hook types being
/// installed, since their shims won't be installed and they won't run automatically.
///
/// This is intentionally not a warning: skipping a stage (e.g. to only run a hook manually by
/// id, or from CI) is a valid setup, not something that needs fixing.
fn tip_unmatched_hook_stages(
store: &Store,
config: Option<&Path>,
selectors: Option<&Selectors>,
refresh: bool,
hook_types: &[HookType],
printer: Printer,
) -> Result<()> {
let installed = Stages::from(
hook_types
.iter()
.map(|&hook_type| Stage::from(hook_type))
.collect::<Vec<_>>(),
);

// A plain root install runs every workspace project, so scan them all, the same way `run` does.
let Ok(root) = Workspace::find_root(config, &CWD) else {
return Ok(());
};
let Ok(workspace) =
Workspace::discover(store, root, config.map(Path::to_path_buf), None, refresh)
else {
return Ok(());
};

let mut unmatched: Vec<(&str, Stages)> = Vec::new();
for project in workspace.projects() {
let config = project.config();
for repo in &config.repos {
let is_remote = matches!(repo, Repo::Remote(_));
for hook in repo.iter_hooks() {
let configured = ConfiguredHook::new(
project.relative_path(),
hook.id,
hook.options.alias.as_deref(),
hook.groups,
);
if !shim_selects_hook(selectors, &configured) {
continue;
}

// A remote hook that omits `stages` may inherit them from its manifest, which
// install never fetches, so skip it rather than risk a false positive. An explicit
// value (even an empty list) overrides the manifest and resolves through
// `default_stages`, the same way the hook builder does.
if is_remote && hook.options.stages.is_none() {
continue;
}
let stages = Stages::resolve(hook.options.stages, config.default_stages);

// `Stage::Manual` has no git shim, so it is ignored here.
let required = Stages::from(
stages
.iter()
.filter(|&stage| stage != Stage::Manual)
.collect::<Vec<_>>(),
);
if !required.is_empty() && !required.intersects(installed) {
unmatched.push((hook.id, required));
}
}
}
}

if unmatched.is_empty() {
return Ok(());
}

let list = unmatched
.iter()
.map(|(id, stages)| format!(" - `{}` ({})", id.cyan(), stages.yellow()))
.collect::<Vec<_>>()
.join("\n");
writeln!(
printer.stdout(),
"{} the following hooks are configured for stages that aren't installed, so they'll only run when invoked manually or from CI, not automatically:\n{list}\nIf that's intentional, no action is needed. Otherwise, install the missing hook type(s) with `{}`, or set `{}` in your config.",
"tip:".cyan().bold(),
"prek install --hook-type <type>".cyan(),
"default_install_hook_types".cyan(),
)?;

Ok(())
}

#[allow(clippy::fn_params_excessive_bools)]
fn install_hook_script(
project: Option<&Project>,
Expand Down
2 changes: 1 addition & 1 deletion crates/prek/src/cli/run/selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ impl Selector {
}
}

fn matches_configured_hook(&self, hook: &ConfiguredHook<'_>) -> bool {
pub(crate) fn matches_configured_hook(&self, hook: &ConfiguredHook<'_>) -> bool {
let matches_hook_id = |selector: &str| {
hook.id == selector || hook.alias.is_some_and(|alias| alias == selector)
};
Expand Down
41 changes: 41 additions & 0 deletions crates/prek/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,19 @@ impl Stages {
pub(crate) fn contains(self, stage: Stage) -> bool {
(self.0 & stage.bit()) != 0
}

pub(crate) fn intersects(self, other: Self) -> bool {
(self.0 & other.0) != 0
}

/// A hook with no `stages` (or an empty list) inherits `default_stages`,
/// which itself falls back to [`Stages::ALL`] when unset.
pub(crate) fn resolve(hook: Option<Self>, default: Option<Self>) -> Self {
match hook {
Some(stages) if !stages.is_empty() => stages,
_ => default.unwrap_or(Self::ALL),
}
}
}

impl Display for Stages {
Expand Down Expand Up @@ -1122,6 +1135,34 @@ impl<'de> Deserialize<'de> for Repo {
}
}

/// The parts of a config hook entry shared by every repo type.
pub(crate) struct ConfigHook<'a> {
pub id: &'a str,
pub groups: Option<&'a [String]>,
pub options: &'a HookOptions,
}

impl Repo {
pub(crate) fn iter_hooks(&self) -> Box<dyn Iterator<Item = ConfigHook<'_>> + '_> {
macro_rules! iter {
($repo:expr) => {
Box::new($repo.hooks.iter().map(|h| ConfigHook {
id: h.id.as_str(),
groups: h.groups.as_deref(),
options: &h.options,
}))
};
}

match self {
Repo::Remote(repo) => iter!(repo),
Repo::Local(repo) => iter!(repo),
Repo::Meta(repo) => iter!(repo),
Repo::Builtin(repo) => iter!(repo),
}
}
}

fn remote_hook_to_local<E>(hook: RemoteHook) -> Result<LocalHook, E>
where
E: DeError,
Expand Down
9 changes: 1 addition & 8 deletions crates/prek/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,7 @@ impl HookSpec {
.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));
}
self.options.stages = Some(Stages::resolve(self.options.stages, config.default_stages));

if let Some(default_env) = &config.default_env {
let mut env = default_env.clone();
Expand Down
Loading
Loading