use std::path::{Path, PathBuf};
use anyhow::Context as _;
use worktrunk::HookType;
use worktrunk::config::{Approvals, Command, CommandConfig, ProjectConfig, UserConfig};
use worktrunk::git::add_hook_skip_hint;
use super::command_approval::approve_command_batch;
use super::command_executor::{
CommandContext, FailureStrategy, PipelineKind, execute_pipeline_foreground, prepare_steps,
};
use super::hook_announcement::SourcedStep;
use super::hook_filter::HookSource;
use super::hooks::{
HookAnnouncer, into_source_groups, lookup_hook_configs, sourced_steps_to_foreground,
};
use super::project_config::{ApprovableCommand, Phase};
type Selection = Vec<(HookSource, CommandConfig)>;
struct PlanEntry {
hook_type: HookType,
anchor: PathBuf,
selection: Selection,
}
pub struct HookPlan {
entries: Vec<PlanEntry>,
}
pub struct HookPlanBuilder<'a> {
entries: Vec<PlanEntry>,
project_config: Option<&'a ProjectConfig>,
user: &'a UserConfig,
project_id: Option<&'a str>,
}
impl<'a> HookPlanBuilder<'a> {
pub fn new(
project_config: Option<&'a ProjectConfig>,
user: &'a UserConfig,
project_id: Option<&'a str>,
) -> Self {
Self {
entries: Vec::new(),
project_config,
user,
project_id,
}
}
pub fn add(&mut self, anchor: &Path, hook_types: &[HookType]) -> &mut Self {
let user_hooks = self.user.hooks(self.project_id);
for &hook_type in hook_types {
let (user_cfg, proj_cfg) =
lookup_hook_configs(&user_hooks, self.project_config, hook_type);
let mut selection: Selection = Vec::new();
if let Some(cfg) = user_cfg {
selection.push((HookSource::User, cfg.clone()));
}
if let Some(cfg) = proj_cfg {
selection.push((HookSource::Project, cfg.clone()));
}
if selection.is_empty() {
continue;
}
match self
.entries
.iter_mut()
.find(|e| e.hook_type == hook_type && e.anchor == anchor)
{
Some(e) => {
e.selection.extend(selection);
e.selection.sort_by_key(|(source, _)| *source);
}
None => self.entries.push(PlanEntry {
hook_type,
anchor: anchor.to_path_buf(),
selection,
}),
}
}
self
}
pub fn finish(self) -> HookPlan {
HookPlan {
entries: self.entries,
}
}
}
impl HookPlan {
fn approvable(&self) -> Vec<ApprovableCommand> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for entry in &self.entries {
for (source, cfg) in &entry.selection {
if *source != HookSource::Project {
continue;
}
for cmd in cfg.commands() {
if seen.insert(cmd.template.clone()) {
out.push(ApprovableCommand {
phase: Phase::Hook(entry.hook_type),
command: Command::new(cmd.name.clone(), cmd.template.clone()),
});
}
}
}
}
out
}
pub fn approve(
self,
project_id: Option<&str>,
yes: bool,
) -> anyhow::Result<Option<ApprovedHookPlan>> {
let approvable = self.approvable();
if approvable.is_empty() {
return Ok(Some(ApprovedHookPlan {
entries: self.entries,
}));
}
let project_id =
project_id.context("project identifier is required to approve project commands")?;
let approved = if yes {
true
} else {
let approvals = Approvals::load().context("Failed to load approvals")?;
approve_command_batch(&approvable, project_id, &approvals, false, false)?
};
if !approved {
return Ok(None);
}
Ok(Some(ApprovedHookPlan {
entries: self.entries,
}))
}
pub fn approve_readonly(
self,
approvals: &Approvals,
project_id: Option<&str>,
) -> ApprovedHookPlan {
let mut entries = self.entries;
for entry in &mut entries {
entry.selection.retain(|(source, cfg)| {
*source != HookSource::Project
|| project_id.is_some_and(|pid| {
cfg.commands()
.all(|c| approvals.is_command_approved(pid, &c.template))
})
});
}
entries.retain(|e| !e.selection.is_empty());
ApprovedHookPlan { entries }
}
pub fn unapproved_project_commands(
&self,
approvals: &Approvals,
project_id: Option<&str>,
) -> Vec<String> {
let Some(pid) = project_id else {
return Vec::new();
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for entry in &self.entries {
for (source, cfg) in &entry.selection {
if *source != HookSource::Project {
continue;
}
for cmd in cfg.commands() {
if !approvals.is_command_approved(pid, &cmd.template)
&& seen.insert(cmd.template.clone())
{
out.push(cmd.template.clone());
}
}
}
}
out
}
}
pub struct ApprovedHookPlan {
entries: Vec<PlanEntry>,
}
impl ApprovedHookPlan {
pub fn empty() -> Self {
Self {
entries: Vec::new(),
}
}
fn lookup(&self, hook_type: HookType, anchor: &Path) -> &[(HookSource, CommandConfig)] {
self.entries
.iter()
.find(|e| e.hook_type == hook_type && e.anchor == anchor)
.map(|e| e.selection.as_slice())
.unwrap_or(&[])
}
}
fn render_planned(
entries: &[(HookSource, CommandConfig)],
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
hook_type: HookType,
) -> anyhow::Result<Vec<SourcedStep>> {
let mut out = Vec::new();
for (source, cfg) in entries {
let is_pipeline = cfg.is_pipeline();
for step in prepare_steps(cfg, ctx, extra_vars, hook_type, *source)? {
out.push(SourcedStep {
step,
source: *source,
is_pipeline,
});
}
}
Ok(out)
}
pub fn execute_planned_hook(
plan: &ApprovedHookPlan,
anchor: &Path,
ctx: &CommandContext<'_>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
failure_strategy: FailureStrategy,
display_path: Option<&Path>,
) -> anyhow::Result<()> {
let sourced = render_planned(plan.lookup(hook_type, anchor), ctx, extra_vars, hook_type)?;
if sourced.is_empty() {
return Ok(());
}
let kind = PipelineKind::Hook {
hook_type,
display_path: display_path.map(Path::to_path_buf),
};
let foreground = sourced_steps_to_foreground(sourced, &kind);
execute_pipeline_foreground(&foreground, ctx.repo, ctx.worktree_path, failure_strategy)
.map_err(add_hook_skip_hint)
}
pub fn register_planned(
announcer: &mut HookAnnouncer<'_>,
plan: &ApprovedHookPlan,
anchor: &Path,
ctx: &CommandContext<'_>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
display_path: Option<&Path>,
) -> anyhow::Result<()> {
let sourced = render_planned(plan.lookup(hook_type, anchor), ctx, extra_vars, hook_type)
.context("failed to render planned hooks")?;
let dp = display_path.map(Path::to_path_buf);
announcer.extend(
into_source_groups(sourced)
.into_iter()
.map(|g| (*ctx, hook_type, dp.clone(), g)),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn project_cfg(toml: &str) -> ProjectConfig {
toml::from_str(toml).unwrap()
}
#[test]
fn approved_plan_lookup_is_frozen_and_anchor_scoped() {
let user = UserConfig::default();
let gate_cfg = project_cfg(r#"post-merge = "echo approved""#);
let mut builder = HookPlanBuilder::new(Some(&gate_cfg), &user, None);
builder.add(Path::new("/dest"), &[HookType::PostMerge]);
let plan = builder
.finish()
.approve(Some("proj"), true)
.unwrap()
.expect("yes-approval never declines");
let sel = plan.lookup(HookType::PostMerge, Path::new("/dest"));
assert_eq!(sel.len(), 1);
let (source, cfg) = &sel[0];
assert_eq!(*source, HookSource::Project);
let templates: Vec<_> = cfg.commands().map(|c| c.template.as_str()).collect();
assert_eq!(templates, vec!["echo approved"]);
assert!(
plan.lookup(HookType::PreMerge, Path::new("/dest"))
.is_empty()
);
}
#[cfg(unix)]
#[test]
fn approve_readonly_drops_unapproved_project_keeps_user() {
let user = UserConfig {
hooks: toml::from_str(r#"pre-remove = "echo user-hook""#).unwrap(),
..UserConfig::default()
};
let proj = project_cfg(r#"pre-remove = "echo project-hook""#);
let mut builder = HookPlanBuilder::new(Some(&proj), &user, None);
builder.add(Path::new("/wt"), &[HookType::PreRemove]);
let plan = builder
.finish()
.approve_readonly(&Approvals::default(), Some("proj"));
let sel = plan.lookup(HookType::PreRemove, Path::new("/wt"));
assert_eq!(
sel.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
vec![HookSource::User],
"unapproved project pipeline dropped, user pipeline kept"
);
let temp_dir = tempfile::tempdir().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let mut approvals = Approvals::default();
approvals
.approve_command(
"proj".to_string(),
"echo project-hook".to_string(),
&approvals_path,
)
.unwrap();
let mut builder = HookPlanBuilder::new(Some(&proj), &user, None);
builder.add(Path::new("/wt"), &[HookType::PreRemove]);
let plan = builder.finish().approve_readonly(&approvals, Some("proj"));
let sel = plan.lookup(HookType::PreRemove, Path::new("/wt"));
assert_eq!(
sel.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
vec![HookSource::User, HookSource::Project],
);
}
#[test]
fn duplicate_add_keeps_sources_grouped() {
let user = UserConfig {
hooks: toml::from_str(r#"post-merge = "echo u""#).unwrap(),
..UserConfig::default()
};
let proj = project_cfg(r#"post-merge = "echo p""#);
let mut builder = HookPlanBuilder::new(Some(&proj), &user, None);
builder.add(Path::new("/dest"), &[HookType::PostMerge]);
builder.add(Path::new("/dest"), &[HookType::PostMerge]);
let plan = builder
.finish()
.approve(Some("proj"), true)
.unwrap()
.unwrap();
let sources: Vec<_> = plan
.lookup(HookType::PostMerge, Path::new("/dest"))
.iter()
.map(|(s, _)| *s)
.collect();
let first_project = sources.iter().position(|s| *s == HookSource::Project);
assert!(
first_project.is_none_or(|i| sources[i..].iter().all(|s| *s == HookSource::Project)),
"sources interleaved: {sources:?}"
);
}
}