use std::fmt::Write as _;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use bstr::ByteSlice;
use clap::ValueEnum;
use owo_colors::OwoColorize;
use prek_consts::CONFIG_FILENAMES;
use same_file::is_same_file;
use crate::cli::reporter::{HookInitReporter, HookInstallReporter};
use crate::cli::run;
use crate::cli::run::{SelectorSource, Selectors};
use crate::cli::{ExitStatus, HookType};
use crate::config::load_config;
use crate::fs::{CWD, Simplified};
use crate::git::{GIT_ROOT, git_cmd};
use crate::printer::Printer;
use crate::store::Store;
use crate::workspace::{Error as WorkspaceError, Project, Workspace};
use crate::{git, warn_user};
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn install(
store: &Store,
config: Option<PathBuf>,
includes: Vec<String>,
skips: Vec<String>,
hook_types: Vec<HookType>,
prepare_hooks: bool,
overwrite: bool,
allow_missing_config: bool,
refresh: bool,
quiet: u8,
verbose: u8,
no_progress: bool,
printer: Printer,
git_dir: Option<&Path>,
) -> Result<ExitStatus> {
if git_dir.is_none()
&& git::has_hooks_path_set().await?
&& !git::has_repo_hooks_path_set().await?
{
anyhow::bail!(
concat!(
"Refusing to install hooks because `core.hooksPath` is configured outside this repository.\n",
"\n{} Git will execute hooks from the configured global/system hooks directory, not from this repository's hooks directory.\n",
"\n{} Remove the global/system setting, or move `core.hooksPath` into repo scope for this repository instead.\n",
" {}\n",
" {}\n",
" {}\n",
),
"note:".yellow().bold(),
"hint:".yellow().bold(),
"git config --unset-all --global core.hooksPath".cyan(),
"git config --unset-all --system core.hooksPath".cyan(),
"git config --local core.hooksPath <path>".cyan(),
);
}
let hook_mode = git::get_shared_repository_file_mode(0o755)
.await
.unwrap_or(0o755);
let project = match Project::discover(config.as_deref(), &CWD) {
Ok(project) => Some(project),
Err(err) => {
if let WorkspaceError::Config(err) = &err {
err.warn_parse_error();
}
None
}
};
let hook_types = get_hook_types(hook_types, project.as_ref(), config.as_deref());
let hooks_path = if let Some(dir) = git_dir {
dir.join("hooks")
} else {
git::get_git_hooks_dir().await?
};
fs_err::create_dir_all(&hooks_path)?;
let selectors = if let Some(project) = &project {
Some(Selectors::load(&includes, &skips, project.path())?)
} else if !includes.is_empty() || !skips.is_empty() {
anyhow::bail!("Cannot use `--include` or `--skip` outside of a git repository");
} else {
None
};
for hook_type in hook_types {
install_hook_script(
project.as_ref(),
config.clone(),
selectors.as_ref(),
hook_type,
&hooks_path,
overwrite,
allow_missing_config,
hook_mode,
quiet,
verbose,
no_progress,
printer,
)?;
}
if prepare_hooks {
self::prepare_hooks(store, config, includes, skips, refresh, printer).await?;
}
Ok(ExitStatus::Success)
}
pub(crate) async fn prepare_hooks(
store: &Store,
config: Option<PathBuf>,
includes: Vec<String>,
skips: Vec<String>,
refresh: bool,
printer: Printer,
) -> Result<ExitStatus> {
let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;
let selectors = Selectors::load(&includes, &skips, &workspace_root)?;
let mut workspace =
Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?;
let reporter = HookInitReporter::new(printer);
let _lock = store.lock_async().await?;
let hooks = workspace
.init_hooks(store, Some(&reporter))
.await
.context("Failed to init hooks")?;
let filtered_hooks: Vec<_> = hooks
.into_iter()
.filter(|h| selectors.matches_hook(h))
.map(Arc::new)
.collect();
let reporter = HookInstallReporter::new(printer);
run::install_hooks(filtered_hooks, store, &reporter).await?;
Ok(ExitStatus::Success)
}
fn get_hook_types(
mut hook_types: Vec<HookType>,
project: Option<&Project>,
config: Option<&Path>,
) -> Vec<HookType> {
if !hook_types.is_empty() {
return hook_types;
}
hook_types = if let Some(project) = project {
project
.config()
.default_install_hook_types
.clone()
.unwrap_or_default()
} else {
let fallbacks = CONFIG_FILENAMES
.iter()
.map(Path::new)
.filter(|p| p.exists());
if let Some(path) = config.into_iter().chain(fallbacks).next() {
match load_config(path) {
Ok(cfg) => cfg.default_install_hook_types.clone().unwrap_or_default(),
Err(err) => {
err.warn_parse_error();
vec![]
}
}
} else {
vec![]
}
};
if hook_types.is_empty() {
hook_types = vec![HookType::PreCommit];
}
hook_types
}
#[allow(clippy::fn_params_excessive_bools)]
fn install_hook_script(
project: Option<&Project>,
config: Option<PathBuf>,
selectors: Option<&Selectors>,
hook_type: HookType,
hooks_path: &Path,
overwrite: bool,
skip_on_missing_config: bool,
hook_mode: u32,
quiet: u8,
verbose: u8,
no_progress: bool,
printer: Printer,
) -> Result<()> {
let hook_path = hooks_path.join(hook_type.as_ref());
let legacy_path = hook_path.with_added_extension("legacy");
if hook_path.try_exists()? {
if overwrite {
writeln!(
printer.stdout(),
"Overwriting existing hook at `{}`",
hook_path.user_display().cyan()
)?;
} else {
if !is_our_script(&hook_path)? {
fs_err::rename(&hook_path, &legacy_path)?;
writeln!(
printer.stdout(),
"Hook already exists at `{}`, moved it to `{}`",
hook_path.user_display().cyan(),
legacy_path.user_display().yellow()
)?;
}
}
}
if legacy_path.try_exists()? {
if overwrite {
fs_err::remove_file(&legacy_path)?;
} else {
writeln!(
printer.stdout(),
"Migration mode: prek will also run legacy hook `{}`. Use `--overwrite` to remove legacy hooks.",
legacy_path.user_display().yellow()
)?;
}
}
let mut args = vec![];
if let Some(selectors) = selectors {
for include in selectors.includes() {
args.push(include.as_normalized_flag());
}
if let Some(env_var) = selectors.skips().iter().find_map(|skip| {
if let SelectorSource::EnvVar(var) = skip.source() {
Some(var)
} else {
None
}
}) {
warn_user!(
"Skip selectors from environment variables `{}` are ignored during installing hooks.",
env_var.cyan()
);
}
for skip in selectors.skips() {
if matches!(skip.source(), SelectorSource::CliFlag(_)) {
args.push(skip.as_normalized_flag());
}
}
}
args.push(format!("--hook-type={hook_type}"));
let mut hint = format!("prek installed at `{}`", hook_path.user_display().cyan());
if let Some(config) = config {
args.push(format!(r#"--config="{}""#, config.display()));
write!(hint, " with specified config `{}`", config.display().cyan())?;
} else if let Some(project) = project {
let git_root = GIT_ROOT.as_ref()?;
let project_path = project.path();
let relative_path = project_path.strip_prefix(git_root).unwrap_or(project_path);
if !relative_path.as_os_str().is_empty() {
args.push(format!(r#"--cd="{}""#, relative_path.display()));
}
if project_path != git_root {
writeln!(hint, " for workspace `{}`", project_path.display().cyan())?;
write!(
hint,
"\n{} this hook installed for `{}` only; run `prek install` from `{}` to install for the entire repo.",
"hint:".bold().yellow(),
project_path.display().cyan(),
git_root.display().cyan()
)?;
}
}
if skip_on_missing_config {
args.push("--skip-on-missing-config".to_string());
}
let prek = std::env::current_exe()?;
let prek = prek.simplified_display().to_string();
let mut prek_global_args = render_global_args(quiet, verbose, no_progress);
if !prek_global_args.is_empty() {
prek_global_args.push(' ');
}
let hook_script = HOOK_TMPL
.replace("[CUR_SCRIPT_VERSION]", &CUR_SCRIPT_VERSION.to_string())
.replace("[PREK_PATH]", &format!(r#""{prek}""#))
.replace("[PREK_GLOBAL_ARGS]", &prek_global_args)
.replace("[PREK_ARGS]", &args.join(" "));
fs_err::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&hook_path)?
.write_all(hook_script.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = hook_path.metadata()?.permissions();
perms.set_mode(hook_mode);
fs_err::set_permissions(&hook_path, perms)?;
}
#[cfg(not(unix))]
let _ = hook_mode;
writeln!(printer.stdout(), "{hint}")?;
Ok(())
}
fn render_global_args(quiet: u8, verbose: u8, no_progress: bool) -> String {
let mut args = Vec::with_capacity(3);
if quiet > 0 {
args.push(format!("-{}", "q".repeat(quiet.into())));
}
if verbose > 0 {
args.push(format!("-{}", "v".repeat(verbose.into())));
}
if no_progress {
args.push("--no-progress".to_string());
}
if args.is_empty() {
String::new()
} else {
args.join(" ")
}
}
pub(crate) static CUR_SCRIPT_VERSION: usize = 4;
static HOOK_TMPL: &str = r#"#!/bin/sh
# File generated by prek: https://github.com/j178/prek
# ID: 182c10f181da4464a3eec51b83331688
HERE="$(cd "$(dirname "$0")" && pwd)"
PREK=[PREK_PATH]
# Check if the full path to prek is executable, otherwise fallback to PATH
if [ ! -x "$PREK" ]; then
PREK="prek"
fi
exec "$PREK" [PREK_GLOBAL_ARGS]hook-impl --hook-dir "$HERE" --script-version [CUR_SCRIPT_VERSION] [PREK_ARGS] -- "$@"
"#;
static PRIOR_HASHES: &[&str] = &[];
static CURRENT_HASH: &str = "182c10f181da4464a3eec51b83331688";
fn is_our_script(hook_path: &Path) -> std::io::Result<bool> {
let content = fs_err::read_to_string(hook_path)?;
Ok(std::iter::once(CURRENT_HASH)
.chain(PRIOR_HASHES.iter().copied())
.any(|hash| content.contains(hash)))
}
pub(crate) async fn uninstall(
config: Option<PathBuf>,
hook_types: Vec<HookType>,
all: bool,
printer: Printer,
git_dir: Option<&Path>,
) -> Result<ExitStatus> {
if git_dir.is_none()
&& git::has_hooks_path_set().await?
&& !git::has_repo_hooks_path_set().await?
{
anyhow::bail!(
concat!(
"Refusing to uninstall hooks because `core.hooksPath` is configured outside this repository.\n",
"\n{} Git will execute hooks from the configured global/system hooks directory, not from this repository's hooks directory.\n",
"\n{} Remove the global/system setting, or move `core.hooksPath` into repo scope for this repository instead.\n",
" {}\n",
" {}\n",
" {}\n",
),
"note:".yellow().bold(),
"hint:".yellow().bold(),
"git config --unset-all --global core.hooksPath".cyan(),
"git config --unset-all --system core.hooksPath".cyan(),
"git config --local core.hooksPath <path>".cyan(),
);
}
let project = Project::discover(config.as_deref(), &CWD).ok();
let hooks_path = if let Some(dir) = git_dir {
dir.join("hooks")
} else {
git::get_git_hooks_dir().await?
};
let types: Vec<HookType> = if all {
HookType::value_variants().to_vec()
} else {
get_hook_types(hook_types, project.as_ref(), config.as_deref())
};
for hook_type in types {
let hook_path = hooks_path.join(hook_type.as_ref());
let legacy_path = hook_path.with_added_extension("legacy");
if is_our_script(&legacy_path).unwrap_or(false) {
fs_err::remove_file(&legacy_path)?;
writeln!(
printer.stderr(),
"Found legacy hook at `{}`, removing it.",
legacy_path.user_display().cyan()
)?;
}
match is_our_script(&hook_path) {
Ok(true) => {}
Ok(false) => {
if !all {
writeln!(
printer.stderr(),
"`{}` is not managed by prek, skipping.",
hook_path.user_display().cyan()
)?;
}
continue;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if !all {
writeln!(
printer.stderr(),
"`{}` does not exist, skipping.",
hook_path.user_display().cyan()
)?;
}
continue;
}
Err(err) => return Err(err.into()),
}
fs_err::remove_file(&hook_path)?;
writeln!(
printer.stdout(),
"Uninstalled `{}`",
hook_type.as_ref().cyan()
)?;
if legacy_path.try_exists()? {
fs_err::rename(&legacy_path, &hook_path)?;
writeln!(
printer.stdout(),
"Restored `{}` to `{}`",
legacy_path.user_display().cyan(),
hook_path.user_display().cyan()
)?;
}
}
Ok(ExitStatus::Success)
}
pub(crate) async fn init_template_dir(
store: &Store,
directory: PathBuf,
config: Option<PathBuf>,
hook_types: Vec<HookType>,
requires_config: bool,
refresh: bool,
quiet: u8,
verbose: u8,
no_progress: bool,
printer: Printer,
) -> Result<ExitStatus> {
install(
store,
config,
vec![],
vec![],
hook_types,
false,
true,
!requires_config,
refresh,
quiet,
verbose,
no_progress,
printer,
Some(&directory),
)
.await?;
let output = git_cmd("git config")?
.arg("config")
.arg("init.templateDir")
.check(false)
.output()
.await?;
let template_dir = String::from_utf8_lossy(output.stdout.trim()).to_string();
if template_dir.is_empty() || !is_same_file(&directory, &template_dir)? {
warn_user!(
"git config `init.templateDir` not set to the target directory, try `{}`",
format!(
"git config --global init.templateDir '{}'",
directory.display()
)
.cyan()
);
}
Ok(ExitStatus::Success)
}