mod bash_paths;
mod config;
mod engine;
mod fetch;
mod file_matching;
mod git;
mod hash_adapter;
mod metadata;
mod serde_glob;
mod serde_regex;
mod unique_filename;
mod wasi_cache;
use anyhow::{anyhow, bail, Result};
use bash_paths::path_to_bash_string;
use clap::{Parser, Subcommand, ValueEnum};
use config::{read_config, Config};
use engine::{get_cache_dir, run_single_linter};
use env_logger::{Builder, Env};
use fetch::fetch_linters;
use file_matching::retain_matching_files;
use git::git_diff_unstaged;
use log::info;
use metadata::{has_metadata, read_metadata};
use owo_colors::OwoColorize;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Parser)]
#[command(
name = "pre-commit",
version,
about = "A CLI for managing pre-commit hooks"
)]
struct Cli {
#[arg(long, default_value_t = ColorOutput::Auto)]
color: ColorOutput,
#[arg(long)]
quiet: bool,
#[arg(short, long)]
config: Option<PathBuf>,
#[command(subcommand)]
command: SubCommand,
}
#[derive(Subcommand)]
enum SubCommand {
Clean,
Fetch,
Install(InstallArgs),
Uninstall,
Run(RunArgs),
SampleConfig,
ValidateConfig,
ShowMetadata(ShowMetadataArgs),
SetMetadata(SetMetadataArgs),
PreCommit,
PrePush(PrePushArgs),
}
#[derive(Parser)]
struct InstallArgs {
#[arg(long)]
hook_type: Option<HookType>,
}
#[derive(Parser)]
struct RunArgs {
#[arg(short, long)]
all: bool,
#[arg(long)]
files: Vec<PathBuf>,
#[arg(long)]
show_diff_on_failure: bool,
}
#[derive(Parser)]
struct ShowMetadataArgs {
file: PathBuf,
}
#[derive(Parser)]
struct SetMetadataArgs {
file: PathBuf,
#[arg(long)]
metadata: PathBuf,
}
#[derive(Parser)]
struct PrePushArgs {
remote: String,
url: String,
}
#[derive(ValueEnum, Clone)]
enum ColorOutput {
Auto,
Always,
Never,
}
impl std::fmt::Display for ColorOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ColorOutput::Auto => write!(f, "auto"),
ColorOutput::Always => write!(f, "always"),
ColorOutput::Never => write!(f, "never"),
}
}
}
#[derive(ValueEnum, Clone, Default)]
enum HookType {
#[default]
PreCommit,
PrePush,
}
impl HookType {
fn as_str(&self) -> &str {
match self {
HookType::PreCommit => "pre-commit",
HookType::PrePush => "pre-push",
}
}
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
let cli = Cli::parse();
let default_level = if cli.quiet { "warn" } else { "info" };
let env = Env::new()
.filter_or("NIT_LOG", default_level)
.write_style("NIT_LOG_STYLE");
Builder::from_env(env)
.format_timestamp(None)
.format_target(false)
.init();
match &cli.command {
SubCommand::Clean => subcommand_clean(&cli).await,
SubCommand::Fetch => subcommand_fetch(&cli).await,
SubCommand::Install(args) => subcommand_install(&cli, args).await,
SubCommand::Uninstall => subcommand_uninstall(&cli).await,
SubCommand::Run(args) => subcommand_run(&cli, args).await,
SubCommand::SampleConfig => subcommand_sample_config(&cli).await,
SubCommand::ValidateConfig => subcommand_validate_config(&cli).await,
SubCommand::ShowMetadata(args) => subcommand_show_metadata(&cli, args).await,
SubCommand::SetMetadata(args) => subcommand_set_metadata(&cli, args).await,
SubCommand::PreCommit => subcommand_pre_commit(&cli).await,
SubCommand::PrePush(args) => subcommand_pre_push(&cli, args).await,
}
}
fn find_and_read_config(top_level: &Path, config: &Option<PathBuf>) -> Result<Config> {
if let Some(path) = config {
read_config(path)
} else {
for filename in &[".nit.json5", ".nit.jsonc", ".nit.json"] {
let path = top_level.join(filename);
if path.exists() {
return read_config(&path);
}
}
bail!("No config file found (.nit.json5/jsonc/json) in the repository");
}
}
async fn subcommand_clean(_cli: &Cli) -> Result<()> {
let cache_dir = get_cache_dir().ok_or(anyhow!("Could not determine cache directory"))?;
fs::remove_dir_all(cache_dir).await?;
info!("Cache directory cleaned");
Ok(())
}
async fn subcommand_fetch(cli: &Cli) -> Result<()> {
let top_level = git::git_top_level()?;
let config = find_and_read_config(&top_level, &cli.config)?;
let cache_dir = get_cache_dir().ok_or(anyhow!("Could not determine cache directory"))?;
fetch_linters(&config.linters, &cache_dir).await
}
async fn subcommand_install(cli: &Cli, args: &InstallArgs) -> Result<()> {
let current_exe = std::env::current_exe()?;
let hooks_dir = git::git_hooks_dir()?;
fs::create_dir_all(&hooks_dir).await?;
let hook_type = args.hook_type.clone().unwrap_or_default();
let hook_path = hooks_dir.join(hook_type.as_str());
if fs::try_exists(&hook_path).await? {
let content = fs::read(&hook_path).await?;
if memchr::memmem::find(&content, b"nit").is_none() {
bail!(
"Hook '{}' already exists and isn't a Nit hook.",
hook_type.as_str()
);
}
}
let exe_path = bash_paths::path_to_bash_string(¤t_exe)?;
let config_arg = if let Some(config) = &cli.config {
format!("--config {}", path_to_bash_string(config)?)
} else {
String::new()
};
fs::write(
&hook_path,
format!(
"#!/bin/bash\n\nset -e\n\n{exe_path} {config_arg} {} \"$@\"\n",
hook_type.as_str()
),
)
.await?;
#[cfg(unix)]
set_executable(&hook_path).await?;
log::info!("Installed pre-commit hook");
Ok(())
}
#[cfg(unix)]
async fn set_executable(path: &Path) -> Result<()> {
let metadata = fs::metadata(path).await?;
let mut permissions = metadata.permissions();
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(permissions.mode() | 0o111);
fs::set_permissions(path, permissions).await?;
Ok(())
}
async fn subcommand_uninstall(_cli: &Cli) -> Result<()> {
let hooks_dir = git::git_hooks_dir()?;
for hook_type in &[HookType::PreCommit, HookType::PrePush] {
let hook_path = hooks_dir.join(hook_type.as_str());
let content = fs::read(&hook_path).await?;
if memchr::memmem::find(&content, b"nit").is_some() {
fs::remove_file(&hook_path).await?;
info!("Uninstalled hook '{}'", hook_type.as_str());
} else {
info!("Hook '{}' is not a Nit hook.", hook_type.as_str());
}
}
Ok(())
}
async fn subcommand_sample_config(_cli: &Cli) -> Result<()> {
let sample_config = include_str!("../sample_config.json5");
println!("{}", sample_config);
Ok(())
}
async fn subcommand_validate_config(cli: &Cli) -> Result<()> {
let top_level = git::git_top_level()?;
let _config = find_and_read_config(&top_level, &cli.config)?;
info!("Config validated");
Ok(())
}
async fn subcommand_run(cli: &Cli, args: &RunArgs) -> Result<()> {
let top_level = git::git_top_level()?;
let config = find_and_read_config(&top_level, &cli.config)?;
let files = if args.all {
git::git_tree_files(&top_level, "HEAD")?
} else {
git::git_staged_files(&top_level)?
};
run(top_level, config, files).await
}
async fn run(
top_level: PathBuf,
config: Config,
mut files: Vec<git::FileInfo>,
) -> std::result::Result<(), anyhow::Error> {
let cache_dir = get_cache_dir().ok_or(anyhow!("Could not determine cache directory"))?;
retain_matching_files(&mut files, &config.include);
fetch_linters(&config.linters, &cache_dir).await?;
let mut diff = git_diff_unstaged(&top_level)?;
let mut failed = false;
for linter in config.linters {
eprintln!("Running linter: {}", linter.name.blue());
let status = run_single_linter(&files, &cache_dir, &top_level, linter).await?;
let new_diff = git_diff_unstaged(&top_level)?;
if !status || diff != new_diff {
failed = true;
eprintln!("Linter {}", "failed".red());
} else {
eprintln!("Linter {}", "passed".green());
}
diff = new_diff;
}
if failed {
bail!("Linting failed");
}
Ok(())
}
async fn subcommand_show_metadata(_cli: &Cli, args: &ShowMetadataArgs) -> Result<()> {
let metadata = read_metadata(&args.file)?;
println!("{metadata:?}");
Ok(())
}
async fn subcommand_set_metadata(_cli: &Cli, args: &SetMetadataArgs) -> Result<()> {
let mut bytes = fs::read(&args.file).await?;
if has_metadata(&bytes)? {
bail!("File already has metadata. Removing it is not yet supported.");
}
let metadata_bytes = fs::read(&args.metadata).await?;
wasm_gen::write_custom_section(&mut bytes, "nit_metadata", &metadata_bytes);
fs::write(&args.file, bytes).await?;
Ok(())
}
async fn subcommand_pre_commit(cli: &Cli) -> Result<()> {
let top_level = git::git_top_level()?;
let config = find_and_read_config(&top_level, &cli.config)?;
let files = git::git_staged_files(&top_level)?;
run(top_level, config, files).await
}
async fn subcommand_pre_push(cli: &Cli, args: &PrePushArgs) -> Result<()> {
todo!()
}
#[cfg(test)]
mod test {
use crate::config::Config;
#[test]
fn verify_sample_config() {
let sample_config = include_str!("../sample_config.json5");
let _config: Config = serde_json5::from_str(&sample_config).unwrap();
}
}