feat(nothooks): add phase-aware hook runner with setup-hook state tracking

This commit is contained in:
Joseph O'Brien
2026-03-31 17:26:26 -04:00
parent ed11aa9bc3
commit 4cc6fb022d
6 changed files with 262 additions and 1 deletions

View File

@@ -1 +1,31 @@
// placeholder
pub mod runner;
pub mod state;
pub use runner::HookRunner;
use notcore::{HookPhase, HookSpec, Report, StepStatus};
#[derive(Debug, PartialEq)]
pub enum HookResult {
Ok,
Skipped,
Failed(String),
}
/// Run all hooks matching `phase` and collect into a `Report`.
pub fn run_phase(
hooks: &[HookSpec],
phase: &HookPhase,
runner: &HookRunner,
) -> Report {
let mut report = Report::default();
for hook in hooks.iter().filter(|h| &h.phase == phase) {
let result = runner.run_hook(hook);
let status = match &result {
HookResult::Ok => StepStatus::Ok,
HookResult::Skipped => StepStatus::Skipped,
HookResult::Failed(msg) => StepStatus::Failed(msg.clone()),
};
report.add(&hook.name, status);
}
report
}

View File

@@ -0,0 +1,72 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use nothooks::{run_phase, HookRunner};
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "nothooks", about = "Bootstrap hook runner")]
struct Cli {
#[command(subcommand)]
command: Cmd,
/// Force re-run of setup hooks
#[arg(long, global = true)]
force: bool,
/// Path to hooks config TOML
#[arg(long, global = true, default_value = "notstrap.toml")]
config: PathBuf,
/// Directory for state file (default: current dir)
#[arg(long, global = true)]
state_dir: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Cmd {
/// Run hooks for a phase
Run {
/// Phase to run: dot or setup
#[arg(long)]
phase: String,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let state_dir = cli
.state_dir
.unwrap_or_else(|| std::env::current_dir().unwrap());
let content = std::fs::read_to_string(&cli.config)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", cli.config.display()))?;
#[derive(serde::Deserialize)]
struct HooksFile {
hooks: Vec<HookSpec>,
}
let file: HooksFile = toml::from_str(&content)?;
let phase = match cli.command {
Cmd::Run { ref phase } => match phase.as_str() {
"dot" => HookPhase::Dot,
"setup" => HookPhase::Setup,
other => anyhow::bail!("unknown phase '{other}', use dot or setup"),
},
};
let runner = if cli.force {
HookRunner::with_force(state_dir)
} else {
HookRunner::new(state_dir)
};
let report = run_phase(&file.hooks, &phase, &runner);
report.print();
if report.has_failures() {
std::process::exit(1);
}
Ok(())
}

View File

@@ -0,0 +1,44 @@
use std::path::PathBuf;
use std::process::Command;
use notcore::{HookPhase, HookSpec};
use crate::HookResult;
use crate::state::HookState;
pub struct HookRunner {
state_dir: PathBuf,
force: bool,
}
impl HookRunner {
pub fn new(state_dir: PathBuf) -> Self {
Self { state_dir, force: false }
}
pub fn with_force(state_dir: PathBuf) -> Self {
Self { state_dir, force: true }
}
pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) {
return HookResult::Skipped;
}
let result = Command::new("nu")
.arg(&spec.script)
.status();
match result {
Ok(status) if status.success() => {
if spec.phase == HookPhase::Setup {
state.mark_done(&spec.name);
let _ = state.save(&self.state_dir);
}
HookResult::Ok
}
Ok(status) => HookResult::Failed(format!("exit code {}", status.code().unwrap_or(-1))),
Err(e) => HookResult::Failed(e.to_string()),
}
}
}

View File

@@ -0,0 +1,36 @@
use std::collections::HashSet;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
const STATE_FILE: &str = ".nothooks-state.toml";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct HookState {
pub completed_setup_hooks: HashSet<String>,
}
impl HookState {
pub fn load(dir: &Path) -> Result<Self> {
let path = dir.join(STATE_FILE);
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&content)?)
}
pub fn save(&self, dir: &Path) -> Result<()> {
let path = dir.join(STATE_FILE);
std::fs::write(path, toml::to_string(self)?)?;
Ok(())
}
pub fn mark_done(&mut self, name: &str) {
self.completed_setup_hooks.insert(name.to_string());
}
pub fn is_done(&self, name: &str) -> bool {
self.completed_setup_hooks.contains(name)
}
}