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

This commit is contained in:
Mick Grove 2025-08-22 17:26:48 -07:00
commit 231b92e52e
6 changed files with 286 additions and 1 deletions

View file

@ -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`

View file

@ -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:

View file

@ -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;

View file

@ -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<Scope> {
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<Scope> {
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<Option<PathBuf>> {
// 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<PathBuf> {
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<PathBuf> {
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<Option<PathBuf>> {
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<Option<PathBuf>> {
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<bool> {
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<Option<PathBuf>> {
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<PathBuf> {
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<PathBuf> {
if cfg!(windows) {
env::var_os("USERPROFILE").map(PathBuf::from)
} else {
env::var_os("HOME").map(PathBuf::from)
}
}

View file

@ -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<Option<f64>> = Lazy::new(|| {

View file

@ -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) => {