use crate::config::expand;
use crate::error::{OrchestratorError, Result};
use crate::events::{ExecutionEvent, LogEntry};
use crate::orchestration::output::OutputHandler;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tracing::{debug, error, info, warn};
pub const DEFAULT_HOOK_TIMEOUT: u64 = 60;
pub const OPENSPEC_GIT_COMMIT_NO_VERIFY_ENV: &str = "OPENSPEC_GIT_COMMIT_NO_VERIFY";
pub const HOOK_OUTPUT_TRUNCATE_BYTES: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(dead_code)]
pub enum HookType {
OnStart,
OnFinish,
OnError,
OnChangeStart,
PreApply,
PostApply,
OnChangeComplete,
PreArchive,
PostArchive,
OnChangeEnd,
OnMerged,
OnQueueAdd,
OnQueueRemove,
}
impl HookType {
pub fn config_key(&self) -> &'static str {
match self {
HookType::OnStart => "on_start",
HookType::OnFinish => "on_finish",
HookType::OnError => "on_error",
HookType::OnChangeStart => "on_change_start",
HookType::PreApply => "pre_apply",
HookType::PostApply => "post_apply",
HookType::OnChangeComplete => "on_change_complete",
HookType::PreArchive => "pre_archive",
HookType::PostArchive => "post_archive",
HookType::OnChangeEnd => "on_change_end",
HookType::OnMerged => "on_merged",
HookType::OnQueueAdd => "on_queue_add",
HookType::OnQueueRemove => "on_queue_remove",
}
}
}
impl std::fmt::Display for HookType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.config_key())
}
}
pub const DEFAULT_HOOK_MAX_RETRIES: u32 = 0;
pub const DEFAULT_HOOK_RETRY_DELAY_SECS: u64 = 3;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookConfig {
pub command: String,
#[serde(default = "default_continue_on_failure")]
pub continue_on_failure: bool,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default)]
pub git_commit_no_verify: bool,
#[serde(default)]
pub max_retries: u32,
#[serde(default = "default_retry_delay_secs")]
pub retry_delay_secs: u64,
}
fn default_continue_on_failure() -> bool {
true
}
fn default_timeout() -> u64 {
DEFAULT_HOOK_TIMEOUT
}
fn default_retry_delay_secs() -> u64 {
DEFAULT_HOOK_RETRY_DELAY_SECS
}
fn default_index_lock_wait_secs() -> u64 {
DEFAULT_INDEX_LOCK_WAIT_SECS
}
impl HookConfig {
pub fn from_command(command: String) -> Self {
Self {
command,
continue_on_failure: true,
timeout: DEFAULT_HOOK_TIMEOUT,
git_commit_no_verify: false,
max_retries: DEFAULT_HOOK_MAX_RETRIES,
retry_delay_secs: DEFAULT_HOOK_RETRY_DELAY_SECS,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum HookConfigValue {
Simple(String),
Full(HookConfig),
}
impl<'de> Deserialize<'de> for HookConfigValue {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, MapAccess, Visitor};
use std::fmt;
struct HookConfigValueVisitor;
impl<'de> Visitor<'de> for HookConfigValueVisitor {
type Value = HookConfigValue;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or a hook configuration object")
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(HookConfigValue::Simple(value.to_string()))
}
fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(HookConfigValue::Simple(value))
}
fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let config = HookConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(HookConfigValue::Full(config))
}
}
deserializer.deserialize_any(HookConfigValueVisitor)
}
}
impl HookConfigValue {
pub fn into_hook_config(self) -> HookConfig {
match self {
HookConfigValue::Simple(cmd) => HookConfig::from_command(cmd),
HookConfigValue::Full(config) => config,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HooksConfig {
#[serde(default = "default_index_lock_wait_secs")]
pub index_lock_wait_secs: u64,
#[serde(default)]
pub on_start: Option<HookConfigValue>,
#[serde(default)]
pub on_finish: Option<HookConfigValue>,
#[serde(default)]
pub on_error: Option<HookConfigValue>,
#[serde(default)]
pub on_change_start: Option<HookConfigValue>,
#[serde(default)]
pub pre_apply: Option<HookConfigValue>,
#[serde(default)]
pub post_apply: Option<HookConfigValue>,
#[serde(default)]
pub on_change_complete: Option<HookConfigValue>,
#[serde(default)]
pub pre_archive: Option<HookConfigValue>,
#[serde(default)]
pub post_archive: Option<HookConfigValue>,
#[serde(default)]
pub on_change_end: Option<HookConfigValue>,
#[serde(default)]
pub on_merged: Option<HookConfigValue>,
#[serde(default)]
pub on_queue_add: Option<HookConfigValue>,
#[serde(default)]
pub on_queue_remove: Option<HookConfigValue>,
}
impl HooksConfig {
pub fn merge(&mut self, other: Self) {
macro_rules! merge_hook {
($field:ident) => {
if other.$field.is_some() {
self.$field = other.$field;
}
};
}
merge_hook!(on_start);
merge_hook!(on_finish);
merge_hook!(on_error);
merge_hook!(on_change_start);
merge_hook!(pre_apply);
merge_hook!(post_apply);
merge_hook!(on_change_complete);
merge_hook!(pre_archive);
merge_hook!(post_archive);
merge_hook!(on_change_end);
merge_hook!(on_merged);
merge_hook!(on_queue_add);
merge_hook!(on_queue_remove);
}
pub fn get(&self, hook_type: HookType) -> Option<HookConfig> {
let value = match hook_type {
HookType::OnStart => self.on_start.clone(),
HookType::OnFinish => self.on_finish.clone(),
HookType::OnError => self.on_error.clone(),
HookType::OnChangeStart => self.on_change_start.clone(),
HookType::PreApply => self.pre_apply.clone(),
HookType::PostApply => self.post_apply.clone(),
HookType::OnChangeComplete => self.on_change_complete.clone(),
HookType::PreArchive => self.pre_archive.clone(),
HookType::PostArchive => self.post_archive.clone(),
HookType::OnChangeEnd => self.on_change_end.clone(),
HookType::OnMerged => self.on_merged.clone(),
HookType::OnQueueAdd => self.on_queue_add.clone(),
HookType::OnQueueRemove => self.on_queue_remove.clone(),
};
value.map(|v| v.into_hook_config())
}
#[allow(dead_code)]
pub fn has_any_hooks(&self) -> bool {
self.on_start.is_some()
|| self.on_finish.is_some()
|| self.on_error.is_some()
|| self.on_change_start.is_some()
|| self.pre_apply.is_some()
|| self.post_apply.is_some()
|| self.on_change_complete.is_some()
|| self.pre_archive.is_some()
|| self.post_archive.is_some()
|| self.on_change_end.is_some()
|| self.on_merged.is_some()
|| self.on_queue_add.is_some()
|| self.on_queue_remove.is_some()
}
}
#[derive(Debug, Clone, Default)]
pub struct HookContext {
pub change_id: Option<String>,
pub changes_processed: usize,
pub total_changes: usize,
pub remaining_changes: usize,
pub completed_tasks: Option<u32>,
pub total_tasks: Option<u32>,
pub apply_count: u32,
pub status: Option<String>,
pub error: Option<String>,
pub dry_run: bool,
pub workspace_path: Option<String>,
pub group_index: Option<u32>,
}
impl HookContext {
pub fn new(
changes_processed: usize,
total_changes: usize,
remaining_changes: usize,
dry_run: bool,
) -> Self {
Self {
changes_processed,
total_changes,
remaining_changes,
dry_run,
..Default::default()
}
}
pub fn with_change(mut self, change_id: &str, completed_tasks: u32, total_tasks: u32) -> Self {
self.change_id = Some(change_id.to_string());
self.completed_tasks = Some(completed_tasks);
self.total_tasks = Some(total_tasks);
self
}
pub fn with_apply_count(mut self, apply_count: u32) -> Self {
self.apply_count = apply_count;
self
}
pub fn with_status(mut self, status: &str) -> Self {
self.status = Some(status.to_string());
self
}
pub fn with_error(mut self, error: &str) -> Self {
self.error = Some(error.to_string());
self
}
pub fn with_parallel_context(mut self, workspace_path: &str, group_index: Option<u32>) -> Self {
self.workspace_path = Some(workspace_path.to_string());
self.group_index = group_index;
self
}
pub fn to_env_vars(&self) -> HashMap<String, String> {
let mut vars = HashMap::new();
if let Some(ref change_id) = self.change_id {
vars.insert("OPENSPEC_CHANGE_ID".to_string(), change_id.clone());
}
vars.insert(
"OPENSPEC_CHANGES_PROCESSED".to_string(),
self.changes_processed.to_string(),
);
vars.insert(
"OPENSPEC_TOTAL_CHANGES".to_string(),
self.total_changes.to_string(),
);
vars.insert(
"OPENSPEC_REMAINING_CHANGES".to_string(),
self.remaining_changes.to_string(),
);
if let Some(completed) = self.completed_tasks {
vars.insert(
"OPENSPEC_COMPLETED_TASKS".to_string(),
completed.to_string(),
);
}
if let Some(total) = self.total_tasks {
vars.insert("OPENSPEC_TOTAL_TASKS".to_string(), total.to_string());
}
vars.insert(
"OPENSPEC_APPLY_COUNT".to_string(),
self.apply_count.to_string(),
);
if let Some(ref status) = self.status {
vars.insert("OPENSPEC_STATUS".to_string(), status.clone());
}
if let Some(ref error) = self.error {
vars.insert("OPENSPEC_ERROR".to_string(), error.clone());
}
vars.insert("OPENSPEC_DRY_RUN".to_string(), self.dry_run.to_string());
if let Some(ref workspace_path) = self.workspace_path {
vars.insert(
"OPENSPEC_WORKSPACE_PATH".to_string(),
workspace_path.clone(),
);
}
if let Some(group_index) = self.group_index {
vars.insert("OPENSPEC_GROUP_INDEX".to_string(), group_index.to_string());
}
vars
}
pub fn expand_placeholders(&self, template: &str) -> String {
let mut result = template.to_string();
if let Some(ref change_id) = self.change_id {
result = expand::expand_placeholder(&result, "{change_id}", change_id);
}
result = expand::expand_placeholder(
&result,
"{changes_processed}",
&self.changes_processed.to_string(),
);
result =
expand::expand_placeholder(&result, "{total_changes}", &self.total_changes.to_string());
result = expand::expand_placeholder(
&result,
"{remaining_changes}",
&self.remaining_changes.to_string(),
);
if let Some(completed) = self.completed_tasks {
result =
expand::expand_placeholder(&result, "{completed_tasks}", &completed.to_string());
}
if let Some(total) = self.total_tasks {
result = expand::expand_placeholder(&result, "{total_tasks}", &total.to_string());
}
result =
expand::expand_placeholder(&result, "{apply_count}", &self.apply_count.to_string());
if let Some(ref status) = self.status {
result = expand::expand_placeholder(&result, "{status}", status);
}
if let Some(ref error) = self.error {
result = expand::expand_placeholder(&result, "{error}", error);
}
result
}
}
fn truncate_hook_output(s: &str, limit: usize) -> (&str, bool) {
if s.len() <= limit {
return (s, false);
}
let mut boundary = limit;
while boundary > 0 && !s.is_char_boundary(boundary) {
boundary -= 1;
}
(&s[..boundary], true)
}
pub const DEFAULT_INDEX_LOCK_WAIT_SECS: u64 = 10;
#[derive(Clone)]
pub struct HookRunner {
config: HooksConfig,
repo_root: PathBuf,
event_tx: Option<mpsc::Sender<ExecutionEvent>>,
output_handler: Option<Arc<dyn OutputHandler>>,
}
impl std::fmt::Debug for HookRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HookRunner")
.field("config", &self.config)
.field("repo_root", &self.repo_root)
.field("event_tx", &self.event_tx.is_some())
.field("output_handler", &self.output_handler.is_some())
.finish()
}
}
impl HookRunner {
pub fn new(config: HooksConfig, repo_root: impl Into<PathBuf>) -> Self {
Self {
config,
repo_root: repo_root.into(),
event_tx: None,
output_handler: None,
}
}
pub fn with_output_handler(
config: HooksConfig,
repo_root: impl Into<PathBuf>,
output_handler: Arc<dyn OutputHandler>,
) -> Self {
Self {
config,
repo_root: repo_root.into(),
event_tx: None,
output_handler: Some(output_handler),
}
}
pub fn with_event_tx(
config: HooksConfig,
repo_root: impl Into<PathBuf>,
event_tx: mpsc::Sender<ExecutionEvent>,
) -> Self {
Self {
config,
repo_root: repo_root.into(),
event_tx: Some(event_tx),
output_handler: None,
}
}
#[allow(dead_code)]
pub fn empty() -> Self {
Self {
config: HooksConfig::default(),
repo_root: PathBuf::from("."),
event_tx: None,
output_handler: None,
}
}
#[allow(dead_code)]
pub fn has_hook(&self, hook_type: HookType) -> bool {
self.config.get(hook_type).is_some()
}
async fn wait_for_index_lock_release(&self, max_wait_secs: u64) -> bool {
let lock_path = self.repo_root.join(".git/index.lock");
if !lock_path.exists() {
return true;
}
info!(
"Waiting for .git/index.lock release (max {}s)...",
max_wait_secs
);
let poll_interval = Duration::from_millis(500);
let max_wait = Duration::from_secs(max_wait_secs);
let start = tokio::time::Instant::now();
loop {
tokio::time::sleep(poll_interval).await;
if !lock_path.exists() {
info!(
".git/index.lock released after {:.1}s",
start.elapsed().as_secs_f64()
);
return true;
}
if start.elapsed() >= max_wait {
warn!(
".git/index.lock still present after {}s, proceeding anyway",
max_wait_secs
);
return false;
}
}
}
async fn emit_hook_output(&self, hook_type: HookType, stdout: &str, stderr: &str) {
if !stdout.is_empty() {
let (display, was_truncated) = truncate_hook_output(stdout, HOOK_OUTPUT_TRUNCATE_BYTES);
let mut msg = format!("{} hook stdout: {}", hook_type, display);
if was_truncated {
msg.push_str(&format!(
"\n[... {} bytes truncated]",
stdout.len() - HOOK_OUTPUT_TRUNCATE_BYTES
));
}
if let Some(ref tx) = self.event_tx {
let _ = tx
.send(ExecutionEvent::Log(LogEntry::info(msg.clone())))
.await;
}
if let Some(ref handler) = self.output_handler {
handler.on_stdout(&msg);
}
}
if !stderr.is_empty() {
let (display, was_truncated) = truncate_hook_output(stderr, HOOK_OUTPUT_TRUNCATE_BYTES);
let mut msg = format!("{} hook stderr: {}", hook_type, display);
if was_truncated {
msg.push_str(&format!(
"\n[... {} bytes truncated]",
stderr.len() - HOOK_OUTPUT_TRUNCATE_BYTES
));
}
if let Some(ref tx) = self.event_tx {
let _ = tx
.send(ExecutionEvent::Log(LogEntry::warn(msg.clone())))
.await;
}
if let Some(ref handler) = self.output_handler {
handler.on_stderr(&msg);
}
}
}
pub async fn run_hook(&self, hook_type: HookType, context: &HookContext) -> Result<()> {
let Some(hook_config) = self.config.get(hook_type) else {
debug!("No hook configured for {}", hook_type);
return Ok(());
};
let command = context.expand_placeholders(&hook_config.command);
let mut env_vars = context.to_env_vars();
env_vars.insert(
OPENSPEC_GIT_COMMIT_NO_VERIFY_ENV.to_string(),
hook_config.git_commit_no_verify.to_string(),
);
let timeout_duration = Duration::from_secs(hook_config.timeout);
info!(
module = module_path!(),
"Running {} hook: {}", hook_type, command
);
debug!("Hook timeout: {}s", hook_config.timeout);
let cmd_msg = format!("Running {} hook: {}", hook_type, command);
if let Some(ref tx) = self.event_tx {
let _ = tx
.send(ExecutionEvent::Log(LogEntry::info(cmd_msg.clone())))
.await;
}
if let Some(ref handler) = self.output_handler {
handler.on_info(&cmd_msg);
}
if hook_type == HookType::OnMerged {
self.wait_for_index_lock_release(self.config.index_lock_wait_secs)
.await;
}
let max_attempts = 1 + hook_config.max_retries;
let mut last_result: Result<()> = Ok(());
for attempt in 1..=max_attempts {
match self
.execute_hook(hook_type, &command, &env_vars, timeout_duration)
.await
{
Ok((success, stdout, stderr)) => {
self.emit_hook_output(hook_type, &stdout, &stderr).await;
if success {
info!("{} hook completed successfully", hook_type);
return Ok(());
}
if attempt < max_attempts {
warn!(
"{} hook failed (attempt {}/{}), retrying in {}s...",
hook_type, attempt, max_attempts, hook_config.retry_delay_secs
);
tokio::time::sleep(Duration::from_secs(hook_config.retry_delay_secs)).await;
last_result = Err(OrchestratorError::HookFailed {
hook_type: hook_type.to_string(),
message: "Hook command returned non-zero exit code".to_string(),
});
continue;
}
if hook_config.continue_on_failure {
warn!(
"{} hook failed after {} attempt(s), continuing due to continue_on_failure=true",
hook_type, max_attempts
);
return Ok(());
} else {
error!(
"{} hook failed after {} attempt(s)",
hook_type, max_attempts
);
return Err(OrchestratorError::HookFailed {
hook_type: hook_type.to_string(),
message: format!(
"Hook command returned non-zero exit code after {} attempt(s)",
max_attempts
),
});
}
}
Err(e) => {
if attempt < max_attempts {
warn!(
"{} hook error (attempt {}/{}): {}, retrying in {}s...",
hook_type, attempt, max_attempts, e, hook_config.retry_delay_secs
);
tokio::time::sleep(Duration::from_secs(hook_config.retry_delay_secs)).await;
last_result = Err(e);
continue;
}
if hook_config.continue_on_failure {
warn!(
"{} hook failed after {} attempt(s): {} (continuing due to continue_on_failure=true)",
hook_type, max_attempts, e
);
return Ok(());
} else {
error!(
"{} hook failed after {} attempt(s): {}",
hook_type, max_attempts, e
);
return Err(e);
}
}
}
}
last_result
}
async fn execute_hook(
&self,
hook_type: HookType,
command: &str,
env_vars: &HashMap<String, String>,
timeout_duration: Duration,
) -> Result<(bool, String, String)> {
let mut cmd = crate::shell_command::build_login_shell_command(command);
cmd.current_dir(&self.repo_root);
debug!(
module = module_path!(),
"Executing {} hook command via login shell in {:?}: {}",
hook_type,
self.repo_root,
command
);
for (key, value) in env_vars {
cmd.env(key, value);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| OrchestratorError::HookFailed {
hook_type: hook_type.to_string(),
message: format!("Failed to spawn hook process: {}", e),
})?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
match timeout(timeout_duration, child.wait()).await {
Ok(result) => {
let status = result.map_err(|e| OrchestratorError::HookFailed {
hook_type: hook_type.to_string(),
message: format!("Failed to wait for hook process: {}", e),
})?;
let mut stdout_output = String::new();
if let Some(mut stdout_pipe) = stdout {
let mut buf = Vec::new();
if (stdout_pipe.read_to_end(&mut buf).await).is_ok() {
if let Ok(s) = String::from_utf8(buf) {
stdout_output = s;
}
}
}
let mut stderr_output = String::new();
if let Some(mut stderr_pipe) = stderr {
let mut buf = Vec::new();
if (stderr_pipe.read_to_end(&mut buf).await).is_ok() {
if let Ok(s) = String::from_utf8(buf) {
stderr_output = s;
}
}
}
Ok((status.success(), stdout_output, stderr_output))
}
Err(_) => Err(OrchestratorError::HookTimeout {
hook_type: hook_type.to_string(),
timeout_secs: timeout_duration.as_secs(),
}),
}
}
#[cfg(test)]
#[allow(dead_code)]
pub fn config(&self) -> &HooksConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_type_display() {
assert_eq!(HookType::OnStart.to_string(), "on_start");
assert_eq!(HookType::PreApply.to_string(), "pre_apply");
assert_eq!(HookType::OnFinish.to_string(), "on_finish");
}
#[test]
fn test_hook_config_from_command() {
let config = HookConfig::from_command("echo test".to_string());
assert_eq!(config.command, "echo test");
assert!(config.continue_on_failure);
assert_eq!(config.timeout, DEFAULT_HOOK_TIMEOUT);
assert!(!config.git_commit_no_verify);
}
#[test]
fn test_hook_context_expand_placeholders() {
let context = HookContext::new(2, 5, 3, false)
.with_change("test-change", 3, 10)
.with_apply_count(1)
.with_status("completed");
let template = "Change {change_id} processed {changes_processed} of {total_changes} remaining {remaining_changes} apply {apply_count}";
let result = context.expand_placeholders(template);
assert_eq!(
result,
"Change test-change processed 2 of 5 remaining 3 apply 1"
);
}
#[test]
fn test_hook_context_to_env_vars() {
let context = HookContext::new(1, 5, 3, true)
.with_change("my-change", 2, 10)
.with_apply_count(2);
let vars = context.to_env_vars();
assert_eq!(
vars.get("OPENSPEC_CHANGE_ID"),
Some(&"my-change".to_string())
);
assert_eq!(
vars.get("OPENSPEC_CHANGES_PROCESSED"),
Some(&"1".to_string())
);
assert_eq!(vars.get("OPENSPEC_TOTAL_CHANGES"), Some(&"5".to_string()));
assert_eq!(
vars.get("OPENSPEC_REMAINING_CHANGES"),
Some(&"3".to_string())
);
assert_eq!(vars.get("OPENSPEC_COMPLETED_TASKS"), Some(&"2".to_string()));
assert_eq!(vars.get("OPENSPEC_TOTAL_TASKS"), Some(&"10".to_string()));
assert_eq!(vars.get("OPENSPEC_APPLY_COUNT"), Some(&"2".to_string()));
assert_eq!(vars.get("OPENSPEC_DRY_RUN"), Some(&"true".to_string()));
assert!(!vars.contains_key(OPENSPEC_GIT_COMMIT_NO_VERIFY_ENV));
}
#[test]
fn test_hooks_config_deserialize_simple_string() {
let json = r#"{"on_start": "echo hello"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnStart).unwrap();
assert_eq!(hook.command, "echo hello");
assert!(hook.continue_on_failure);
assert_eq!(hook.timeout, DEFAULT_HOOK_TIMEOUT);
}
#[test]
fn test_hooks_config_deserialize_full_object() {
let json = r#"{
"on_start": {
"command": "echo hello",
"continue_on_failure": false,
"timeout": 120
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnStart).unwrap();
assert_eq!(hook.command, "echo hello");
assert!(!hook.continue_on_failure);
assert_eq!(hook.timeout, 120);
assert!(!hook.git_commit_no_verify);
}
#[test]
fn test_hooks_config_deserialize_git_commit_no_verify() {
let json = r#"{
"on_merged": {
"command": "make bump-patch",
"git_commit_no_verify": true
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnMerged).unwrap();
assert_eq!(hook.command, "make bump-patch");
assert!(hook.git_commit_no_verify);
assert!(hook.continue_on_failure);
assert_eq!(hook.timeout, DEFAULT_HOOK_TIMEOUT);
}
#[test]
fn test_hooks_config_deserialize_mixed() {
let json = r#"{
"on_start": "echo start",
"post_apply": {
"command": "cargo test",
"continue_on_failure": false,
"timeout": 300
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let on_start = config.get(HookType::OnStart).unwrap();
assert_eq!(on_start.command, "echo start");
assert!(on_start.continue_on_failure);
let post_apply = config.get(HookType::PostApply).unwrap();
assert_eq!(post_apply.command, "cargo test");
assert!(!post_apply.continue_on_failure);
assert_eq!(post_apply.timeout, 300);
}
#[test]
fn test_hooks_config_has_any_hooks() {
let empty = HooksConfig::default();
assert!(!empty.has_any_hooks());
let json = r#"{"on_start": "echo hello"}"#;
let with_hook: HooksConfig = serde_json::from_str(json).unwrap();
assert!(with_hook.has_any_hooks());
}
#[test]
fn test_hook_runner_has_hook() {
let json = r#"{"on_start": "echo hello"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
assert!(runner.has_hook(HookType::OnStart));
assert!(!runner.has_hook(HookType::PreApply));
}
#[tokio::test]
async fn test_hook_runner_run_hook_not_configured() {
let runner = HookRunner::empty();
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hook_runner_run_hook_success() {
let json = r#"{"on_start": "echo hello"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hook_runner_run_hook_failure_with_continue() {
let json = r#"{"on_start": {"command": "exit 1", "continue_on_failure": true}}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hook_runner_run_hook_failure_without_continue() {
let json = r#"{"on_start": {"command": "exit 1", "continue_on_failure": false}}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_err());
}
#[cfg(feature = "heavy-tests")]
#[tokio::test]
async fn test_hook_runner_timeout() {
let json =
r#"{"on_start": {"command": "sleep 10", "timeout": 1, "continue_on_failure": false}}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_err());
if let Err(OrchestratorError::HookTimeout { timeout_secs, .. }) = result {
assert_eq!(timeout_secs, 1);
} else {
panic!("Expected HookTimeout error");
}
}
#[tokio::test]
async fn test_hook_runner_with_env_vars() {
let json = r#"{"on_start": "echo $OPENSPEC_CHANGE_ID"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::new(1, 5, 3, false).with_change("test-id", 2, 10);
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hook_runner_exposes_git_commit_no_verify_true() {
let json = r#"{
"on_merged": {
"command": "test \"$OPENSPEC_GIT_COMMIT_NO_VERIFY\" = \"true\"",
"continue_on_failure": false,
"git_commit_no_verify": true
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let result = runner
.run_hook(HookType::OnMerged, &HookContext::default())
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hook_runner_exposes_git_commit_no_verify_false_by_default() {
let json = r#"{
"on_merged": {
"command": "test \"$OPENSPEC_GIT_COMMIT_NO_VERIFY\" = \"false\"",
"continue_on_failure": false
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let result = runner
.run_hook(HookType::OnMerged, &HookContext::default())
.await;
assert!(result.is_ok());
}
#[test]
fn test_hooks_config_on_queue_add() {
let json = r#"{"on_queue_add": "echo 'Added {change_id}'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnQueueAdd).unwrap();
assert_eq!(hook.command, "echo 'Added {change_id}'");
}
#[tokio::test]
async fn test_on_queue_add_hook_execution() {
let json = r#"{"on_queue_add": "echo added"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::new(0, 5, 5, false).with_change("test-change", 0, 3);
let result = runner.run_hook(HookType::OnQueueAdd, &context).await;
assert!(result.is_ok());
}
#[test]
fn test_hooks_config_on_queue_remove() {
let json = r#"{"on_queue_remove": "echo 'Removed {change_id}'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnQueueRemove).unwrap();
assert_eq!(hook.command, "echo 'Removed {change_id}'");
}
#[tokio::test]
async fn test_on_queue_remove_hook_execution() {
let json = r#"{"on_queue_remove": "echo removed"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::new(0, 5, 5, false).with_change("test-change", 0, 3);
let result = runner.run_hook(HookType::OnQueueRemove, &context).await;
assert!(result.is_ok());
}
#[test]
fn test_hooks_config_on_change_start() {
let json = r#"{"on_change_start": "echo 'Starting {change_id}'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnChangeStart).unwrap();
assert_eq!(hook.command, "echo 'Starting {change_id}'");
}
#[tokio::test]
async fn test_on_change_start_hook_receives_change_id() {
let json = r#"{"on_change_start": "echo test"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::new(0, 3, 3, false).with_change("add-feature", 0, 5);
let result = runner.run_hook(HookType::OnChangeStart, &context).await;
assert!(result.is_ok());
let vars = context.to_env_vars();
assert_eq!(
vars.get("OPENSPEC_CHANGE_ID"),
Some(&"add-feature".to_string())
);
}
#[test]
fn test_on_change_start_placeholder_expansion() {
let context = HookContext::new(0, 3, 3, false).with_change("my-change", 0, 5);
let template = "git commit -m 'changeset: {change_id}'";
let result = context.expand_placeholders(template);
assert_eq!(result, "git commit -m 'changeset: my-change'");
}
#[test]
fn test_hooks_config_on_change_end() {
let json = r#"{"on_change_end": "echo 'Finished {change_id}'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnChangeEnd).unwrap();
assert_eq!(hook.command, "echo 'Finished {change_id}'");
}
#[tokio::test]
async fn test_on_change_end_hook_execution() {
let json = r#"{"on_change_end": "echo finished"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::new(1, 3, 2, false).with_change("change-a", 5, 5);
let result = runner.run_hook(HookType::OnChangeEnd, &context).await;
assert!(result.is_ok());
}
#[test]
fn test_on_change_end_tracks_progress() {
let context = HookContext::new(1, 3, 2, false).with_change("change-a", 5, 5);
let template = "echo '{changes_processed}/{total_changes}'";
let result = context.expand_placeholders(template);
assert_eq!(result, "echo '1/3'");
}
#[test]
fn test_hook_types_config_key_order() {
assert_eq!(HookType::OnStart.config_key(), "on_start");
assert_eq!(HookType::OnChangeStart.config_key(), "on_change_start");
assert_eq!(HookType::PreApply.config_key(), "pre_apply");
assert_eq!(HookType::PostApply.config_key(), "post_apply");
assert_eq!(
HookType::OnChangeComplete.config_key(),
"on_change_complete"
);
assert_eq!(HookType::PreArchive.config_key(), "pre_archive");
assert_eq!(HookType::PostArchive.config_key(), "post_archive");
assert_eq!(HookType::OnChangeEnd.config_key(), "on_change_end");
assert_eq!(HookType::OnMerged.config_key(), "on_merged");
assert_eq!(HookType::OnFinish.config_key(), "on_finish");
}
#[test]
fn test_hook_runner_is_reusable_for_tui_and_cli() {
let json = r#"{
"on_change_start": "echo start",
"on_change_end": "echo end"
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config.clone(), ".");
let tui_context = HookContext::new(0, 3, 3, false).with_change("change-a", 0, 5);
let cli_context = HookContext::new(0, 3, 3, false).with_change("change-a", 0, 5);
assert_eq!(tui_context.to_env_vars(), cli_context.to_env_vars());
assert!(runner.has_hook(HookType::OnChangeStart));
assert!(runner.has_hook(HookType::OnChangeEnd));
}
#[test]
fn test_apply_count_increments() {
let context1 = HookContext::new(0, 1, 1, false)
.with_change("my-change", 1, 3)
.with_apply_count(1);
let context2 = HookContext::new(0, 1, 1, false)
.with_change("my-change", 2, 3)
.with_apply_count(2);
let context3 = HookContext::new(0, 1, 1, false)
.with_change("my-change", 3, 3)
.with_apply_count(3);
let template = "echo 'Apply #{apply_count}'";
assert_eq!(context1.expand_placeholders(template), "echo 'Apply #1'");
assert_eq!(context2.expand_placeholders(template), "echo 'Apply #2'");
assert_eq!(context3.expand_placeholders(template), "echo 'Apply #3'");
}
#[test]
fn test_on_finish_with_status_placeholder() {
let context = HookContext::new(3, 3, 0, false).with_status("completed");
let template = "echo 'Status: {status}, Changes: {changes_processed}/{total_changes}'";
let result = context.expand_placeholders(template);
assert_eq!(result, "echo 'Status: completed, Changes: 3/3'");
}
#[test]
fn test_on_finish_with_iteration_limit_status() {
let context = HookContext::new(2, 3, 1, false).with_status("iteration_limit");
let vars = context.to_env_vars();
assert_eq!(
vars.get("OPENSPEC_STATUS"),
Some(&"iteration_limit".to_string())
);
}
#[test]
fn test_on_error_with_error_placeholder() {
let context = HookContext::new(1, 3, 2, false)
.with_change("failing-change", 2, 5)
.with_error("LLM API timeout");
let template = "echo '[on_error] change={change_id} error={error}'";
let result = context.expand_placeholders(template);
assert_eq!(
result,
"echo '[on_error] change=failing-change error=LLM API timeout'"
);
}
#[test]
fn test_on_error_env_vars() {
let context = HookContext::new(1, 3, 2, false)
.with_change("failing-change", 2, 5)
.with_error("Connection refused");
let vars = context.to_env_vars();
assert_eq!(
vars.get("OPENSPEC_ERROR"),
Some(&"Connection refused".to_string())
);
assert_eq!(
vars.get("OPENSPEC_CHANGE_ID"),
Some(&"failing-change".to_string())
);
}
#[test]
fn test_on_start_has_no_change_id() {
let context = HookContext::new(0, 3, 3, false);
let template = "echo '{change_id}'";
let result = context.expand_placeholders(template);
assert_eq!(result, "echo '{change_id}'");
let template2 = "echo 'total={total_changes}'";
let result2 = context.expand_placeholders(template2);
assert_eq!(result2, "echo 'total=3'");
}
#[test]
fn test_all_hook_types_can_be_configured() {
let json = r#"{
"on_start": "echo start",
"on_finish": "echo finish",
"on_error": "echo error",
"on_change_start": "echo change_start",
"pre_apply": "echo pre_apply",
"post_apply": "echo post_apply",
"on_change_complete": "echo change_complete",
"pre_archive": "echo pre_archive",
"post_archive": "echo post_archive",
"on_change_end": "echo change_end",
"on_merged": "echo merged",
"on_queue_add": "echo queue_add",
"on_queue_remove": "echo queue_remove"
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
assert!(runner.has_hook(HookType::OnStart));
assert!(runner.has_hook(HookType::OnFinish));
assert!(runner.has_hook(HookType::OnError));
assert!(runner.has_hook(HookType::OnChangeStart));
assert!(runner.has_hook(HookType::PreApply));
assert!(runner.has_hook(HookType::PostApply));
assert!(runner.has_hook(HookType::OnChangeComplete));
assert!(runner.has_hook(HookType::PreArchive));
assert!(runner.has_hook(HookType::PostArchive));
assert!(runner.has_hook(HookType::OnChangeEnd));
assert!(runner.has_hook(HookType::OnMerged));
assert!(runner.has_hook(HookType::OnQueueAdd));
assert!(runner.has_hook(HookType::OnQueueRemove));
}
#[test]
fn test_hook_context_with_parallel_context() {
let context = HookContext::new(1, 5, 4, false)
.with_change("test-change", 3, 10)
.with_parallel_context("/tmp/workspace-test", Some(2));
assert_eq!(
context.workspace_path,
Some("/tmp/workspace-test".to_string())
);
assert_eq!(context.group_index, Some(2));
}
#[test]
fn test_hook_context_parallel_env_vars() {
let context = HookContext::new(2, 8, 6, false)
.with_change("parallel-change", 5, 10)
.with_parallel_context("/workspace/change-1", Some(3));
let vars = context.to_env_vars();
assert_eq!(
vars.get("OPENSPEC_WORKSPACE_PATH"),
Some(&"/workspace/change-1".to_string())
);
assert_eq!(vars.get("OPENSPEC_GROUP_INDEX"), Some(&"3".to_string()));
assert_eq!(
vars.get("OPENSPEC_CHANGE_ID"),
Some(&"parallel-change".to_string())
);
assert_eq!(
vars.get("OPENSPEC_CHANGES_PROCESSED"),
Some(&"2".to_string())
);
}
#[test]
fn test_hook_context_parallel_env_vars_without_group_index() {
let context = HookContext::new(0, 3, 3, false)
.with_change("single-change", 0, 5)
.with_parallel_context("/workspace/single", None);
let vars = context.to_env_vars();
assert_eq!(
vars.get("OPENSPEC_WORKSPACE_PATH"),
Some(&"/workspace/single".to_string())
);
assert!(!vars.contains_key("OPENSPEC_GROUP_INDEX"));
}
#[test]
fn test_hook_context_no_parallel_context() {
let context = HookContext::new(0, 1, 1, false).with_change("sequential-change", 0, 3);
let vars = context.to_env_vars();
assert!(!vars.contains_key("OPENSPEC_WORKSPACE_PATH"));
assert!(!vars.contains_key("OPENSPEC_GROUP_INDEX"));
assert_eq!(
vars.get("OPENSPEC_CHANGE_ID"),
Some(&"sequential-change".to_string())
);
}
#[tokio::test]
async fn test_hook_output_captured_and_logged() {
use tokio::sync::mpsc;
let json = r#"{"on_start": "echo 'Hello from hook'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let (tx, mut rx) = mpsc::channel(10);
let runner = HookRunner::with_event_tx(config, ".", tx);
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
let mut log_messages = Vec::new();
while let Ok(event) = rx.try_recv() {
if let ExecutionEvent::Log(entry) = event {
log_messages.push(entry.message);
}
}
assert!(!log_messages.is_empty(), "Expected at least 1 log message");
assert!(
log_messages[0].contains("Running on_start hook"),
"Expected command log, got: {}",
log_messages[0]
);
if log_messages.len() > 1 {
assert!(
log_messages[1].contains("Hello from hook")
|| log_messages[1].contains("on_start hook stdout"),
"Expected stdout output log, got: {}",
log_messages[1]
);
}
}
#[tokio::test]
async fn test_hook_without_event_tx_still_works() {
let json = r#"{"on_start": "echo test"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
}
use std::sync::{Arc, Mutex};
#[derive(Default, Clone)]
struct RecordingOutputHandler {
messages: Arc<Mutex<Vec<(String, String)>>>,
}
impl RecordingOutputHandler {
fn new() -> Self {
Self {
messages: Arc::new(Mutex::new(Vec::new())),
}
}
fn all(&self) -> Vec<(String, String)> {
self.messages.lock().unwrap().clone()
}
fn content(&self) -> Vec<String> {
self.all().into_iter().map(|(_, v)| v).collect()
}
}
impl crate::orchestration::output::OutputHandler for RecordingOutputHandler {
fn on_stdout(&self, line: &str) {
self.messages
.lock()
.unwrap()
.push(("stdout".into(), line.to_string()));
}
fn on_stderr(&self, line: &str) {
self.messages
.lock()
.unwrap()
.push(("stderr".into(), line.to_string()));
}
fn on_agent_stderr(&self, line: &str) {
self.messages
.lock()
.unwrap()
.push(("agent_stderr".into(), line.to_string()));
}
fn on_info(&self, message: &str) {
self.messages
.lock()
.unwrap()
.push(("info".into(), message.to_string()));
}
fn on_warn(&self, message: &str) {
self.messages
.lock()
.unwrap()
.push(("warn".into(), message.to_string()));
}
fn on_error(&self, message: &str) {
self.messages
.lock()
.unwrap()
.push(("error".into(), message.to_string()));
}
fn on_success(&self, message: &str) {
self.messages
.lock()
.unwrap()
.push(("success".into(), message.to_string()));
}
}
#[tokio::test]
async fn test_cli_hook_stdout_visible_via_output_handler() {
let json = r#"{"on_start": "echo 'hello stdout'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let handler = RecordingOutputHandler::new();
let runner = HookRunner::with_output_handler(config, ".", Arc::new(handler.clone()));
let result = runner
.run_hook(HookType::OnStart, &HookContext::default())
.await;
assert!(result.is_ok());
let content = handler.content();
assert!(
content.iter().any(|m| m.contains("Running on_start hook")),
"Command log not found: {:?}",
content
);
assert!(
content
.iter()
.any(|m| m.contains("on_start hook stdout") && m.contains("hello stdout")),
"stdout output not found: {:?}",
content
);
}
#[tokio::test]
async fn test_cli_hook_stderr_visible_via_output_handler() {
let json = r#"{"on_start": "echo 'hello stderr' >&2"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let handler = RecordingOutputHandler::new();
let runner = HookRunner::with_output_handler(config, ".", Arc::new(handler.clone()));
let result = runner
.run_hook(HookType::OnStart, &HookContext::default())
.await;
assert!(result.is_ok());
let content = handler.content();
assert!(
content
.iter()
.any(|m| m.contains("on_start hook stderr") && m.contains("hello stderr")),
"stderr output not found: {:?}",
content
);
}
#[tokio::test]
async fn test_cli_hook_output_visible_even_on_failure() {
let json = r#"{"on_start": {"command": "echo 'output before fail'; exit 1", "continue_on_failure": true}}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let handler = RecordingOutputHandler::new();
let runner = HookRunner::with_output_handler(config, ".", Arc::new(handler.clone()));
let result = runner
.run_hook(HookType::OnStart, &HookContext::default())
.await;
assert!(result.is_ok(), "expected Ok with continue_on_failure=true");
let content = handler.content();
assert!(
content.iter().any(|m| m.contains("output before fail")),
"output before failure not shown: {:?}",
content
);
}
#[tokio::test]
async fn test_cli_hook_global_hooks_no_change_id() {
let json = r#"{"on_finish": "echo 'run finished'"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let handler = RecordingOutputHandler::new();
let runner = HookRunner::with_output_handler(config, ".", Arc::new(handler.clone()));
let ctx = HookContext::new(3, 3, 0, false).with_status("completed");
let result = runner.run_hook(HookType::OnFinish, &ctx).await;
assert!(result.is_ok());
let content = handler.content();
assert!(
content.iter().any(|m| m.contains("Running on_finish hook")),
"on_finish command log not shown: {:?}",
content
);
assert!(
content.iter().any(|m| m.contains("run finished")),
"on_finish stdout not shown: {:?}",
content
);
}
#[tokio::test]
async fn test_cli_hook_truncated_output_marked_explicitly() {
let big_output = "x".repeat(HOOK_OUTPUT_TRUNCATE_BYTES + 100);
let json = format!(r#"{{"on_start": "printf '{}'" }}"#, big_output);
let config: HooksConfig = serde_json::from_str(&json).unwrap();
let handler = RecordingOutputHandler::new();
let runner = HookRunner::with_output_handler(config, ".", Arc::new(handler.clone()));
let result = runner
.run_hook(HookType::OnStart, &HookContext::default())
.await;
assert!(result.is_ok());
let content = handler.content();
let has_truncation_marker = content.iter().any(|m| m.contains("bytes truncated"));
assert!(
has_truncation_marker,
"Expected explicit truncation marker in output: {:?}",
content
);
}
#[test]
fn test_truncate_hook_output_below_limit() {
let s = "hello";
let (result, truncated) = truncate_hook_output(s, 1024);
assert_eq!(result, "hello");
assert!(!truncated);
}
#[test]
fn test_truncate_hook_output_at_limit() {
let s = "a".repeat(1024);
let (result, truncated) = truncate_hook_output(&s, 1024);
assert_eq!(result.len(), 1024);
assert!(!truncated);
}
#[test]
fn test_truncate_hook_output_above_limit() {
let s = "b".repeat(2000);
let (result, truncated) = truncate_hook_output(&s, 1024);
assert_eq!(result.len(), 1024);
assert!(truncated);
}
#[test]
fn test_truncate_hook_output_multibyte_boundary() {
let s = "日".repeat(400); let (result, truncated) = truncate_hook_output(&s, 1024);
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
assert!(truncated);
assert!(result.len() <= 1024);
}
#[test]
fn test_hook_config_deserialize_with_max_retries() {
let json = r#"{
"on_merged": {
"command": "make bump-patch",
"max_retries": 3,
"retry_delay_secs": 5
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnMerged).unwrap();
assert_eq!(hook.command, "make bump-patch");
assert_eq!(hook.max_retries, 3);
assert_eq!(hook.retry_delay_secs, 5);
assert!(hook.continue_on_failure);
assert_eq!(hook.timeout, DEFAULT_HOOK_TIMEOUT);
assert!(!hook.git_commit_no_verify);
}
#[test]
fn test_hook_config_default_retry_values() {
let json = r#"{
"on_merged": {
"command": "make bump-patch"
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnMerged).unwrap();
assert_eq!(hook.max_retries, DEFAULT_HOOK_MAX_RETRIES);
assert_eq!(hook.retry_delay_secs, DEFAULT_HOOK_RETRY_DELAY_SECS);
}
#[test]
fn test_hook_config_simple_string_has_default_retry_values() {
let json = r#"{"on_start": "echo hello"}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let hook = config.get(HookType::OnStart).unwrap();
assert_eq!(hook.max_retries, DEFAULT_HOOK_MAX_RETRIES);
assert_eq!(hook.retry_delay_secs, DEFAULT_HOOK_RETRY_DELAY_SECS);
}
#[test]
fn test_hooks_config_backward_compat_no_new_fields() {
let json = r#"{
"on_start": "echo start",
"on_merged": {
"command": "make bump-patch",
"continue_on_failure": false,
"timeout": 120,
"git_commit_no_verify": true
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let on_start = config.get(HookType::OnStart).unwrap();
assert_eq!(on_start.command, "echo start");
assert_eq!(on_start.max_retries, 0);
assert_eq!(on_start.retry_delay_secs, 3);
let on_merged = config.get(HookType::OnMerged).unwrap();
assert_eq!(on_merged.command, "make bump-patch");
assert!(!on_merged.continue_on_failure);
assert_eq!(on_merged.timeout, 120);
assert!(on_merged.git_commit_no_verify);
assert_eq!(on_merged.max_retries, 0);
assert_eq!(on_merged.retry_delay_secs, 3);
assert_eq!(config.index_lock_wait_secs, DEFAULT_INDEX_LOCK_WAIT_SECS);
}
#[test]
fn test_hooks_config_index_lock_wait_secs() {
let json = r#"{"index_lock_wait_secs": 30}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.index_lock_wait_secs, 30);
}
#[test]
fn test_hooks_config_index_lock_wait_secs_default() {
let json = r#"{}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.index_lock_wait_secs, DEFAULT_INDEX_LOCK_WAIT_SECS);
}
#[tokio::test]
async fn test_hook_retry_success_after_failure() {
let tmp_dir = tempfile::tempdir().unwrap();
let marker = tmp_dir.path().join("attempt_marker");
let marker_str = marker.to_str().unwrap();
let cmd = format!(
"if [ -f '{}' ]; then echo ok; else touch '{}' && exit 1; fi",
marker_str, marker_str
);
let json = serde_json::json!({
"on_merged": {
"command": cmd,
"max_retries": 1,
"retry_delay_secs": 0,
"continue_on_failure": false
}
});
let config: HooksConfig = serde_json::from_str(&json.to_string()).unwrap();
let runner = HookRunner::new(config, tmp_dir.path());
let context = HookContext::default();
let result = runner.run_hook(HookType::OnMerged, &context).await;
assert!(result.is_ok(), "Expected retry to succeed: {:?}", result);
}
#[tokio::test]
async fn test_hook_retry_all_fail_continue_on_failure() {
let json = r#"{
"on_start": {
"command": "exit 1",
"max_retries": 2,
"retry_delay_secs": 0,
"continue_on_failure": true
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok(), "Expected Ok with continue_on_failure=true");
}
#[tokio::test]
async fn test_hook_retry_all_fail_no_continue() {
let json = r#"{
"on_start": {
"command": "exit 1",
"max_retries": 1,
"retry_delay_secs": 0,
"continue_on_failure": false
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_err(), "Expected error with all retries exhausted");
}
#[tokio::test]
async fn test_hook_no_retry_by_default() {
let json = r#"{
"on_start": {
"command": "exit 1",
"continue_on_failure": false
}
}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, ".");
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(
result.is_err(),
"Expected immediate failure with max_retries=0"
);
}
#[tokio::test]
async fn test_hook_runner_cwd_is_repo_root() {
let tmp_dir = tempfile::tempdir().unwrap();
let json = r#"{"on_start": {"command": "pwd", "continue_on_failure": false}}"#;
let config: HooksConfig = serde_json::from_str(json).unwrap();
let runner = HookRunner::new(config, tmp_dir.path());
let context = HookContext::default();
let result = runner.run_hook(HookType::OnStart, &context).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_index_lock_wait_no_lock_file() {
let tmp_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp_dir.path().join(".git")).unwrap();
let runner = HookRunner::new(HooksConfig::default(), tmp_dir.path());
let released = runner.wait_for_index_lock_release(1).await;
assert!(released, "Expected immediate return when no lock file");
}
#[tokio::test]
async fn test_index_lock_wait_lock_released() {
let tmp_dir = tempfile::tempdir().unwrap();
let git_dir = tmp_dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let lock_file = git_dir.join("index.lock");
std::fs::write(&lock_file, "lock").unwrap();
let runner = HookRunner::new(HooksConfig::default(), tmp_dir.path());
let lock_clone = lock_file.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
let _ = std::fs::remove_file(lock_clone);
});
let released = runner.wait_for_index_lock_release(5).await;
assert!(released, "Expected lock to be released");
}
#[cfg(feature = "heavy-tests")]
#[tokio::test]
async fn test_index_lock_wait_timeout() {
let tmp_dir = tempfile::tempdir().unwrap();
let git_dir = tmp_dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let lock_file = git_dir.join("index.lock");
std::fs::write(&lock_file, "lock").unwrap();
let runner = HookRunner::new(HooksConfig::default(), tmp_dir.path());
let released = runner.wait_for_index_lock_release(1).await;
assert!(!released, "Expected timeout when lock persists");
}
}