use super::{
cache::{HookFileCache, get_cached_git_repo},
file_discovery::FileDiscoveryMethod,
filters::{FilterParams, apply_file_filters},
};
use crate::{
cli::output,
config::hooks::{HookCommand, HookDefinition, HookScript},
git::GitRepo,
hooks::conditions,
scan::Scanner,
};
use anyhow::{Context, Result, anyhow};
use std::{process::Command, sync::Arc, time::Duration};
#[derive(Debug, Clone)]
struct CommandResult {
name: String,
success: bool,
skipped: bool,
duration: Duration,
output: String, }
#[derive(Default)]
pub struct HookExecutor;
impl HookExecutor {
pub fn new() -> Self {
Self
}
pub async fn execute(&self, hook_name: &str, args: &[String]) -> Result<()> {
let total_start = std::time::Instant::now();
tracing::trace!("🚀 Hook execution starting: {}", hook_name);
use crate::config::CONFIG;
let config_start = std::time::Instant::now();
let hooks_config = &CONFIG.hooks;
tracing::trace!("⚡ Config access time: {:?}", config_start.elapsed());
tracing::trace!(
"Hooks config loaded: skip_all={}, parallel={}",
hooks_config.skip_all,
hooks_config.parallel
);
tracing::trace!(
"Pre-commit commands: {}",
hooks_config.pre_commit.commands.len()
);
if hooks_config.skip_all {
output::info!("All hooks are skipped (skip_all=true)");
return Ok(());
}
let hook = match hook_name {
"pre-commit" => &hooks_config.pre_commit,
"commit-msg" => &hooks_config.commit_msg,
"post-checkout" => &hooks_config.post_checkout,
"pre-push" => &hooks_config.pre_push,
"post-commit" => &hooks_config.post_commit,
"post-merge" => &hooks_config.post_merge,
_ => return Err(anyhow!("Hook '{}' not found in configuration", hook_name)),
};
if hook.skip {
output::info!(&format!("Hook '{hook_name}' is skipped"));
return Ok(());
}
Self::print_banner(hook_name);
tracing::trace!(
"Hook configuration: commands={}, scripts={}",
hook.commands.len(),
hook.scripts.len()
);
for (name, cmd) in &hook.commands {
tracing::trace!("Command '{}': run='{}'", name, cmd.run);
}
let file_cache_start = std::time::Instant::now();
let repo = get_cached_git_repo()?;
let file_cache = Arc::new(HookFileCache::new(repo, hook_name));
file_cache.precompute();
tracing::trace!(
"⚡ File cache initialization time: {:?}",
file_cache_start.elapsed()
);
let collect_start = std::time::Instant::now();
let mut executables = self.collect_executables(hook, hook_name, args, &file_cache)?;
tracing::trace!(
"⚡ Executable collection time: {:?}",
collect_start.elapsed()
);
tracing::trace!("Collected {} executables", executables.len());
for exec in &executables {
tracing::trace!(
"Executable: name='{}', type={:?}, priority={}",
exec.name,
exec.exec_type,
exec.priority
);
}
let sort_start = std::time::Instant::now();
executables.sort_by_key(|e| e.priority);
tracing::trace!("⚡ Sort time: {:?}", sort_start.elapsed());
let group_start = std::time::Instant::now();
let mut priority_groups: Vec<Vec<Executable>> = Vec::new();
let mut current_priority = None;
for exec in executables {
if current_priority != Some(exec.priority) {
priority_groups.push(Vec::new());
current_priority = Some(exec.priority);
}
if let Some(group) = priority_groups.last_mut() {
group.push(exec);
}
}
tracing::trace!("⚡ Grouping time: {:?}", group_start.elapsed());
let execution_start = std::time::Instant::now();
let mut all_results = Vec::new();
let mut has_failures = false;
for (group_idx, group) in priority_groups.iter().enumerate() {
if group.is_empty() {
continue;
}
let group_exec_start = std::time::Instant::now();
let parallel = hook.parallel || hooks_config.parallel;
let max_concurrent = if parallel && group.len() > 1 {
std::cmp::min(group.len(), system_profile::SYSTEM.recommended_cpu_workers)
} else {
1 };
tracing::trace!(
"🔀 Executing group {} with max {} concurrent ({} commands)",
group_idx,
max_concurrent,
group.len()
);
match self
.execute_group(group.clone(), &file_cache, max_concurrent, group_idx)
.await
{
Ok(results) => {
all_results.extend(results);
}
Err(_) => {
has_failures = true;
}
}
tracing::trace!(
"⚡ Group {} execution time: {:?}",
group_idx,
group_exec_start.elapsed()
);
}
tracing::trace!("⚡ Total execution time: {:?}", execution_start.elapsed());
Self::print_summary_with_results(total_start.elapsed(), &all_results);
tracing::trace!("🏁 Total hook time: {:?}", total_start.elapsed());
if has_failures {
return Err(anyhow!("Hook failed"));
}
Ok(())
}
fn collect_executables(
&self,
hook: &HookDefinition,
hook_name: &str,
args: &[String],
file_cache: &Arc<HookFileCache>,
) -> Result<Vec<Executable>> {
let mut executables = Vec::new();
let hook_def = Arc::new(hook.clone());
let evaluator = conditions::ConditionEvaluator::with_repo(file_cache.repo.clone());
for (idx, builtin_name) in hook.builtin.iter().enumerate() {
executables.push(Executable {
name: builtin_name.clone(),
exec_type: ExecutableType::Builtin(builtin_name.clone()),
priority: -(idx as i32) - 100, command: None,
script: None,
hook_name: hook_name.to_string(),
args: args.to_vec(),
file_cache: file_cache.clone(),
hook_definition: hook_def.clone(),
});
}
for (name, cmd) in &hook.commands {
if conditions::should_skip(&cmd.skip, &cmd.only, &evaluator)? {
output::info!(&format!("Skipping command '{name}' due to conditions"));
continue;
}
if cmd.run.starts_with("guardy_builtin:") {
let builtin = cmd.run.strip_prefix("guardy_builtin:").unwrap();
executables.push(Executable {
name: name.clone(),
exec_type: ExecutableType::Builtin(builtin.to_string()),
priority: cmd.priority,
command: Some(cmd.clone()),
script: None,
hook_name: hook_name.to_string(),
args: args.to_vec(),
file_cache: file_cache.clone(),
hook_definition: hook_def.clone(),
});
} else {
executables.push(Executable {
name: name.clone(),
exec_type: ExecutableType::Command,
priority: cmd.priority,
command: Some(cmd.clone()),
script: None,
hook_name: hook_name.to_string(),
args: args.to_vec(),
file_cache: file_cache.clone(),
hook_definition: hook_def.clone(),
});
}
}
for (name, script) in &hook.scripts {
executables.push(Executable {
name: name.clone(),
exec_type: ExecutableType::Script,
priority: 0, command: None,
script: Some(script.clone()),
hook_name: hook_name.to_string(),
args: args.to_vec(),
file_cache: file_cache.clone(),
hook_definition: hook_def.clone(),
});
}
Ok(executables)
}
async fn run(exec: &Executable, precomputed_files: &PrecomputedFiles) -> CommandResult {
let start = std::time::Instant::now();
tracing::trace!(
"▶️ Executing: {} ({})",
exec.name,
match &exec.exec_type {
ExecutableType::Builtin(_) => "builtin",
ExecutableType::Command => "command",
ExecutableType::Script => "script",
}
);
let result = match &exec.exec_type {
ExecutableType::Builtin(builtin) => {
let builtin_result = HookExecutor::run_builtin(
builtin,
&exec.hook_name,
&exec.args,
&exec.hook_definition,
)
.await;
CommandResult {
name: exec.name.clone(),
success: builtin_result.is_ok(),
skipped: false,
duration: start.elapsed(),
output: String::new(), }
}
ExecutableType::Command => {
let cmd = match exec.command.as_ref() {
Some(cmd) => cmd,
None => {
return CommandResult {
name: exec.name.clone(),
success: false,
skipped: false,
duration: start.elapsed(),
output: String::new(),
};
}
};
HookExecutor::run_command(
&exec.name,
cmd,
&exec.hook_name,
&exec.args,
&exec.file_cache,
precomputed_files,
)
.await
}
ExecutableType::Script => {
let script = match exec.script.as_ref() {
Some(script) => script,
None => {
return CommandResult {
name: exec.name.clone(),
success: false,
skipped: false,
duration: start.elapsed(),
output: String::new(),
};
}
};
let script_result = HookExecutor::run_script(
&exec.name,
script,
&exec.hook_name,
precomputed_files,
)
.await;
CommandResult {
name: exec.name.clone(),
success: script_result.is_ok(),
skipped: false,
duration: start.elapsed(),
output: String::new(), }
}
};
tracing::trace!("⚡ Executable '{}' time: {:?}", exec.name, start.elapsed());
result
}
async fn execute_group(
&self,
group: Vec<Executable>,
file_cache: &Arc<HookFileCache>,
max_concurrent: usize,
group_idx: usize,
) -> Result<Vec<CommandResult>> {
use tokio::sync::Mutex;
let precompute_start = std::time::Instant::now();
let mut needs_staged_files = false;
let mut needs_all_files = false;
let mut needs_push_files = false;
for exec in &group {
if let Some(cmd) = &exec.command {
if cmd.run.contains("{staged_files}") {
needs_staged_files = true;
}
if cmd.run.contains("{all_files}") {
needs_all_files = true;
}
if cmd.run.contains("{push_files}") {
needs_push_files = true;
}
if !cmd.glob.is_empty() {
match exec.hook_name.as_str() {
"pre-commit" | "commit-msg" => {
needs_staged_files = true;
}
"pre-push" => {
needs_push_files = true;
}
_ => {
if cmd.all_files {
needs_all_files = true;
}
}
}
}
}
}
let mut precomputed_files = PrecomputedFiles::default();
if needs_staged_files {
let staged_files: Vec<String> = file_cache
.get_staged_files()
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
precomputed_files.staged_files = Some(staged_files.into());
tracing::trace!("Pre-computed staged_files for group {}", group_idx);
}
if needs_all_files {
let all_files: Vec<String> = file_cache
.get_all_files()
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
precomputed_files.all_files = Some(all_files.into());
tracing::trace!("Pre-computed all_files for group {}", group_idx);
}
if needs_push_files {
let push_files: Vec<String> = file_cache
.get_push_files()
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
precomputed_files.push_files = Some(push_files.into());
tracing::trace!("Pre-computed push_files for group {}", group_idx);
}
tracing::trace!(
"⚡ File precomputation time: {:?}",
precompute_start.elapsed()
);
let results = Arc::new(Mutex::new(Vec::new()));
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
let precomputed_files = Arc::new(precomputed_files);
let mut handles = Vec::new();
for exec in group {
let results = results.clone();
let precomputed_files = precomputed_files.clone();
let permit = semaphore.clone().acquire_owned().await?;
use supercli::starbase_styles::color::owo::OwoColorize;
println!(
"{} {} {} {}",
"┃".cyan(),
exec.name.cyan(),
"(started)".dimmed(),
"❯".dimmed()
);
let handle = tokio::spawn(async move {
let cmd_result = HookExecutor::run(&exec, &precomputed_files).await;
if !cmd_result.output.is_empty() {
print!("{}", cmd_result.output);
}
drop(permit);
let mut res = results.lock().await;
res.push(cmd_result);
});
handles.push(handle);
}
for handle in handles {
handle.await?;
}
let results = results.lock().await.clone();
let has_failures = results.iter().any(|r| !r.success);
if has_failures {
return Err(anyhow!("Some commands failed"));
}
Ok(results)
}
async fn run_script(
name: &str,
script: &HookScript,
hook_name: &str,
precomputed_files: &PrecomputedFiles,
) -> Result<()> {
output::info!(&format!("Running script: {name}"));
let script_path = format!(".guardy/scripts/{hook_name}/{name}");
if !std::path::Path::new(&script_path).exists() {
return Err(anyhow!("Script file not found: {}", script_path));
}
let mut command = Command::new(&script.runner);
command.arg(&script_path);
for (key, value) in &script.env {
let files = precomputed_files
.staged_files
.as_ref()
.or(precomputed_files.all_files.as_ref())
.map(|f| f.as_ref())
.unwrap_or(&[]);
let substituted_value = HookExecutor::substitute_placeholders(
value,
precomputed_files,
files, &[], hook_name,
&[], name, )?;
command.env(key, substituted_value);
}
let output = command.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
output::error!(&format!("✗ Script '{name}' failed"));
return Err(anyhow!("Script failed: {}", stderr));
}
output::success!(&format!("✓ Script '{name}' completed"));
Ok(())
}
async fn run_builtin(
builtin: &str,
hook_name: &str,
args: &[String],
hook_def: &HookDefinition,
) -> Result<()> {
match builtin {
"scan_secrets" => {
if hook_name != "pre-commit" {
return Ok(());
}
HookExecutor::scan_secrets().await
}
"conventional_commits" => {
if hook_name != "commit-msg" || args.is_empty() {
return Ok(());
}
HookExecutor::validate_commit_msg(&args[0], hook_def).await
}
"ensure_clean" => {
if hook_name != "pre-push" {
output::warning!("ensure_clean builtin only works in pre-push hook");
return Ok(());
}
HookExecutor::ensure_clean().await
}
unknown => {
output::warning!(&format!("Unknown builtin command: {unknown}"));
Ok(())
}
}
}
async fn scan_secrets() -> Result<()> {
output::info!("Scanning for secrets...");
let repo = GitRepo::discover()?;
let staged_files = repo.get_staged_files()?;
if staged_files.is_empty() {
output::info!("No staged files to check");
return Ok(());
}
let scanner = Scanner::new()?;
let stats = scanner.scan(&staged_files)?;
if stats.total_matches > 0 {
output::error!(&format!(
"❌ Found {} secrets in staged files",
stats.total_matches
));
output::info!("See above for detailed match information.");
println!("\nCommit aborted. Remove secrets before committing.");
return Err(anyhow!("Secrets detected in staged files"));
}
output::success!(&format!(
"✅ Scanned {} files - no secrets found",
stats.files_scanned
));
Ok(())
}
async fn validate_commit_msg(commit_file: &str, hook_def: &HookDefinition) -> Result<()> {
output::info!("Validating commit message format...");
let config = hook_def.conventional_commits.as_ref();
let default_types = vec![
"feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
"revert",
];
let allowed_types = config
.and_then(|c| c.allowed_types.as_ref())
.map(|types| types.iter().map(|s| s.as_str()).collect::<Vec<_>>())
.unwrap_or(default_types);
let enforce_scope = config.map(|c| c.enforce_scope).unwrap_or(false);
let commit_msg =
std::fs::read_to_string(commit_file).context("Failed to read commit message file")?;
let commit_msg = commit_msg
.lines()
.filter(|line| !line.starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if commit_msg.is_empty() {
return Err(anyhow!("Empty commit message"));
}
match git_conventional::Commit::parse(&commit_msg) {
Ok(commit) => {
let commit_type = commit.type_().as_str();
if !allowed_types.contains(&commit_type) {
output::error!(&format!(
"❌ Invalid commit type: '{}'\nAllowed types: {}",
commit_type,
allowed_types.join(", ")
));
return Err(anyhow!("Commit type '{}' is not allowed", commit_type));
}
if enforce_scope && commit.scope().is_none() {
output::error!("❌ Scope is required but not provided");
output::info!("Expected format: <type>(<scope>): <description>");
output::info!("Examples:");
output::info!(" feat(auth): add login functionality");
output::info!(" fix(ui): correct button alignment");
return Err(anyhow!("Scope is required for all commits"));
}
let scope_str = commit.scope().map(|s| s.as_str()).unwrap_or("no scope");
let is_breaking = commit.breaking();
let breaking_indicator = if is_breaking { " [BREAKING]" } else { "" };
output::success!(&format!(
"✅ Valid conventional commit: {} ({}){breaking_indicator}",
commit_type, scope_str
));
if !enforce_scope && commit_type == "feat" && commit.scope().is_none() {
output::warning!("Consider adding a scope to feat commits");
}
Ok(())
}
Err(e) => {
output::error!(&format!("❌ Invalid conventional commit format: {e}"));
output::info!(&format!(
"Expected format: <type>(<scope>): <description>{}",
if enforce_scope {
" (scope required)"
} else {
""
}
));
output::info!("Examples:");
output::info!(" feat(auth): add login functionality");
output::info!(" fix(ui): correct button alignment");
if !enforce_scope {
output::info!(" docs: update README");
}
Err(anyhow!(
"Commit message does not follow conventional commits format"
))
}
}
}
async fn ensure_clean() -> Result<()> {
output::info!("Checking for uncommitted changes...");
let repo = GitRepo::discover()?;
let status = repo.get_status()?;
if !status.is_empty() {
output::error!(
"Repository has uncommitted changes. Please commit them before pushing:"
);
for file in &status {
println!(" {}", file);
}
return Err(anyhow!(
"Uncommitted changes detected. Commit or stash them before pushing."
));
}
output::success!("✅ Repository is clean - ready to push");
Ok(())
}
async fn run_command(
cmd_name: &str,
cmd: &HookCommand,
hook_name: &str,
hook_args: &[String],
_file_cache: &Arc<HookFileCache>,
precomputed_files: &PrecomputedFiles,
) -> CommandResult {
let cmd_start = std::time::Instant::now();
let description = if !cmd.description.is_empty() {
cmd.description.clone()
} else {
cmd_name.to_string()
};
let discovery_method = FileDiscoveryMethod::from_hook_command(cmd, hook_name);
if discovery_method.should_skip() {
use supercli::starbase_styles::color::owo::OwoColorize;
let skip_msg = format!(
" {} {} {} {}\n",
"○".dimmed(),
description.dimmed(),
"(skip)".dimmed(),
"no matching files".yellow()
);
return CommandResult {
name: description,
success: true,
skipped: true,
duration: cmd_start.elapsed(),
output: skip_msg,
};
}
let filtered_staged_files = if let Some(staged_files) = &precomputed_files.staged_files {
let filter_params = FilterParams {
glob: &cmd.glob,
exclude: &cmd.exclude,
root: cmd.root.as_deref(),
file_types: &cmd.file_types,
};
match apply_file_filters(staged_files, filter_params) {
Ok(filtered) => {
tracing::trace!("Filtered staged_files: {} files", filtered.len());
filtered
}
Err(e) => {
tracing::error!("Failed to filter staged files: {}", e);
return CommandResult {
name: description,
success: false,
skipped: false,
duration: cmd_start.elapsed(),
output: String::new(),
};
}
}
} else {
Vec::new()
};
let filtered_custom_files = if let Some(files_cmd) = &cmd.files {
tracing::trace!("Executing custom files command: {}", files_cmd);
match HookExecutor::get_files_from_command(files_cmd) {
Ok(custom_files) => {
let filter_params = FilterParams {
glob: &cmd.glob,
exclude: &cmd.exclude,
root: cmd.root.as_deref(),
file_types: &cmd.file_types,
};
match apply_file_filters(&custom_files, filter_params) {
Ok(filtered) => {
tracing::trace!("Filtered custom files: {} files", filtered.len());
filtered
}
Err(e) => {
tracing::error!("Failed to filter custom files: {}", e);
return CommandResult {
name: description,
success: false,
skipped: false,
duration: cmd_start.elapsed(),
output: String::new(),
};
}
}
}
Err(e) => {
tracing::error!("Failed to get files from command: {}", e);
return CommandResult {
name: description,
success: false,
skipped: false,
duration: cmd_start.elapsed(),
output: String::new(),
};
}
}
} else {
Vec::new()
};
let has_file_requirement = !cmd.glob.is_empty()
|| cmd.run.contains("{staged_files}")
|| cmd.run.contains("{files}");
let has_matching_files =
!filtered_staged_files.is_empty() || !filtered_custom_files.is_empty();
if has_file_requirement && !has_matching_files {
use supercli::starbase_styles::color::owo::OwoColorize;
let skip_msg = format!(
" {} {} {} {}\n",
"○".dimmed(),
description.dimmed(),
"(skip)".dimmed(),
"no matching files".yellow()
);
return CommandResult {
name: description,
success: true,
skipped: true,
duration: cmd_start.elapsed(),
output: skip_msg,
};
}
let subst_start = std::time::Instant::now();
let command_str = match HookExecutor::substitute_placeholders(
&cmd.run,
precomputed_files,
&filtered_staged_files,
&filtered_custom_files,
hook_name,
hook_args,
cmd_name,
) {
Ok(cmd_str) => cmd_str,
Err(e) => {
tracing::error!("Failed to substitute placeholders: {}", e);
return CommandResult {
name: description,
success: false,
skipped: false,
duration: cmd_start.elapsed(),
output: String::new(),
};
}
};
tracing::trace!(
"⚡ Placeholder substitution time: {:?}",
subst_start.elapsed()
);
tracing::trace!("🔧 Executing command: {}", command_str);
let exec_start = std::time::Instant::now();
let mut command = Command::new("sh");
command.arg("-c").arg(&command_str);
if let Some(root_dir) = &cmd.root {
command.current_dir(root_dir);
tracing::trace!("Setting working directory to: {}", root_dir);
}
for (key, value) in &cmd.env {
let substituted_value = match HookExecutor::substitute_placeholders(
value,
precomputed_files,
&filtered_staged_files,
&filtered_custom_files,
hook_name,
hook_args,
cmd_name,
) {
Ok(val) => val,
Err(e) => {
tracing::error!("Failed to substitute env var placeholder: {}", e);
return CommandResult {
name: description,
success: false,
skipped: false,
duration: cmd_start.elapsed(),
output: String::new(),
};
}
};
command.env(key, substituted_value);
}
use supercli::starbase_styles::color::owo::OwoColorize;
let output_result = command.output().unwrap_or_else(|e| {
tracing::error!("Failed to execute command: {}", e);
std::process::Output {
status: std::process::ExitStatus::default(),
stdout: Vec::new(),
stderr: Vec::new(),
}
});
tracing::trace!("⚡ Command execution time: {:?}", exec_start.elapsed());
let success = output_result.status.success();
let mut output_buffer = String::new();
if !output_result.stdout.is_empty() {
let stdout = String::from_utf8_lossy(&output_result.stdout);
output_buffer.push_str(&stdout);
if !stdout.ends_with('\n') {
output_buffer.push('\n');
}
}
if !output_result.stderr.is_empty() {
let stderr = String::from_utf8_lossy(&output_result.stderr);
output_buffer.push_str(&stderr);
if !stderr.ends_with('\n') {
output_buffer.push('\n');
}
}
if success {
output_buffer.push_str(&format!(
" {} {} {}\n",
"✔".bright_green(),
description.cyan(),
"(passed)".bright_green()
));
} else {
output_buffer.push_str(&format!(
" {} {} {}\n",
"✗".bright_red(),
description.cyan(),
"(failed)".bright_red()
));
}
tracing::trace!("⚡ Total command time: {:?}", cmd_start.elapsed());
CommandResult {
name: description,
success,
skipped: false,
duration: cmd_start.elapsed(),
output: output_buffer,
}
}
fn substitute_placeholders(
command: &str,
precomputed_files: &PrecomputedFiles,
filtered_staged_files: &[String],
filtered_custom_files: &[String],
hook_name: &str,
hook_args: &[String],
command_name: &str,
) -> Result<String> {
let mut result = command.to_string();
if result.contains("{staged_files}") {
let files_str = filtered_staged_files.join(" ");
result = result.replace("{staged_files}", &files_str);
}
if result.contains("{files}") {
let files_str = filtered_custom_files.join(" ");
result = result.replace("{files}", &files_str);
}
if result.contains("{all_files}")
&& let Some(all_files) = &precomputed_files.all_files
{
let files_str = all_files.join(" ");
result = result.replace("{all_files}", &files_str);
}
if result.contains("{push_files}")
&& hook_name == "pre-push"
&& let Some(push_files) = &precomputed_files.push_files
{
let files_str = push_files.join(" ");
result = result.replace("{push_files}", &files_str);
}
if result.contains("{cmd}") {
result = result.replace("{cmd}", command);
}
if result.contains("{guardy_job_name}") {
result = result.replace("{guardy_job_name}", command_name);
}
if result.contains("{0}") {
let args_str = hook_args.join(" ");
result = result.replace("{0}", &args_str);
}
for (idx, arg) in hook_args.iter().enumerate() {
let placeholder = format!("{{{}}}", idx + 1);
if result.contains(&placeholder) {
result = result.replace(&placeholder, arg);
}
}
Ok(result)
}
fn get_files_from_command(files_cmd: &str) -> Result<Vec<String>> {
tracing::trace!("Executing files command: {}", files_cmd);
let output = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", files_cmd])
.output()
.with_context(|| format!("Failed to execute files command: {files_cmd}"))?
} else {
Command::new("sh")
.args(["-c", files_cmd])
.output()
.with_context(|| format!("Failed to execute files command: {files_cmd}"))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("Files command failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = stdout
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect();
tracing::trace!("Files command returned {} files", files.len());
Ok(files)
}
fn print_banner(hook_name: &str) {
use crate::cli::banner;
use supercli::starbase_styles::color::owo::OwoColorize;
let hook_context = format!("hook: {}", hook_name.yellow().bold());
banner::print_banner(Some(&hook_context));
}
fn print_summary_with_results(duration: std::time::Duration, results: &[CommandResult]) {
use supercli::starbase_styles::color::owo::OwoColorize;
let secs = duration.as_secs_f64();
println!(" {}", "─".repeat(35).dimmed());
println!(
"{} {}",
"summary:".cyan(),
format!("(done in {:.2} seconds)", secs).dimmed()
);
for result in results {
let duration_secs = result.duration.as_secs_f64();
if result.skipped {
println!(
"○ {} {} {}",
result.name.dimmed(),
"(skipped)".dimmed(),
format!("({:.2} seconds)", duration_secs).dimmed()
);
} else if result.success {
println!(
"✔ {} {}",
result.name.green(),
format!("({:.2} seconds)", duration_secs).dimmed()
);
} else {
println!(
"✗ {} {}",
result.name.red(),
format!("({:.2} seconds)", duration_secs).dimmed()
);
}
}
}
}
#[derive(Default)]
struct PrecomputedFiles {
staged_files: Option<Arc<[String]>>,
all_files: Option<Arc<[String]>>,
push_files: Option<Arc<[String]>>,
}
#[derive(Clone)]
struct Executable {
name: String,
exec_type: ExecutableType,
priority: i32,
command: Option<HookCommand>,
script: Option<HookScript>,
hook_name: String,
args: Vec<String>,
file_cache: Arc<HookFileCache>,
hook_definition: Arc<HookDefinition>,
}
#[derive(Clone, Debug)]
enum ExecutableType {
Builtin(String),
Command,
Script,
}