From 5b89765f93f663f11710682eb8c7a8d0a4cc2f90 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:21:11 +0800 Subject: [PATCH] Implement meta hooks (#135) * temp * temp * Split run module * Finish * Fix clippy --- src/builtin/meta_hooks.rs | 149 +++++ src/builtin/mod.rs | 40 ++ src/cli/install.rs | 2 +- src/cli/mod.rs | 2 +- src/cli/run/filter.rs | 259 ++++++++ src/cli/run/keeper.rs | 249 ++++++++ src/cli/run/mod.rs | 7 + src/cli/{ => run}/run.rs | 318 +++++++--- src/config.rs | 54 +- src/hook.rs | 15 +- src/languages/mod.rs | 9 +- src/main.rs | 1 + src/run.rs | 575 +----------------- ...prefligit__config__tests__read_config.snap | 2 - tests/run.rs | 62 ++ 15 files changed, 1053 insertions(+), 691 deletions(-) create mode 100644 src/builtin/meta_hooks.rs create mode 100644 src/builtin/mod.rs create mode 100644 src/cli/run/filter.rs create mode 100644 src/cli/run/keeper.rs create mode 100644 src/cli/run/mod.rs rename src/cli/{ => run}/run.rs (54%) diff --git a/src/builtin/meta_hooks.rs b/src/builtin/meta_hooks.rs new file mode 100644 index 0000000..27be1a3 --- /dev/null +++ b/src/builtin/meta_hooks.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use fancy_regex::Regex; +use itertools::Itertools; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; + +use crate::cli::run::{get_filenames, FileFilter, FileOptions}; +use crate::config::Language; +use crate::hook::{Hook, Project}; +use crate::store::Store; + +/// Ensures that the configured hooks apply to at least one file in the repository. +pub async fn check_hooks_apply( + _hook: &Hook, + filenames: &[&String], + _env_vars: Arc>, +) -> Result<(i32, Vec)> { + let store = Store::from_settings()?.init()?; + + let input = get_filenames(FileOptions::default().with_all_files(true)).await?; + + let mut code = 0; + let mut output = Vec::new(); + + for filename in filenames { + let mut project = Project::from_config_file(Some(PathBuf::from(filename)))?; + let hooks = project.init_hooks(&store, None).await?; + + let filter = FileFilter::new( + &input, + project.config().files.as_deref(), + project.config().exclude.as_deref(), + )?; + + for hook in hooks { + if hook.always_run || matches!(hook.language, Language::Fail) { + continue; + } + + let filenames = filter.for_hook(&hook)?; + + if filenames.is_empty() { + code = 1; + output + .extend(format!("{} does not apply to this repository\n", hook.id).as_bytes()); + } + } + } + + Ok((code, output)) +} + +// Returns true if the exclude patter matches any files matching the include pattern. +fn excludes_any + Sync>( + files: &[T], + include: Option<&str>, + exclude: Option<&str>, +) -> Result { + if exclude.is_none_or(|s| s == "^$") { + return Ok(true); + } + + let include = include.map(Regex::new).transpose()?; + let exclude = exclude.map(Regex::new).transpose()?; + Ok(files.into_par_iter().any(|f| { + let f = f.as_ref(); + if let Some(re) = &include { + if !re.is_match(f).unwrap_or(false) { + return false; + } + } + if let Some(re) = &exclude { + if !re.is_match(f).unwrap_or(false) { + return false; + } + } + true + })) +} + +/// Ensures that exclude directives apply to any file in the repository. +pub async fn check_useless_excludes( + _hook: &Hook, + filenames: &[&String], + _env_vars: Arc>, +) -> Result<(i32, Vec)> { + let store = Store::from_settings()?.init()?; + + let input = get_filenames(FileOptions::default().with_all_files(true)).await?; + + let mut code = 0; + let mut output = Vec::new(); + + for filename in filenames { + let mut project = Project::from_config_file(Some(PathBuf::from(filename)))?; + + if !excludes_any(&input, None, project.config().exclude.as_deref())? { + code = 1; + output.extend( + format!( + "The global exclude pattern {:?} does not match any files", + project.config().exclude.as_deref().unwrap_or("") + ) + .as_bytes(), + ); + } + + let hooks = project.init_hooks(&store, None).await?; + + let filter = FileFilter::new( + &input, + project.config().files.as_deref(), + project.config().exclude.as_deref(), + )?; + + for hook in hooks { + let filtered_files = filter.by_tag(&hook); + if !excludes_any( + &filtered_files, + hook.files.as_deref(), + hook.exclude.as_deref(), + )? { + code = 1; + output.extend( + format!( + "The exclude pattern {:?} for {} does not match any files\n", + hook.exclude.as_deref().unwrap_or(""), + hook.id + ) + .as_bytes(), + ); + } + } + } + + Ok((code, output)) +} + +/// Prints all arguments passed to the hook. Useful for debugging. +pub fn identity( + _hook: &Hook, + filenames: &[&String], + _env_vars: Arc>, +) -> (i32, Vec) { + (0, filenames.iter().join("\n").into_bytes()) +} diff --git a/src/builtin/mod.rs b/src/builtin/mod.rs new file mode 100644 index 0000000..906ef78 --- /dev/null +++ b/src/builtin/mod.rs @@ -0,0 +1,40 @@ +use crate::hook::{Hook, Repo}; +use std::collections::HashMap; +use std::sync::Arc; + +mod meta_hooks; + +/// Returns true if the hook has a builtin Rust implementation. +pub fn check_fast_path(hook: &Hook) -> bool { + if matches!(hook.repo(), Repo::Meta { .. }) { + return true; + }; + + false +} + +pub async fn run_fast_path( + hook: &Hook, + filenames: &[&String], + env_vars: Arc>, +) -> anyhow::Result<(i32, Vec)> { + match hook.repo() { + Repo::Meta { .. } => run_meta_hook(hook, filenames, env_vars).await, + _ => unreachable!(), + } +} + +async fn run_meta_hook( + hook: &Hook, + filenames: &[&String], + env_vars: Arc>, +) -> anyhow::Result<(i32, Vec)> { + match hook.id.as_str() { + "check-hooks-apply" => meta_hooks::check_hooks_apply(hook, filenames, env_vars).await, + "check-useless-excludes" => { + meta_hooks::check_useless_excludes(hook, filenames, env_vars).await + } + "identity" => Ok(meta_hooks::identity(hook, filenames, env_vars)), + _ => unreachable!(), + } +} diff --git a/src/cli/install.rs b/src/cli/install.rs index 0481718..e4fc109 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -66,7 +66,7 @@ pub(crate) async fn install( let _lock = store.lock_async().await?; let reporter = HookInitReporter::from(printer); - let hooks = project.init_hooks(&store, &reporter).await?; + let hooks = project.init_hooks(&store, Some(&reporter)).await?; let reporter = HookInstallReporter::from(printer); run::install_hooks(&hooks, &reporter).await?; } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bcb2484..15d8d76 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,7 +12,7 @@ mod clean; mod hook_impl; mod install; mod reporter; -mod run; +pub mod run; mod sample_config; mod self_update; mod validate; diff --git a/src/cli/run/filter.rs b/src/cli/run/filter.rs new file mode 100644 index 0000000..ffb9df2 --- /dev/null +++ b/src/cli/run/filter.rs @@ -0,0 +1,259 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use fancy_regex as regex; +use fancy_regex::Regex; +use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; +use tracing::{debug, error}; + +use crate::config::Stage; +use crate::fs::normalize_path; +use crate::git; +use crate::hook::Hook; +use crate::identify::tags_from_path; + +/// Filter filenames by include/exclude patterns. +pub struct FilenameFilter { + include: Option, + exclude: Option, +} + +impl FilenameFilter { + pub fn new(include: Option<&str>, exclude: Option<&str>) -> Result> { + let include = include.map(Regex::new).transpose()?; + let exclude = exclude.map(Regex::new).transpose()?; + Ok(Self { include, exclude }) + } + + pub fn filter(&self, filename: impl AsRef) -> bool { + let filename = filename.as_ref(); + if let Some(re) = &self.include { + if !re.is_match(filename).unwrap_or(false) { + return false; + } + } + if let Some(re) = &self.exclude { + if re.is_match(filename).unwrap_or(false) { + return false; + } + } + true + } + + pub fn from_hook(hook: &Hook) -> Result> { + Self::new(hook.files.as_deref(), hook.exclude.as_deref()) + } +} + +/// Filter files by tags. +struct FileTagFilter<'a> { + all: &'a [String], + any: &'a [String], + exclude: &'a [String], +} + +impl<'a> FileTagFilter<'a> { + fn new(types: &'a [String], types_or: &'a [String], exclude_types: &'a [String]) -> Self { + Self { + all: types, + any: types_or, + exclude: exclude_types, + } + } + + fn filter(&self, file_types: &[&str]) -> bool { + if !self.all.is_empty() && !self.all.iter().all(|t| file_types.contains(&t.as_str())) { + return false; + } + if !self.any.is_empty() && !self.any.iter().any(|t| file_types.contains(&t.as_str())) { + return false; + } + if self + .exclude + .iter() + .any(|t| file_types.contains(&t.as_str())) + { + return false; + } + true + } + + fn from_hook(hook: &'a Hook) -> Self { + Self::new(&hook.types, &hook.types_or, &hook.exclude_types) + } +} + +pub struct FileFilter<'a> { + filenames: Vec<&'a String>, +} + +impl<'a> FileFilter<'a> { + pub fn new( + filenames: &'a [String], + include: Option<&str>, + exclude: Option<&str>, + ) -> Result> { + let filter = FilenameFilter::new(include, exclude)?; + + let filenames = filenames + .into_par_iter() + .filter(|filename| filter.filter(filename)) + .filter(|filename| { + // TODO: does this check really necessary? + // Ignore not existing files. + std::fs::symlink_metadata(filename) + .map(|m| m.file_type().is_file()) + .unwrap_or(false) + }) + .collect::>(); + + Ok(Self { filenames }) + } + + pub fn len(&self) -> usize { + self.filenames.len() + } + + pub fn by_tag(&self, hook: &Hook) -> Vec<&String> { + let filter = FileTagFilter::from_hook(hook); + let filenames: Vec<_> = self + .filenames + .par_iter() + .filter(|filename| { + let path = Path::new(filename); + match tags_from_path(path) { + Ok(tags) => filter.filter(&tags), + Err(err) => { + error!(filename, error = %err, "Failed to get tags"); + false + } + } + }) + .copied() + .collect(); + + filenames + } + + pub fn for_hook(&self, hook: &Hook) -> Result, Box> { + let filter = FilenameFilter::from_hook(hook)?; + let filenames = self + .filenames + .par_iter() + .filter(|filename| filter.filter(filename)); + + let filter = FileTagFilter::from_hook(hook); + let filenames: Vec<_> = filenames + .filter(|filename| { + let path = Path::new(filename); + match tags_from_path(path) { + Ok(tags) => filter.filter(&tags), + Err(err) => { + error!(filename, error = %err, "Failed to get tags"); + false + } + } + }) + .copied() + .collect(); + + Ok(filenames) + } +} + +#[derive(Default)] +pub struct FileOptions { + pub hook_stage: Option, + pub from_ref: Option, + pub to_ref: Option, + pub all_files: bool, + pub files: Vec, + pub commit_msg_filename: Option, +} + +impl FileOptions { + pub fn with_all_files(mut self, all_files: bool) -> Self { + self.all_files = all_files; + self + } +} + +/// Get all filenames to run hooks on. +#[allow(clippy::too_many_arguments)] +pub async fn get_filenames(opts: FileOptions) -> Result> { + let FileOptions { + hook_stage, + from_ref, + to_ref, + all_files, + files, + commit_msg_filename, + } = opts; + + let mut filenames = filenames_for_args( + hook_stage, + from_ref, + to_ref, + all_files, + files, + commit_msg_filename, + ) + .await?; + + for filename in &mut filenames { + normalize_path(filename); + } + Ok(filenames) +} + +#[allow(clippy::too_many_arguments)] +async fn filenames_for_args( + hook_stage: Option, + from_ref: Option, + to_ref: Option, + all_files: bool, + files: Vec, + commit_msg_filename: Option, +) -> Result> { + if hook_stage.is_some_and(|stage| !stage.operate_on_files()) { + return Ok(vec![]); + } + if hook_stage.is_some_and(|stage| matches!(stage, Stage::PrepareCommitMsg | Stage::CommitMsg)) { + return Ok(vec![commit_msg_filename + .unwrap() + .to_string_lossy() + .to_string()]); + } + if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) { + let files = git::get_changed_files(&from_ref, &to_ref).await?; + debug!( + "Files changed between {} and {}: {}", + from_ref, + to_ref, + files.len() + ); + return Ok(files); + } + + if !files.is_empty() { + let files: Vec<_> = files + .into_iter() + .map(|f| f.to_string_lossy().to_string()) + .collect(); + debug!("Files passed as arguments: {}", files.len()); + return Ok(files); + } + if all_files { + let files = git::get_all_files().await?; + debug!("All files in the repo: {}", files.len()); + return Ok(files); + } + if git::is_in_merge_conflict().await? { + let files = git::get_conflicted_files().await?; + debug!("Conflicted files: {}", files.len()); + return Ok(files); + } + let files = git::get_staged_files().await?; + debug!("Staged files: {}", files.len()); + Ok(files) +} diff --git a/src/cli/run/keeper.rs b/src/cli/run/keeper.rs new file mode 100644 index 0000000..22e76c2 --- /dev/null +++ b/src/cli/run/keeper.rs @@ -0,0 +1,249 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Mutex; + +use anstream::eprintln; +use anyhow::Result; +use owo_colors::OwoColorize; +use tracing::{error, trace}; + +use crate::cleanup::add_cleanup; +use crate::fs::Simplified; +use crate::git::{self, git_cmd, GIT}; +use crate::store::Store; + +static RESTORE_WORKTREE: Mutex> = Mutex::new(None); + +struct IntentToAddKeeper(Vec); +struct WorkingTreeKeeper(Option); + +impl IntentToAddKeeper { + async fn clean() -> Result { + let files = git::intent_to_add_files().await?; + if files.is_empty() { + return Ok(Self(vec![])); + } + + // TODO: xargs + git_cmd("git rm")? + .arg("rm") + .arg("--cached") + .arg("--") + .args(&files) + .check(true) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + + Ok(Self(files.into_iter().map(PathBuf::from).collect())) + } + + fn restore(&self) -> Result<()> { + // Restore the intent-to-add changes. + if !self.0.is_empty() { + Command::new(GIT.as_ref()?) + .arg("add") + .arg("--intent-to-add") + .arg("--") + // TODO: xargs + .args(&self.0) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + } + Ok(()) + } +} + +impl Drop for IntentToAddKeeper { + fn drop(&mut self) { + if let Err(err) = self.restore() { + anstream::eprintln!( + "{}", + format!("Failed to restore intent-to-add changes: {err}").red() + ); + } + } +} + +impl WorkingTreeKeeper { + async fn clean(patch_dir: &Path) -> Result { + let tree = git::write_tree().await?; + + let mut cmd = git_cmd("git diff-index")?; + let output = cmd + .arg("diff-index") + .arg("--ignore-submodules") + .arg("--binary") + .arg("--exit-code") + .arg("--no-color") + .arg("--no-ext-diff") + .arg(tree) + .arg("--") + .check(false) + .output() + .await?; + + if output.status.success() { + trace!("No non-staged changes detected"); + // No non-staged changes + Ok(Self(None)) + } else if output.status.code() == Some(1) { + if output.stdout.trim_ascii().is_empty() { + trace!("diff-index status code 1 with empty stdout"); + // probably git auto crlf behavior quirks + Ok(Self(None)) + } else { + let now = std::time::SystemTime::now(); + let pid = std::process::id(); + let patch_name = format!( + "{}-{}.patch", + now.duration_since(std::time::UNIX_EPOCH)?.as_millis(), + pid + ); + let patch_path = patch_dir.join(&patch_name); + + anstream::eprintln!( + "{}", + format!( + "Non-staged changes detected, saving to `{}`", + patch_path.user_display() + ) + .yellow() + ); + fs_err::create_dir_all(patch_dir)?; + fs_err::write(&patch_path, output.stdout)?; + + // Clean the working tree + Self::checkout_working_tree()?; + + Ok(Self(Some(patch_path))) + } + } else { + Err(cmd.check_status(output.status).unwrap_err().into()) + } + } + + fn checkout_working_tree() -> Result<()> { + let status = Command::new(GIT.as_ref()?) + .arg("-c") + .arg("submodule.recurse=0") + .arg("checkout") + .arg("--") + .arg(".") + // prevent recursive post-checkout hooks + .env("_PRE_COMMIT_SKIP_POST_CHECKOUT", "1") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to checkout working tree")) + } + } + + fn git_apply(patch: &Path) -> Result<()> { + let status = Command::new(GIT.as_ref()?) + .arg("apply") + .arg("--whitespace=nowarn") + .arg(patch) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to apply the patch")) + } + } + + fn restore(&self) -> Result<()> { + let Some(patch) = self.0.as_ref() else { + return Ok(()); + }; + + // Try to apply the patch + if Self::git_apply(patch).is_err() { + error!("Failed to apply the patch, rolling back changes"); + eprintln!( + "{}", + "Failed to apply the patch, rolling back changes".red() + ); + + Self::checkout_working_tree()?; + Self::git_apply(patch)?; + }; + + eprintln!( + "{}", + format!( + "\nRestored working tree changes from `{}`", + patch.user_display() + ) + .yellow() + ); + + Ok(()) + } +} + +impl Drop for WorkingTreeKeeper { + fn drop(&mut self) { + if let Err(err) = self.restore() { + eprintln!( + "{}", + format!("Failed to restore working tree changes: {err}").red() + ); + } + } +} + +/// Clean Git intent-to-add files and working tree changes, and restore them when dropped. +pub struct WorkTreeKeeper { + intent_to_add: Option, + working_tree: Option, +} + +#[derive(Default)] +pub struct RestoreGuard { + _guard: (), +} + +impl Drop for RestoreGuard { + fn drop(&mut self) { + if let Some(mut keeper) = RESTORE_WORKTREE.lock().unwrap().take() { + keeper.restore(); + } + } +} + +impl WorkTreeKeeper { + /// Clear intent-to-add changes from the index and clear the non-staged changes from the working directory. + /// Restore them when the instance is dropped. + pub async fn clean(store: &Store) -> Result { + let cleaner = Self { + intent_to_add: Some(IntentToAddKeeper::clean().await?), + working_tree: Some(WorkingTreeKeeper::clean(store.path()).await?), + }; + + // Set to the global for the cleanup hook. + *RESTORE_WORKTREE.lock().unwrap() = Some(cleaner); + + // Make sure restoration when ctrl-c is pressed. + add_cleanup(|| { + if let Some(guard) = &mut *RESTORE_WORKTREE.lock().unwrap() { + guard.restore(); + } + }); + + Ok(RestoreGuard::default()) + } + + /// Restore the intent-to-add changes and non-staged changes. + fn restore(&mut self) { + self.intent_to_add.take(); + self.working_tree.take(); + } +} diff --git a/src/cli/run/mod.rs b/src/cli/run/mod.rs new file mode 100644 index 0000000..c3538e0 --- /dev/null +++ b/src/cli/run/mod.rs @@ -0,0 +1,7 @@ +pub use filter::{get_filenames, FileFilter, FileOptions}; +pub(crate) use run::{install_hooks, run}; + +mod filter; +mod keeper; +#[allow(clippy::module_inception)] +mod run; diff --git a/src/cli/run.rs b/src/cli/run/run.rs similarity index 54% rename from src/cli/run.rs rename to src/cli/run/run.rs index 9ba0bf3..f1d721d 100644 --- a/src/cli/run.rs +++ b/src/cli/run/run.rs @@ -1,23 +1,31 @@ +use std::cmp::max; use std::collections::HashMap; -use std::fmt::Write; +use std::fmt::Write as _; +use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use anstream::ColorChoice; use anyhow::Result; use futures::stream::FuturesUnordered; use futures::StreamExt; use itertools::Itertools; -use owo_colors::OwoColorize; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use owo_colors::{OwoColorize, Style}; +use rand::prelude::{SliceRandom, StdRng}; +use rand::SeedableRng; use tracing::{debug, trace}; +use unicode_width::UnicodeWidthStr; use crate::cli::reporter::{HookInitReporter, HookInstallReporter}; +use crate::cli::run::keeper::WorkTreeKeeper; +use crate::cli::run::{get_filenames, FileFilter, FileOptions}; use crate::cli::{ExitStatus, RunExtraArgs}; use crate::config::Stage; -use crate::fs::{normalize_path, Simplified}; +use crate::fs::Simplified; use crate::git; +use crate::git::{get_diff, git_cmd}; use crate::hook::{Hook, Project}; use crate::printer::Printer; -use crate::run::{run_hooks, FilenameFilter, WorkTreeKeeper}; use crate::store::Store; #[allow(clippy::too_many_arguments)] @@ -71,7 +79,7 @@ pub(crate) async fn run( let reporter = HookInitReporter::from(printer); let lock = store.lock_async().await?; - let hooks = project.init_hooks(&store, &reporter).await?; + let hooks = project.init_hooks(&store, Some(&reporter)).await?; let hooks: Vec<_> = hooks .into_iter() @@ -130,40 +138,27 @@ pub(crate) async fn run( _guard = Some(WorkTreeKeeper::clean(&store).await?); } - let mut filenames = all_filenames( + let filenames = get_filenames(FileOptions { hook_stage, from_ref, to_ref, all_files, files, - extra_args.commit_msg_filename.as_ref(), - ) + commit_msg_filename: extra_args.commit_msg_filename.clone(), + }) .await?; - for filename in &mut filenames { - normalize_path(filename); - } - let filter = FilenameFilter::new( + let filter = FileFilter::new( + &filenames, project.config().files.as_deref(), project.config().exclude.as_deref(), )?; - let filenames = filenames - .into_par_iter() - .filter(|filename| filter.filter(filename)) - .filter(|filename| { - // Ignore not existing files. - std::fs::symlink_metadata(filename) - .map(|m| m.file_type().is_file()) - .unwrap_or(false) - }) - .collect::>(); - - trace!("Files after filtered: {}", filenames.len()); + trace!("Files after filtered: {}", filter.len()); run_hooks( &hooks, &skips, - filenames, + &filter, env_vars, project.config().fail_fast.unwrap_or(false), show_diff_on_failure, @@ -253,59 +248,6 @@ fn get_skips() -> Vec { } } -/// Get all filenames to run hooks on. -#[allow(clippy::too_many_arguments)] -async fn all_filenames( - hook_stage: Option, - from_ref: Option, - to_ref: Option, - all_files: bool, - files: Vec, - commit_msg_filename: Option<&PathBuf>, -) -> Result> { - if hook_stage.is_some_and(|stage| !stage.operate_on_files()) { - return Ok(vec![]); - } - if hook_stage.is_some_and(|stage| matches!(stage, Stage::PrepareCommitMsg | Stage::CommitMsg)) { - return Ok(vec![commit_msg_filename - .unwrap() - .to_string_lossy() - .to_string()]); - } - if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) { - let files = git::get_changed_files(&from_ref, &to_ref).await?; - debug!( - "Files changed between {} and {}: {}", - from_ref, - to_ref, - files.len() - ); - return Ok(files); - } - - if !files.is_empty() { - let files: Vec<_> = files - .into_iter() - .map(|f| f.to_string_lossy().to_string()) - .collect(); - debug!("Files passed as arguments: {}", files.len()); - return Ok(files); - } - if all_files { - let files = git::get_all_files().await?; - debug!("All files in the repo: {}", files.len()); - return Ok(files); - } - if git::is_in_merge_conflict().await? { - let files = git::get_conflicted_files().await?; - debug!("Conflicted files: {}", files.len()); - return Ok(files); - } - let files = git::get_staged_files().await?; - debug!("Staged files: {}", files.len()); - Ok(files) -} - async fn install_hook(hook: &Hook, env_dir: PathBuf) -> Result<()> { debug!(%hook, target = %env_dir.display(), "Install environment"); @@ -348,3 +290,221 @@ pub async fn install_hooks(hooks: &[Hook], reporter: &HookInstallReporter) -> Re Ok(()) } + +const SKIPPED: &str = "Skipped"; +const NO_FILES: &str = "(no files to check)"; + +fn status_line(start: &str, cols: usize, end_msg: &str, end_color: Style, postfix: &str) -> String { + let dots = cols - start.width_cjk() - end_msg.len() - postfix.len() - 1; + format!( + "{}{}{}{}", + start, + ".".repeat(dots), + postfix, + end_msg.style(end_color) + ) +} + +fn calculate_columns(hooks: &[Hook]) -> usize { + let name_len = hooks + .iter() + .map(|hook| hook.name.width_cjk()) + .max() + .unwrap_or(0); + max(80, name_len + 3 + NO_FILES.len() + 1 + SKIPPED.len()) +} + +/// Run all hooks. +pub async fn run_hooks( + hooks: &[Hook], + skips: &[String], + filter: &FileFilter<'_>, + env_vars: HashMap<&'static str, String>, + fail_fast: bool, + show_diff_on_failure: bool, + verbose: bool, + printer: Printer, +) -> Result { + let env_vars = Arc::new(env_vars); + + let columns = calculate_columns(hooks); + let mut success = true; + + let mut diff = get_diff().await?; + // hooks must run in serial + for hook in hooks { + let (hook_success, new_diff) = run_hook( + hook, + filter, + env_vars.clone(), + skips, + diff, + columns, + verbose, + printer, + ) + .await?; + + success &= hook_success; + diff = new_diff; + if !success && (fail_fast || hook.fail_fast) { + break; + } + } + + if !success && show_diff_on_failure { + writeln!(printer.stdout(), "All changes made by hooks:")?; + let color = match ColorChoice::global() { + ColorChoice::Auto => "--color=auto", + ColorChoice::Always | ColorChoice::AlwaysAnsi => "--color=always", + ColorChoice::Never => "--color=never", + }; + git_cmd("git diff")? + .arg("--no-pager") + .arg("diff") + .arg("--no-ext-diff") + .arg(color) + .check(true) + .spawn()? + .wait() + .await?; + }; + + if success { + Ok(ExitStatus::Success) + } else { + Ok(ExitStatus::Failure) + } +} + +/// Shuffle the files so that they more evenly fill out the xargs +/// partitions, but do it deterministically in case a hook cares about ordering. +fn shuffle(filenames: &mut [T]) { + const SEED: u64 = 1_542_676_187; + let mut rng = StdRng::seed_from_u64(SEED); + filenames.shuffle(&mut rng); +} + +async fn run_hook( + hook: &Hook, + filter: &FileFilter<'_>, + env_vars: Arc>, + skips: &[String], + diff: Vec, + columns: usize, + verbose: bool, + printer: Printer, +) -> Result<(bool, Vec)> { + if skips.contains(&hook.id) || skips.contains(&hook.alias) { + writeln!( + printer.stdout(), + "{}", + status_line( + &hook.name, + columns, + SKIPPED, + Style::new().black().on_yellow(), + "", + ) + )?; + return Ok((true, diff)); + } + + let mut filenames = filter.for_hook(hook)?; + + if filenames.is_empty() && !hook.always_run { + writeln!( + printer.stdout(), + "{}", + status_line( + &hook.name, + columns, + SKIPPED, + Style::new().black().on_cyan(), + NO_FILES, + ) + )?; + return Ok((true, diff)); + } + + write!( + printer.stdout(), + "{}{}", + &hook.name, + ".".repeat(columns - hook.name.width_cjk() - 6 - 1) + )?; + std::io::stdout().flush()?; + + let start = std::time::Instant::now(); + + let (status, output) = if hook.pass_filenames { + shuffle(&mut filenames); + hook.language.run(hook, &filenames, env_vars).await? + } else { + hook.language.run(hook, &[], env_vars).await? + }; + + let duration = start.elapsed(); + + let new_diff = get_diff().await?; + let file_modified = diff != new_diff; + let success = status == 0 && !file_modified; + + if success { + writeln!(printer.stdout(), "{}", "Passed".on_green())?; + } else { + writeln!(printer.stdout(), "{}", "Failed".on_red())?; + } + + if verbose || hook.verbose || !success { + writeln!( + printer.stdout(), + "{}", + format!("- hook id: {}", hook.id).dimmed() + )?; + if verbose || hook.verbose { + writeln!( + printer.stdout(), + "{}", + format!("- duration: {:.2?}s", duration.as_secs_f64()).dimmed() + )?; + } + if status != 0 { + writeln!( + printer.stdout(), + "{}", + format!("- exit code: {status}").dimmed() + )?; + } + if file_modified { + writeln!( + printer.stdout(), + "{}", + "- files were modified by this hook".dimmed() + )?; + } + + // To be consistent with pre-commit, merge stderr into stdout. + let stdout = output.trim_ascii(); + if !stdout.is_empty() { + if let Some(file) = hook.log_file.as_deref() { + fs_err::OpenOptions::new() + .create(true) + .append(true) + .open(file) + .and_then(|mut f| { + f.write_all(stdout)?; + Ok(()) + })?; + } else { + writeln!( + printer.stdout(), + "{}", + textwrap::indent(&String::from_utf8_lossy(stdout), " ").dimmed() + )?; + }; + } + } + + Ok((success, new_diff)) +} diff --git a/src/config.rs b/src/config.rs index bc7cfc9..0e081b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,7 +127,6 @@ impl Display for HookType { } } -// TODO: warn on deprecated stages #[derive(Debug, Clone, Copy, PartialEq, Deserialize, clap::ValueEnum)] #[serde(rename_all = "kebab-case")] pub enum Stage { @@ -265,7 +264,7 @@ impl RepoLocation { match self { RepoLocation::Local => "local", RepoLocation::Meta => "meta", - RepoLocation::Remote(_) => "remote", + RepoLocation::Remote(url) => url.as_str(), } } } @@ -392,7 +391,7 @@ pub type ConfigLocalHook = ManifestHook; pub enum MetaHookID { CheckHooksApply, CheckUselessExcludes, - Identify, + Identity, } impl Display for MetaHookID { @@ -400,7 +399,7 @@ impl Display for MetaHookID { let name = match self { MetaHookID::CheckHooksApply => "check-hooks-apply", MetaHookID::CheckUselessExcludes => "check-useless-excludes", - MetaHookID::Identify => "identify", + MetaHookID::Identity => "identity", }; f.write_str(name) } @@ -413,7 +412,7 @@ impl FromStr for MetaHookID { match s { "check-hooks-apply" => Ok(MetaHookID::CheckHooksApply), "check-useless-excludes" => Ok(MetaHookID::CheckUselessExcludes), - "identify" => Ok(MetaHookID::Identify), + "identity" => Ok(MetaHookID::Identity), _ => Err(()), } } @@ -447,30 +446,30 @@ impl<'de> Deserialize<'de> for ConfigMetaHook { let mut defaults = match id { MetaHookID::CheckHooksApply => ManifestHook { - id: "check-hooks-apply".to_string(), + id: MetaHookID::CheckHooksApply.to_string(), name: "Check hooks apply".to_string(), language: Language::System, - entry: "a".to_string(), // TODO: direct call to the hook + entry: String::new(), options: HookOptions { files: Some(format!("^{}$", regex::escape(CONFIG_FILE))), ..Default::default() }, }, MetaHookID::CheckUselessExcludes => ManifestHook { - id: "check-useless-excludes".to_string(), + id: MetaHookID::CheckUselessExcludes.to_string(), name: "Check useless excludes".to_string(), language: Language::System, - entry: "a".to_string(), + entry: String::new(), options: HookOptions { files: Some(format!("^{}$", regex::escape(CONFIG_FILE))), ..Default::default() }, }, - MetaHookID::Identify => ManifestHook { - id: "identify".to_string(), - name: "identify".to_string(), + MetaHookID::Identity => ManifestHook { + id: MetaHookID::Identity.to_string(), + name: "identity".to_string(), language: Language::System, - entry: "a".to_string(), + entry: String::new(), options: HookOptions { verbose: Some(true), ..Default::default() @@ -520,7 +519,6 @@ impl Display for ConfigRemoteRepo { #[derive(Debug, Clone)] pub struct ConfigLocalRepo { - pub repo: String, pub hooks: Vec, } @@ -532,7 +530,6 @@ impl Display for ConfigLocalRepo { #[derive(Debug, Clone)] pub struct ConfigMetaRepo { - pub repo: String, pub hooks: Vec, } @@ -587,10 +584,7 @@ impl<'de> Deserialize<'de> for ConfigRepo { } let LocalRepo { hooks } = LocalRepo::deserialize(rest) .map_err(|e| serde::de::Error::custom(format!("Invalid local repo: {e}")))?; - Ok(ConfigRepo::Local(ConfigLocalRepo { - repo: "local".to_string(), - hooks, - })) + Ok(ConfigRepo::Local(ConfigLocalRepo { hooks })) } RepoLocation::Meta => { #[derive(Deserialize)] @@ -600,17 +594,12 @@ impl<'de> Deserialize<'de> for ConfigRepo { } let MetaRepo { hooks } = MetaRepo::deserialize(rest) .map_err(|e| serde::de::Error::custom(format!("Invalid meta repo: {e}")))?; - Ok(ConfigRepo::Meta(ConfigMetaRepo { - repo: "meta".to_string(), - hooks, - })) + Ok(ConfigRepo::Meta(ConfigMetaRepo { hooks })) } } } } -// TODO: check minimum_pre_commit_version - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] pub struct ManifestHook { @@ -694,7 +683,6 @@ mod tests { repos: [ Local( ConfigLocalRepo { - repo: "local", hooks: [ ManifestHook { id: "cargo-fmt", @@ -895,7 +883,6 @@ mod tests { repos: [ Local( ConfigLocalRepo { - repo: "local", hooks: [ ManifestHook { id: "cargo-fmt", @@ -1009,7 +996,7 @@ mod tests { hooks: - id: check-hooks-apply - id: check-useless-excludes - - id: identify + - id: identity "}; let result = serde_yaml::from_str::(yaml); insta::assert_debug_snapshot!(result, @r###" @@ -1018,13 +1005,12 @@ mod tests { repos: [ Meta( ConfigMetaRepo { - repo: "meta", hooks: [ ConfigMetaHook( ManifestHook { id: "check-hooks-apply", name: "Check hooks apply", - entry: "a", + entry: "", language: System, options: HookOptions { alias: None, @@ -1054,7 +1040,7 @@ mod tests { ManifestHook { id: "check-useless-excludes", name: "Check useless excludes", - entry: "a", + entry: "", language: System, options: HookOptions { alias: None, @@ -1082,9 +1068,9 @@ mod tests { ), ConfigMetaHook( ManifestHook { - id: "identify", - name: "identify", - entry: "a", + id: "identity", + name: "identity", + entry: "", language: System, options: HookOptions { alias: None, diff --git a/src/hook.rs b/src/hook.rs index 9ad5ebb..632d508 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -160,7 +160,7 @@ impl Project { async fn init_repos( &mut self, store: &Store, - reporter: &dyn HookInitReporter, + reporter: Option<&dyn HookInitReporter>, ) -> Result<(), Error> { let mut tasks = FuturesUnordered::new(); let mut seen = HashSet::new(); @@ -170,9 +170,14 @@ impl Project { continue; } tasks.push(async move { - let progress = reporter.on_clone_start(&format!("{repo}")); + let progress = reporter + .map(|reporter| (reporter, reporter.on_clone_start(&format!("{repo}")))); + let path = store.prepare_remote_repo(repo, &[]).await; - reporter.on_clone_complete(progress); + + if let Some((reporter, progress)) = progress { + reporter.on_clone_complete(progress); + } (repo, path) }); @@ -217,7 +222,7 @@ impl Project { pub async fn init_hooks( &mut self, store: &Store, - reporter: &dyn HookInitReporter, + reporter: Option<&dyn HookInitReporter>, ) -> Result, Error> { self.init_repos(store, reporter).await?; @@ -296,7 +301,7 @@ impl Project { } } - reporter.on_complete(); + reporter.map(HookInitReporter::on_complete); Ok(hooks) } diff --git a/src/languages/mod.rs b/src/languages/mod.rs index ddb2075..4517556 100644 --- a/src/languages/mod.rs +++ b/src/languages/mod.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; -use anyhow::Result; - +use crate::builtin; use crate::config::Language; use crate::hook::Hook; +use anyhow::Result; mod docker; mod docker_image; @@ -90,6 +90,11 @@ impl Language { filenames: &[&String], env_vars: Arc>, ) -> Result<(i32, Vec)> { + // fast path for hooks implemented in Rust + if builtin::check_fast_path(hook) { + return builtin::run_fast_path(hook, filenames, env_vars).await; + } + match self { Self::Python => PYTHON.run(hook, filenames, env_vars).await, Self::Node => NODE.run(hook, filenames, env_vars).await, diff --git a/src/main.rs b/src/main.rs index a76ba95..1b01c07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use crate::cli::{Cli, Command, ExitStatus, SelfCommand, SelfNamespace, SelfUpdat use crate::git::get_root; use crate::printer::Printer; +mod builtin; mod cleanup; mod cli; mod config; diff --git a/src/run.rs b/src/run.rs index ae99059..a808c5f 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,338 +1,11 @@ use std::cmp::max; -use std::collections::HashMap; -use std::fmt::Write as _; use std::future::Future; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use anstream::{eprintln, ColorChoice}; -use anyhow::Result; -use fancy_regex::{self as regex, Regex}; -use owo_colors::{OwoColorize, Style}; -use rand::prelude::{SliceRandom, StdRng}; -use rand::SeedableRng; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; use tokio::task::JoinSet; -use tracing::{error, trace}; -use unicode_width::UnicodeWidthStr; +use tracing::trace; -use crate::cleanup::add_cleanup; -use crate::cli::ExitStatus; -use crate::fs::Simplified; -use crate::git; -use crate::git::{get_diff, git_cmd, GIT}; use crate::hook::Hook; -use crate::identify::tags_from_path; -use crate::printer::Printer; -use crate::store::Store; - -const SKIPPED: &str = "Skipped"; -const NO_FILES: &str = "(no files to check)"; - -/// Filter filenames by include/exclude patterns. -pub struct FilenameFilter { - include: Option, - exclude: Option, -} - -impl FilenameFilter { - pub fn new(include: Option<&str>, exclude: Option<&str>) -> Result> { - let include = include.map(Regex::new).transpose()?; - let exclude = exclude.map(Regex::new).transpose()?; - Ok(Self { include, exclude }) - } - - pub fn filter(&self, filename: impl AsRef) -> bool { - let filename = filename.as_ref(); - if let Some(re) = &self.include { - if !re.is_match(filename).unwrap_or(false) { - return false; - } - } - if let Some(re) = &self.exclude { - if re.is_match(filename).unwrap_or(false) { - return false; - } - } - true - } - - pub fn from_hook(hook: &Hook) -> Result> { - Self::new(hook.files.as_deref(), hook.exclude.as_deref()) - } -} - -/// Filter files by tags. -struct FileTagFilter<'a> { - all: &'a [String], - any: &'a [String], - exclude: &'a [String], -} - -impl<'a> FileTagFilter<'a> { - fn new(types: &'a [String], types_or: &'a [String], exclude_types: &'a [String]) -> Self { - Self { - all: types, - any: types_or, - exclude: exclude_types, - } - } - - fn filter(&self, file_types: &[&str]) -> bool { - if !self.all.is_empty() && !self.all.iter().all(|t| file_types.contains(&t.as_str())) { - return false; - } - if !self.any.is_empty() && !self.any.iter().any(|t| file_types.contains(&t.as_str())) { - return false; - } - if self - .exclude - .iter() - .any(|t| file_types.contains(&t.as_str())) - { - return false; - } - true - } - - fn from_hook(hook: &'a Hook) -> Self { - Self::new(&hook.types, &hook.types_or, &hook.exclude_types) - } -} - -fn status_line(start: &str, cols: usize, end_msg: &str, end_color: Style, postfix: &str) -> String { - let dots = cols - start.width_cjk() - end_msg.len() - postfix.len() - 1; - format!( - "{}{}{}{}", - start, - ".".repeat(dots), - postfix, - end_msg.style(end_color) - ) -} - -fn calculate_columns(hooks: &[Hook]) -> usize { - let name_len = hooks - .iter() - .map(|hook| hook.name.width_cjk()) - .max() - .unwrap_or(0); - max(80, name_len + 3 + NO_FILES.len() + 1 + SKIPPED.len()) -} - -/// Run all hooks. -pub async fn run_hooks( - hooks: &[Hook], - skips: &[String], - filenames: Vec, - env_vars: HashMap<&'static str, String>, - fail_fast: bool, - show_diff_on_failure: bool, - verbose: bool, - printer: Printer, -) -> Result { - let env_vars = Arc::new(env_vars); - - let columns = calculate_columns(hooks); - let mut success = true; - - let mut diff = get_diff().await?; - // hooks must run in serial - for hook in hooks { - let (hook_success, new_diff) = run_hook( - hook, - &filenames, - env_vars.clone(), - skips, - diff, - columns, - verbose, - printer, - ) - .await?; - - success &= hook_success; - diff = new_diff; - if !success && (fail_fast || hook.fail_fast) { - break; - } - } - - if !success && show_diff_on_failure { - writeln!(printer.stdout(), "All changes made by hooks:")?; - let color = match ColorChoice::global() { - ColorChoice::Auto => "--color=auto", - ColorChoice::Always | ColorChoice::AlwaysAnsi => "--color=always", - ColorChoice::Never => "--color=never", - }; - git_cmd("git diff")? - .arg("--no-pager") - .arg("diff") - .arg("--no-ext-diff") - .arg(color) - .check(true) - .spawn()? - .wait() - .await?; - }; - - if success { - Ok(ExitStatus::Success) - } else { - Ok(ExitStatus::Failure) - } -} - -/// Shuffle the files so that they more evenly fill out the xargs -/// partitions, but do it deterministically in case a hook cares about ordering. -fn shuffle(filenames: &mut [T]) { - const SEED: u64 = 1_542_676_187; - let mut rng = StdRng::seed_from_u64(SEED); - filenames.shuffle(&mut rng); -} - -async fn run_hook( - hook: &Hook, - filenames: &[String], - env_vars: Arc>, - skips: &[String], - diff: Vec, - columns: usize, - verbose: bool, - printer: Printer, -) -> Result<(bool, Vec)> { - if skips.contains(&hook.id) || skips.contains(&hook.alias) { - writeln!( - printer.stdout(), - "{}", - status_line( - &hook.name, - columns, - SKIPPED, - Style::new().black().on_yellow(), - "", - ) - )?; - return Ok((true, diff)); - } - - let filter = FilenameFilter::from_hook(hook)?; - let filenames = filenames - .into_par_iter() - .filter(|filename| filter.filter(filename)); - - let filter = FileTagFilter::from_hook(hook); - let mut filenames: Vec<_> = filenames - .filter(|filename| { - let path = Path::new(filename); - match tags_from_path(path) { - Ok(tags) => filter.filter(&tags), - Err(err) => { - error!(filename, error = %err, "Failed to get tags"); - false - } - } - }) - .collect(); - - if filenames.is_empty() && !hook.always_run { - writeln!( - printer.stdout(), - "{}", - status_line( - &hook.name, - columns, - SKIPPED, - Style::new().black().on_cyan(), - NO_FILES, - ) - )?; - return Ok((true, diff)); - } - - write!( - printer.stdout(), - "{}{}", - &hook.name, - ".".repeat(columns - hook.name.width_cjk() - 6 - 1) - )?; - std::io::stdout().flush()?; - - let start = std::time::Instant::now(); - - let (status, output) = if hook.pass_filenames { - shuffle(&mut filenames); - hook.language.run(hook, &filenames, env_vars).await? - } else { - hook.language.run(hook, &[], env_vars).await? - }; - - let duration = start.elapsed(); - - let new_diff = get_diff().await?; - let file_modified = diff != new_diff; - let success = status == 0 && !file_modified; - - if success { - writeln!(printer.stdout(), "{}", "Passed".on_green())?; - } else { - writeln!(printer.stdout(), "{}", "Failed".on_red())?; - } - - if verbose || hook.verbose || !success { - writeln!( - printer.stdout(), - "{}", - format!("- hook id: {}", hook.id).dimmed() - )?; - if verbose || hook.verbose { - writeln!( - printer.stdout(), - "{}", - format!("- duration: {:.2?}s", duration.as_secs_f64()).dimmed() - )?; - } - if status != 0 { - writeln!( - printer.stdout(), - "{}", - format!("- exit code: {status}").dimmed() - )?; - } - if file_modified { - writeln!( - printer.stdout(), - "{}", - "- files were modified by this hook".dimmed() - )?; - } - - // To be consistent with pre-commit, merge stderr into stdout. - let stdout = output.trim_ascii(); - if !stdout.is_empty() { - if let Some(file) = hook.log_file.as_deref() { - fs_err::OpenOptions::new() - .create(true) - .append(true) - .open(file) - .and_then(|mut f| { - f.write_all(stdout)?; - Ok(()) - })?; - } else { - writeln!( - printer.stdout(), - "{}", - textwrap::indent(&String::from_utf8_lossy(stdout), " ").dimmed() - )?; - }; - } - } - - Ok((success, new_diff)) -} fn target_concurrency(serial: bool) -> usize { if serial || std::env::var_os("PRE_COMMIT_NO_CONCURRENCY").is_some() { @@ -388,11 +61,15 @@ fn partitions<'a>( partitions } -pub async fn run_by_batch(hook: &Hook, filenames: &[&String], run: F) -> Result> +pub async fn run_by_batch( + hook: &Hook, + filenames: &[&String], + run: F, +) -> anyhow::Result> where F: Fn(Vec) -> Fut, F: Clone + Send + Sync + 'static, - Fut: Future> + Send + 'static, + Fut: Future> + Send + 'static, T: Send + 'static, { let mut concurrency = target_concurrency(hook.require_serial); @@ -437,239 +114,3 @@ where Ok(results) } - -static RESTORE_WORKTREE: Mutex> = Mutex::new(None); - -struct IntentToAddKeeper(Vec); -struct WorkingTreeKeeper(Option); - -impl IntentToAddKeeper { - async fn clean() -> Result { - let files = git::intent_to_add_files().await?; - if files.is_empty() { - return Ok(Self(vec![])); - } - - // TODO: xargs - git_cmd("git rm")? - .arg("rm") - .arg("--cached") - .arg("--") - .args(&files) - .check(true) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await?; - - Ok(Self(files.into_iter().map(PathBuf::from).collect())) - } - - fn restore(&self) -> Result<()> { - // Restore the intent-to-add changes. - if !self.0.is_empty() { - Command::new(GIT.as_ref()?) - .arg("add") - .arg("--intent-to-add") - .arg("--") - // TODO: xargs - .args(&self.0) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status()?; - } - Ok(()) - } -} - -impl Drop for IntentToAddKeeper { - fn drop(&mut self) { - if let Err(err) = self.restore() { - eprintln!( - "{}", - format!("Failed to restore intent-to-add changes: {err}").red() - ); - } - } -} - -impl WorkingTreeKeeper { - async fn clean(patch_dir: &Path) -> Result { - let tree = git::write_tree().await?; - - let mut cmd = git_cmd("git diff-index")?; - let output = cmd - .arg("diff-index") - .arg("--ignore-submodules") - .arg("--binary") - .arg("--exit-code") - .arg("--no-color") - .arg("--no-ext-diff") - .arg(tree) - .arg("--") - .check(false) - .output() - .await?; - - if output.status.success() { - trace!("No non-staged changes detected"); - // No non-staged changes - Ok(Self(None)) - } else if output.status.code() == Some(1) { - if output.stdout.trim_ascii().is_empty() { - trace!("diff-index status code 1 with empty stdout"); - // probably git auto crlf behavior quirks - Ok(Self(None)) - } else { - let now = std::time::SystemTime::now(); - let pid = std::process::id(); - let patch_name = format!( - "{}-{}.patch", - now.duration_since(std::time::UNIX_EPOCH)?.as_millis(), - pid - ); - let patch_path = patch_dir.join(&patch_name); - - eprintln!( - "{}", - format!( - "Non-staged changes detected, saving to `{}`", - patch_path.user_display() - ) - .yellow() - ); - fs_err::create_dir_all(patch_dir)?; - fs_err::write(&patch_path, output.stdout)?; - - // Clean the working tree - Self::checkout_working_tree()?; - - Ok(Self(Some(patch_path))) - } - } else { - Err(cmd.check_status(output.status).unwrap_err().into()) - } - } - - fn checkout_working_tree() -> Result<()> { - let status = Command::new(GIT.as_ref()?) - .arg("-c") - .arg("submodule.recurse=0") - .arg("checkout") - .arg("--") - .arg(".") - // prevent recursive post-checkout hooks - .env("_PRE_COMMIT_SKIP_POST_CHECKOUT", "1") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status()?; - if status.success() { - Ok(()) - } else { - Err(anyhow::anyhow!("Failed to checkout working tree")) - } - } - - fn git_apply(patch: &Path) -> Result<()> { - let status = Command::new(GIT.as_ref()?) - .arg("apply") - .arg("--whitespace=nowarn") - .arg(patch) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status()?; - if status.success() { - Ok(()) - } else { - Err(anyhow::anyhow!("Failed to apply the patch")) - } - } - - fn restore(&self) -> Result<()> { - let Some(patch) = self.0.as_ref() else { - return Ok(()); - }; - - // Try to apply the patch - if Self::git_apply(patch).is_err() { - error!("Failed to apply the patch, rolling back changes"); - eprintln!( - "{}", - "Failed to apply the patch, rolling back changes".red() - ); - - Self::checkout_working_tree()?; - Self::git_apply(patch)?; - }; - - eprintln!( - "{}", - format!( - "\nRestored working tree changes from `{}`", - patch.user_display() - ) - .yellow() - ); - - Ok(()) - } -} - -impl Drop for WorkingTreeKeeper { - fn drop(&mut self) { - if let Err(err) = self.restore() { - eprintln!( - "{}", - format!("Failed to restore working tree changes: {err}").red() - ); - } - } -} - -/// Clean Git intent-to-add files and working tree changes, and restore them when dropped. -pub struct WorkTreeKeeper { - intent_to_add: Option, - working_tree: Option, -} - -#[derive(Default)] -pub struct RestoreGuard { - _guard: (), -} - -impl Drop for RestoreGuard { - fn drop(&mut self) { - if let Some(mut keeper) = RESTORE_WORKTREE.lock().unwrap().take() { - keeper.restore(); - } - } -} - -impl WorkTreeKeeper { - /// Clear intent-to-add changes from the index and clear the non-staged changes from the working directory. - /// Restore them when the instance is dropped. - pub async fn clean(store: &Store) -> Result { - let cleaner = Self { - intent_to_add: Some(IntentToAddKeeper::clean().await?), - working_tree: Some(WorkingTreeKeeper::clean(store.path()).await?), - }; - - // Set to the global for the cleanup hook. - *RESTORE_WORKTREE.lock().unwrap() = Some(cleaner); - - // Make sure restoration when ctrl-c is pressed. - add_cleanup(|| { - if let Some(guard) = &mut *RESTORE_WORKTREE.lock().unwrap() { - guard.restore(); - } - }); - - Ok(RestoreGuard::default()) - } - - /// Restore the intent-to-add changes and non-staged changes. - fn restore(&mut self) { - self.intent_to_add.take(); - self.working_tree.take(); - } -} diff --git a/src/snapshots/prefligit__config__tests__read_config.snap b/src/snapshots/prefligit__config__tests__read_config.snap index 6d141bf..d530520 100644 --- a/src/snapshots/prefligit__config__tests__read_config.snap +++ b/src/snapshots/prefligit__config__tests__read_config.snap @@ -102,7 +102,6 @@ ConfigWire { ), Local( ConfigLocalRepo { - repo: "local", hooks: [ ManifestHook { id: "cargo-fmt", @@ -141,7 +140,6 @@ ConfigWire { ), Local( ConfigLocalRepo { - repo: "local", hooks: [ ManifestHook { id: "cargo-dev-generate-all", diff --git a/tests/run.rs b/tests/run.rs index 7640102..fa273cf 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -183,6 +183,68 @@ fn local_need_install() { "#); } +#[test] +fn meta_hooks() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.workdir(); + cwd.child("file.txt").write_str("Hello, world!\n")?; + cwd.child("valid.json").write_str("{}")?; + cwd.child("invalid.json").write_str("{}")?; + cwd.child("main.py").write_str(r#"print "abc" "#)?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes + - id: identity + - repo: local + hooks: + - id: match-no-files + name: match no files + language: system + entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' + files: ^nonexistent$ + - id: useless-exclude + name: useless exclude + language: system + entry: python3 -c 'import sys; sys.exit(0)' + exclude: $nonexistent^ + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + Check hooks apply........................................................Failed + - hook id: check-hooks-apply + - exit code: 1 + match-no-files does not apply to this repository + Check useless excludes...................................................Failed + - hook id: check-useless-excludes + - exit code: 1 + The exclude pattern "$nonexistent^" for useless-exclude does not match any files + identity.................................................................Passed + - hook id: identity + - duration: [TIME] + invalid.json + valid.json + main.py + .pre-commit-config.yaml + file.txt + match no files.......................................(no files to check)Skipped + useless exclude..........................................................Passed + + ----- stderr ----- + "#); + + Ok(()) +} + #[test] fn invalid_hook_id() { let context = TestContext::new();