//! Client hooks (spec §20): executables in `.oxy/hooks/`, never //! cloned. Each receives one JSON object on stdin; `pre-*` hooks block the //! operation by exiting non-zero, and their stderr becomes the error. The //! `semantic-deps` hook runs only when `hooks.semantic-deps = true` and //! answers `{"deps": ["", ...]}`. use super::*; use std::process::{Command, Stdio}; /// Minimal JSON string escaping. pub fn json_str(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), c => out.push(c), } } out.push('"'); out } pub fn json_array>(items: I) -> String { format!("[{}]", items.into_iter().collect::>().join(",")) } /// What running a hook produced. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookOutcome { pub ran: bool, pub stdout: String, } impl Repo { fn hook_path(&self, event: &str) -> PathBuf { self.oxy.join("hooks").join(event) } /// Run the hook for `event` with `input` on stdin, if one exists and is /// executable. A non-zero exit is an error carrying the hook's stderr. pub fn run_hook(&self, event: &str, input: &str) -> Result { let path = self.hook_path(event); if !path.is_file() { return Ok(HookOutcome { ran: false, stdout: String::new() }); } let mut child = Command::new(&path) .current_dir(&self.root) .env("OXY_EVENT", event) .env("OXY_ROOT", &self.root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| RepoError::Msg(format!("running hook {event}: {e}")))?; if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(input.as_bytes()); } let out = child.wait_with_output()?; let stdout = String::from_utf8_lossy(&out.stdout).to_string(); if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); return fail(format!("hook {event} refused: {}", if stderr.is_empty() { "(no message)".to_string() } else { stderr })); } Ok(HookOutcome { ran: true, stdout }) } /// Extra dependencies from the `semantic-deps` hook (spec §8.1, §20). pub(crate) fn semantic_deps(&self, change_id: &ChangeId, touched: &[String], candidates: &[(ChangeId, ChangeHash, Vec)]) -> Result> { if !self.config.semantic_deps { return Ok(vec![]); } let input = format!( "{{\"change_id\":{},\"touched_paths\":{},\"candidate_deps\":{}}}", json_str(&change_id.to_hex()), json_array(touched.iter().map(|p| json_str(p))), json_array(candidates.iter().map(|(id, h, paths)| format!("{{\"change_id\":{},\"hash\":{},\"paths\":{}}}", json_str(&id.to_hex()), json_str(&h.to_hex()), json_array(paths.iter().map(|p| json_str(p)))))) ); let out = self.run_hook("semantic-deps", &input)?; if !out.ran { return Ok(vec![]); } // Accept every 64-hex token after "deps"; any other shape is ignored. let body = out.stdout.split_once("\"deps\"").map(|(_, rest)| rest.to_string()).unwrap_or_default(); let mut deps = Vec::new(); let bytes = body.as_bytes(); let mut i = 0; while i + 64 <= bytes.len() { if bytes[i..i + 64].iter().all(|b| b.is_ascii_hexdigit()) && (i == 0 || !bytes[i - 1].is_ascii_hexdigit()) && (i + 64 == bytes.len() || !bytes[i + 64].is_ascii_hexdigit()) { if let Some(h) = ObjectRef::from_hex(&body[i..i + 64]) { deps.push(h); } i += 64; } else { i += 1; } } Ok(deps) } }