From 231b92e52e8e563aed1ecba6516c23c77dcd4b98 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 22 Aug 2025 17:26:48 -0700 Subject: [PATCH] Added a new install-precommit subcommand that installs a git pre-commit hook, prompting or accepting --global/--repo flags to control scope and configuring the hook to run kingfisher --quiet --only-valid --no-update-check --- CHANGELOG.md | 1 + README.md | 8 + src/cli/commands/mod.rs | 1 + src/cli/commands/precommit.rs | 265 ++++++++++++++++++++++++++++++++++ src/cli/global.rs | 7 +- src/main.rs | 5 + 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands/precommit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a03df..e666552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [1.46.0] - Improved rules: AWS, pem +- Added a new precommit subcommand that installs a git pre-commit hook, prompting or accepting --global/--repo flags to control scope and configuring the hook to run kingfisher --quiet --only-valid --no-update-check ## [1.45.0] - Added `--repo-artifacts` flag to scan repository issues, gists/snippets, and wikis when cloning via `--git-url` diff --git a/README.md b/README.md index 56e5f18..272c67a 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,14 @@ make darwin-all # builds both x64 and arm64 make all # builds for every OS and architecture supported ``` +### Install as a Git pre-commit hook + +Run `kingfisher precommit --install` to set up a Git pre-commit hook that runs +`kingfisher --quiet --only-valid --no-update-check` before each commit. +Use `--global` to operate on all repositories or `--repo` to target only the +current repository without prompting. Remove the hook with +`kingfisher precommit --remove`. + ### Run Kingfisher in Docker Run the dockerized Kingfisher container: diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index c73ec82..ec48c0f 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -2,5 +2,6 @@ pub mod github; pub mod gitlab; pub mod inputs; pub mod output; +pub mod precommit; pub mod rules; pub mod scan; diff --git a/src/cli/commands/precommit.rs b/src/cli/commands/precommit.rs new file mode 100644 index 0000000..a6a1f0a --- /dev/null +++ b/src/cli/commands/precommit.rs @@ -0,0 +1,265 @@ +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs}; + +use anyhow::{anyhow, Context, Result}; +use clap::{ArgAction, ArgGroup, Args}; + +use crate::gix; + +/// Arguments for `precommit` command +#[derive(Args, Debug, Clone)] +#[command(group( + ArgGroup::new("action") + .args(["install", "remove"]) + .required(true) + .multiple(false) +))] +pub struct PrecommitArgs { + /// Install the pre-commit hook + #[arg(long, action = ArgAction::SetTrue, conflicts_with = "remove")] + pub install: bool, + + /// Remove the pre-commit hook + #[arg(long, action = ArgAction::SetTrue, conflicts_with = "install")] + pub remove: bool, + + /// Operate on all repositories using the global hooks directory + #[arg(long, conflicts_with = "repo")] + pub global: bool, + + /// Operate only on the current repository + #[arg(long, conflicts_with = "global")] + pub repo: bool, +} + +/// Scope of operation +enum Scope { + Global, + Repo, +} + +/// Run the `precommit` command +pub fn run(args: &PrecommitArgs) -> Result<()> { + if args.install { + if let Some(path) = find_existing_hook()? { + println!("Kingfisher pre-commit hook already installed at {}", path.display()); + return Ok(()); + } + let scope = determine_scope(args, true)?; + let hook_path = match scope { + Scope::Global => install_global()?, + Scope::Repo => install_repo()?, + }; + println!("Installed Kingfisher pre-commit hook at {}", hook_path.display()); + } else if args.remove { + let scope = determine_scope(args, false)?; + let removed = match scope { + Scope::Global => remove_global()?, + Scope::Repo => remove_repo()?, + }; + if let Some(path) = removed { + println!("Removed Kingfisher pre-commit hook from {}", path.display()); + } else { + println!("No Kingfisher pre-commit hook found to remove"); + } + } + Ok(()) +} + +fn determine_scope(args: &PrecommitArgs, installing: bool) -> Result { + if args.global { + Ok(Scope::Global) + } else if args.repo { + Ok(Scope::Repo) + } else { + let verb = if installing { "Install" } else { "Remove" }; + prompt_scope(verb) + } +} + +fn prompt_scope(action: &str) -> Result { + print!("{} pre-commit hook globally? [y/N]: ", action); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if matches!(input.trim().to_lowercase().as_str(), "y" | "yes") { + Ok(Scope::Global) + } else { + Ok(Scope::Repo) + } +} + +fn find_existing_hook() -> Result> { + // Check repo-local hook + if let Ok(repo) = gix::discover(".") { + let path = repo.path().join("hooks").join(hook_filename()); + if hook_contains_kingfisher(&path) { + return Ok(Some(path)); + } + } + + // Check global hook + if let Some(dir) = current_global_hooks_dir()? { + let path = dir.join(hook_filename()); + if hook_contains_kingfisher(&path) { + return Ok(Some(path)); + } + } + + Ok(None) +} + +fn install_repo() -> Result { + let repo = gix::discover(".").context("Not inside a git repository")?; + let hooks_dir = repo.path().join("hooks"); + fs::create_dir_all(&hooks_dir)?; + let hook_path = hooks_dir.join(hook_filename()); + write_hook(&hook_path)?; + Ok(hook_path) +} + +fn install_global() -> Result { + let hooks_dir = get_or_set_global_hooks_dir()?; + let hook_path = hooks_dir.join(hook_filename()); + write_hook(&hook_path)?; + Ok(hook_path) +} + +fn remove_repo() -> Result> { + let repo = gix::discover(".").context("Not inside a git repository")?; + let hook_path = repo.path().join("hooks").join(hook_filename()); + if remove_hook(&hook_path)? { + Ok(Some(hook_path)) + } else { + Ok(None) + } +} + +fn remove_global() -> Result> { + if let Some(dir) = current_global_hooks_dir()? { + let hook_path = dir.join(hook_filename()); + if remove_hook(&hook_path)? { + return Ok(Some(hook_path)); + } + } + Ok(None) +} + +fn write_hook(path: &Path) -> Result<()> { + if path.exists() { + let content = fs::read_to_string(path)?; + if content.contains("kingfisher") { + println!("Kingfisher pre-commit hook already installed at {}", path.display()); + return Ok(()); + } + let mut file = fs::OpenOptions::new().append(true).open(path)?; + if !content.ends_with('\n') { + writeln!(file)?; + } + writeln!(file, "{}", hook_call_line())?; + } else { + fs::write(path, hook_content())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms)?; + } + } + Ok(()) +} + +fn remove_hook(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + let content = fs::read_to_string(path)?; + if !content.contains("kingfisher") { + return Ok(false); + } + let ending = if cfg!(windows) { "\r\n" } else { "\n" }; + let lines: Vec<&str> = content.lines().filter(|l| !l.contains("kingfisher")).collect(); + if lines.is_empty() { + fs::remove_file(path)?; + } else { + let mut new_content = lines.join(ending); + new_content.push_str(ending); + fs::write(path, new_content)?; + } + Ok(true) +} + +fn hook_contains_kingfisher(path: &Path) -> bool { + fs::read_to_string(path).map(|c| c.contains("kingfisher")).unwrap_or(false) +} + +fn hook_filename() -> &'static str { + if cfg!(windows) { + "pre-commit.bat" + } else { + "pre-commit" + } +} + +fn hook_content() -> String { + if cfg!(windows) { + format!("@echo off\r\n{}\r\n", hook_call_line()) + } else { + format!("#!/bin/sh\n{}\n", hook_call_line()) + } +} + +fn hook_call_line() -> String { + if cfg!(windows) { + "kingfisher --quiet --only-valid --no-update-check %*".to_string() + } else { + "kingfisher --quiet --only-valid --no-update-check \"$@\"".to_string() + } +} + +fn current_global_hooks_dir() -> Result> { + let output = + Command::new("git").args(["config", "--global", "--get", "core.hooksPath"]).output()?; + if output.status.success() { + let p = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if p.is_empty() { + Ok(None) + } else { + Ok(Some(PathBuf::from(p))) + } + } else { + Ok(None) + } +} + +fn get_or_set_global_hooks_dir() -> Result { + if let Some(dir) = current_global_hooks_dir()? { + fs::create_dir_all(&dir)?; + return Ok(dir); + } + + let home = home_dir().ok_or_else(|| anyhow!("Unable to determine home directory"))?; + let hooks = home.join(".githooks"); + fs::create_dir_all(&hooks)?; + Command::new("git") + .args([ + "config", + "--global", + "core.hooksPath", + hooks.to_str().ok_or_else(|| anyhow!("Invalid path"))?, + ]) + .status() + .context("Failed to set git global core.hooksPath")?; + Ok(hooks) +} + +fn home_dir() -> Option { + if cfg!(windows) { + env::var_os("USERPROFILE").map(PathBuf::from) + } else { + env::var_os("HOME").map(PathBuf::from) + } +} diff --git a/src/cli/global.rs b/src/cli/global.rs index 93599b7..4116ddd 100644 --- a/src/cli/global.rs +++ b/src/cli/global.rs @@ -7,7 +7,8 @@ use sysinfo::{MemoryRefreshKind, RefreshKind, System}; use tracing::Level; use crate::cli::commands::{ - github::GitHubArgs, gitlab::GitLabArgs, rules::RulesArgs, scan::ScanArgs, + github::GitHubArgs, gitlab::GitLabArgs, precommit::PrecommitArgs, rules::RulesArgs, + scan::ScanArgs, }; #[deny(missing_docs)] @@ -62,6 +63,10 @@ pub enum Command { /// Manage rules #[command(alias = "rule")] Rules(RulesArgs), + + /// Manage Kingfisher as a Git pre-commit hook + #[command(name = "precommit")] + Precommit(PrecommitArgs), } pub static RAM_GB: Lazy> = Lazy::new(|| { diff --git a/src/main.rs b/src/main.rs index 38c0a88..7ef0e9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,7 @@ use tracing_subscriber::{ use url::Url; use crate::cli::commands::gitlab::{GitLabCommand, GitLabRepoType, GitLabReposCommand}; +use crate::cli::commands::precommit; fn main() -> anyhow::Result<()> { color_backtrace::install(); @@ -81,6 +82,7 @@ fn main() -> anyhow::Result<()> { Command::GitHub(_) => num_cpus::get(), // Default for GitHub commands Command::GitLab(_) => num_cpus::get(), // Default for GitLab commands Command::Rules(_) => num_cpus::get(), // Default for Rules commands + Command::Precommit(_) => num_cpus::get(), }; // Set up the Tokio runtime with the specified number of threads @@ -219,6 +221,9 @@ async fn async_main(args: CommandLineArgs) -> Result<()> { run_rules_list(&list_args)?; } }, + Command::Precommit(pre_args) => { + precommit::run(&pre_args)?; + } Command::GitHub(github_args) => match github_args.command { GitHubCommand::Repos(repos_command) => match repos_command { GitHubReposCommand::List(list_args) => {