1use std::{
4 fs,
5 io::{self, Read},
6 path::{Path, PathBuf},
7 process::{Command, ExitCode},
8};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{
13 claim,
14 cli::{self, Agent, HookName},
15 gate,
16 reviewer::ReviewQueue,
17 surface::{self, SurfacePlan},
18};
19
20const INSTALLED_HOOKS: &[HookName] =
21 &[HookName::CommitMsg, HookName::PostCommit, HookName::PrePush];
22
23pub fn run(args: cli::InstallHooksArgs, state_dir: &Path) -> Result<ExitCode> {
24 let repo_root = git_root()?;
25 let hooks_path = repo_root.join(state_dir).join("hooks");
26 let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
27 let agents = surface_agents(&args);
28
29 if args.dry_run {
30 println!("{}", plan.render());
31 for agent in &agents {
32 println!(
33 "surface: {} -> {}",
34 surface::agent_slug(*agent),
35 surface::surface_relative_path(*agent)
36 );
37 }
38 return Ok(ExitCode::SUCCESS);
39 }
40
41 if args.uninstall {
42 uninstall(&plan)?;
43 for agent in &agents {
44 SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
45 }
46 } else {
47 install(&plan)?;
48 for agent in &agents {
49 SurfacePlan::for_agent(&repo_root, *agent).install()?;
50 }
51 }
52
53 Ok(ExitCode::SUCCESS)
54}
55
56fn surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
60 let mut agents = Vec::new();
61 if args.claude {
62 agents.push(Agent::Claude);
63 }
64 if args.codex {
65 agents.push(Agent::Codex);
66 }
67 if args.pi {
68 agents.push(Agent::Pi);
69 }
70
71 if agents.is_empty() && args.uninstall {
72 return surface::ALL_AGENTS.to_vec();
73 }
74 agents
75}
76
77pub fn dispatch(args: cli::HookDispatchArgs, state_dir: &Path) -> Result<ExitCode> {
78 run_chained_hook(state_dir, args.hook, &args.args)?;
79
80 match args.hook {
81 HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args)?,
82 HookName::PostCommit => dispatch_post_commit(state_dir)?,
83 HookName::PrePush => dispatch_pre_push(state_dir)?,
84 }
85
86 Ok(ExitCode::SUCCESS)
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct HookInstallPlan {
91 pub repo_root: PathBuf,
92 pub hooks_path: PathBuf,
93 pub uninstall: bool,
94}
95
96impl HookInstallPlan {
97 pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
98 Self {
99 repo_root: repo_root.to_path_buf(),
100 hooks_path: hooks_path.to_path_buf(),
101 uninstall,
102 }
103 }
104
105 pub fn render(&self) -> String {
106 let action = if self.uninstall {
107 "uninstall"
108 } else {
109 "install"
110 };
111 let hooks = INSTALLED_HOOKS
112 .iter()
113 .map(|hook| hook.as_str())
114 .collect::<Vec<_>>()
115 .join(", ");
116 format!(
117 "truth-mirror hook plan\nrepo={}\naction={action}\nhooksPath={}\nhooks={hooks}",
118 self.repo_root.display(),
119 self.hooks_path.display()
120 )
121 }
122}
123
124pub fn render_shim(hook: HookName) -> String {
125 format!(
126 "#!/bin/sh\nexec truth-mirror hook-dispatch {} \"$@\"\n",
127 hook.as_str()
128 )
129}
130
131fn install(plan: &HookInstallPlan) -> Result<()> {
132 fs::create_dir_all(&plan.hooks_path)?;
133 fs::create_dir_all(plan.hooks_path.join("chained"))?;
134 let git_hooks = plan.repo_root.join(".git/hooks");
135
136 for hook in INSTALLED_HOOKS {
137 let existing = git_hooks.join(hook.as_str());
138 if existing.is_file() {
139 let chained = plan.hooks_path.join("chained").join(hook.as_str());
140 fs::copy(&existing, chained)?;
141 }
142
143 let hook_path = plan.hooks_path.join(hook.as_str());
144 fs::write(&hook_path, render_shim(*hook))?;
145 make_executable(&hook_path)?;
146 }
147
148 git_config(&["config", "core.hooksPath", &path_for_git(&plan.hooks_path)])?;
149 Ok(())
150}
151
152fn uninstall(plan: &HookInstallPlan) -> Result<()> {
153 let _ = Command::new("git")
154 .args(["config", "--unset", "core.hooksPath"])
155 .current_dir(&plan.repo_root)
156 .status();
157
158 if plan.hooks_path.exists() {
159 fs::remove_dir_all(&plan.hooks_path)?;
160 }
161 Ok(())
162}
163
164fn dispatch_commit_msg(state_dir: &Path, args: &[String]) -> Result<()> {
165 let commit_msg_path = args
166 .first()
167 .context("commit-msg hook requires COMMIT_EDITMSG path")?;
168 let commit_message = fs::read_to_string(commit_msg_path)?;
169 let diff = git_stdout(&["diff", "--cached"])?;
170 let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
171 claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &[])?;
172 Ok(())
173}
174
175fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
176 let sha = git_stdout(&["rev-parse", "HEAD"])?;
177 ReviewQueue::new(state_dir).enqueue(sha.trim())?;
178 Ok(())
179}
180
181fn dispatch_pre_push(state_dir: &Path) -> Result<()> {
182 let mut stdin = String::new();
183 io::stdin().read_to_string(&mut stdin)?;
184 for line in stdin.lines() {
185 if let Some(range) = pre_push_range_from_line(line) {
186 gate::run(
187 cli::GateArgs {
188 pre_push: Some(range),
189 commit_msg: None,
190 claim_file: None,
191 diff_file: None,
192 fake_markers: Vec::new(),
193 },
194 state_dir,
195 )?;
196 }
197 }
198 Ok(())
199}
200
201fn pre_push_range_from_line(line: &str) -> Option<String> {
202 let parts = line.split_whitespace().collect::<Vec<_>>();
203 let local_sha = parts.get(1)?;
204 let remote_sha = parts.get(3)?;
205 if is_zero_sha(local_sha) {
206 return None;
207 }
208
209 if is_zero_sha(remote_sha) {
210 Some((*local_sha).to_owned())
211 } else {
212 Some(format!("{remote_sha}..{local_sha}"))
213 }
214}
215
216fn is_zero_sha(value: &str) -> bool {
217 value.chars().all(|character| character == '0')
218}
219
220fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
221 let chained = state_dir.join("hooks/chained").join(hook.as_str());
222 if !chained.is_file() {
223 return Ok(());
224 }
225
226 let status = Command::new(&chained).args(args).status()?;
227 if !status.success() {
228 bail!(
229 "chained hook {} failed with status {status}",
230 chained.display()
231 );
232 }
233 Ok(())
234}
235
236fn git_root() -> Result<PathBuf> {
237 Ok(PathBuf::from(
238 git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
239 ))
240}
241
242fn git_stdout(args: &[&str]) -> Result<String> {
243 let output = Command::new("git").args(args).output()?;
244 if !output.status.success() {
245 bail!(
246 "git {} failed: {}",
247 args.join(" "),
248 String::from_utf8_lossy(&output.stderr)
249 );
250 }
251 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
252}
253
254fn git_config(args: &[&str]) -> Result<()> {
255 let status = Command::new("git").args(args).status()?;
256 if !status.success() {
257 bail!("git {} failed with status {status}", args.join(" "));
258 }
259 Ok(())
260}
261
262fn path_for_git(path: &Path) -> String {
263 path.to_string_lossy().into_owned()
264}
265
266#[cfg(unix)]
267fn make_executable(path: &Path) -> Result<()> {
268 use std::os::unix::fs::PermissionsExt;
269
270 let mut permissions = fs::metadata(path)?.permissions();
271 permissions.set_mode(0o755);
272 fs::set_permissions(path, permissions)?;
273 Ok(())
274}
275
276#[cfg(not(unix))]
277fn make_executable(_path: &Path) -> Result<()> {
278 Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283 use proptest::prelude::*;
284
285 use super::{HookInstallPlan, pre_push_range_from_line, render_shim};
286 use crate::cli::HookName;
287
288 #[test]
289 fn hook_shim_is_only_exec_delegation() {
290 let shim = render_shim(HookName::CommitMsg);
291 let lines = shim.lines().collect::<Vec<_>>();
292
293 assert_eq!(lines.len(), 2);
294 assert_eq!(lines[0], "#!/bin/sh");
295 assert_eq!(
296 lines[1],
297 "exec truth-mirror hook-dispatch commit-msg \"$@\""
298 );
299 }
300
301 #[test]
302 fn dry_run_plan_names_hooks_and_hooks_path() {
303 let plan = HookInstallPlan::new(
304 std::path::Path::new("/repo"),
305 std::path::Path::new("/repo/.truth-mirror/hooks"),
306 false,
307 );
308 let rendered = plan.render();
309
310 assert!(rendered.contains("commit-msg"));
311 assert!(rendered.contains("post-commit"));
312 assert!(rendered.contains("pre-push"));
313 assert!(rendered.contains("hooksPath=/repo/.truth-mirror/hooks"));
314 }
315
316 #[test]
317 fn pre_push_line_maps_to_git_range() {
318 let line = "refs/heads/main abc123 refs/heads/main def456";
319
320 assert_eq!(
321 pre_push_range_from_line(line),
322 Some("def456..abc123".to_owned())
323 );
324 }
325
326 proptest! {
327 #[test]
328 fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
329 let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
330 let shim = render_shim(hook);
331 let lines = shim.lines().collect::<Vec<_>>();
332
333 prop_assert_eq!(lines.len(), 2);
334 prop_assert_eq!(lines[0], "#!/bin/sh");
335 prop_assert!(lines[1].starts_with("exec truth-mirror hook-dispatch "));
336 prop_assert!(lines[1].contains(hook.as_str()));
337 }
338 }
339}