git_worktree_manager/operations/
guard.rs1use std::io::Read;
10use std::path::Path;
11
12use crate::error::{CwError, Result};
13
14const RISK_PATTERNS: &[&str] = &[
15 "git push",
16 "gh release",
17 "gh pr merge",
18 "npm publish",
19 "cargo publish",
20 "bun publish",
21 "pnpm publish",
22];
23
24#[derive(Debug, serde::Deserialize)]
25struct HookPayload {
26 tool_name: Option<String>,
27 tool_input: Option<serde_json::Value>,
28}
29
30fn read_input(source: &str) -> std::io::Result<String> {
31 if source == "-" {
32 let mut s = String::new();
33 std::io::stdin().read_to_string(&mut s)?;
34 Ok(s)
35 } else {
36 std::fs::read_to_string(source)
37 }
38}
39
40fn cwd_is_healthy(cwd: &Path) -> bool {
41 cwd.exists() && cwd.is_dir()
42}
43
44fn command_is_risky(cmd: &str) -> bool {
45 let normalized = cmd.split_ascii_whitespace().collect::<Vec<_>>().join(" ");
46 RISK_PATTERNS.iter().any(|pat| normalized.contains(pat))
47}
48
49pub fn run(tool_input_source: &str) -> Result<()> {
50 let raw = read_input(tool_input_source).map_err(CwError::Io)?;
51 let payload: HookPayload = serde_json::from_str(&raw).map_err(CwError::Json)?;
52
53 if payload.tool_name.as_deref() != Some("Bash") {
54 return Ok(());
55 }
56 let input = match payload.tool_input {
57 Some(v) => v,
58 None => return Ok(()),
59 };
60 let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
61 if !command_is_risky(command) {
62 return Ok(());
63 }
64 let cwd_str = input.get("cwd").and_then(|v| v.as_str());
65 let cwd = match cwd_str {
66 Some(s) => std::path::PathBuf::from(s),
67 None => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
68 };
69 if !cwd_is_healthy(&cwd) {
70 eprintln!(
71 "gw guard: blocking risky command '{}' — cwd '{}' does not exist or is not a directory.",
72 command,
73 cwd.display(),
74 );
75 return Err(CwError::ExitCode(2));
76 }
77 Ok(())
78}