Skip to main content

git_worktree_manager/operations/
claude_settings.rs

1//! Inline `--settings` JSON for `gw`-spawned Claude sessions, and the entry
2//! builders used by `gw setup-claude` to register `.claude/settings.json` hooks.
3//!
4//! Hook commands use the bare name `gw` rather than an absolute path. Claude
5//! Code resolves the command through the user's `PATH`, which is what makes
6//! `.claude/settings.json` portable across machines (Homebrew, cargo, manual
7//! installers, CI) and safe to commit to a repo.
8
9use serde_json::json;
10
11use crate::error::Result;
12
13const GW_BIN: &str = "gw";
14
15/// Build the `--settings` JSON payload that registers `gw guard` as a
16/// PreToolUse(Bash) hook.
17pub fn guard_settings_json() -> Result<String> {
18    let payload = json!({
19        "hooks": {
20            "PreToolUse": [
21                pre_tool_use_bash_entry()?
22            ]
23        }
24    });
25
26    Ok(payload.to_string())
27}
28
29/// One PreToolUse(Bash) hook entry — used by both `--settings` inline injection
30/// and `setup-claude`'s `.claude/settings.json` merge.
31pub fn pre_tool_use_bash_entry() -> Result<serde_json::Value> {
32    Ok(json!({
33        "matcher": "Bash",
34        "hooks": [
35            { "type": "command", "command": format!("{GW_BIN} guard --tool-input -") }
36        ]
37    }))
38}
39
40/// One WorktreeCreate hook entry (no matcher — Claude Code does not support
41/// matchers for worktree lifecycle events).
42pub fn worktree_create_entry() -> Result<serde_json::Value> {
43    Ok(json!({
44        "hooks": [
45            { "type": "command", "command": format!("{GW_BIN} _claude-worktree-create") }
46        ]
47    }))
48}
49
50/// One WorktreeRemove hook entry (no matcher — Claude Code does not support
51/// matchers for worktree lifecycle events).
52pub fn worktree_remove_entry() -> Result<serde_json::Value> {
53    Ok(json!({
54        "hooks": [
55            { "type": "command", "command": format!("{GW_BIN} _claude-worktree-remove") }
56        ]
57    }))
58}
59
60/// True if `command` looks like a previous `gw setup-claude` registration
61/// — any string that ends with one of our known hook suffixes. Used by
62/// `sync_claude` to migrate absolute-path entries left by older versions
63/// (≤ 0.1.11) to the new bare-name form without creating duplicates.
64pub(crate) fn is_legacy_gw_command(command: &str, suffix: &str) -> bool {
65    if !command.ends_with(suffix) {
66        return false;
67    }
68    // Avoid matching the current bare-name form (e.g. "gw guard …") — that's
69    // not legacy, it's the same string we'd insert this run.
70    let prefix = command.trim_end_matches(suffix).trim_end();
71    !prefix.is_empty() && prefix != GW_BIN
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use serde_json::Value;
78
79    fn parse(json: &str) -> Value {
80        serde_json::from_str(json).expect("valid json")
81    }
82
83    #[test]
84    fn payload_has_pretooluse_bash_hook() {
85        let s = guard_settings_json().expect("ok");
86        let v = parse(&s);
87        let arr = v["hooks"]["PreToolUse"]
88            .as_array()
89            .expect("PreToolUse array");
90        assert_eq!(arr.len(), 1);
91        assert_eq!(arr[0]["matcher"], "Bash");
92        let inner = arr[0]["hooks"].as_array().expect("inner hooks");
93        assert_eq!(inner.len(), 1);
94        assert_eq!(inner[0]["type"], "command");
95        let cmd = inner[0]["command"].as_str().expect("command str");
96        assert_eq!(cmd, "gw guard --tool-input -");
97    }
98
99    #[test]
100    fn pre_tool_use_bash_entry_is_bare_gw() {
101        let entry = pre_tool_use_bash_entry().expect("ok");
102        assert_eq!(entry["matcher"], "Bash");
103        let inner = entry["hooks"].as_array().expect("inner hooks array");
104        assert_eq!(inner[0]["type"], "command");
105        assert_eq!(inner[0]["command"], "gw guard --tool-input -");
106    }
107
108    #[test]
109    fn worktree_create_entry_is_bare_gw() {
110        let entry = worktree_create_entry().expect("ok");
111        // WorktreeCreate entries must NOT have a matcher key.
112        assert!(entry.get("matcher").is_none());
113        let inner = entry["hooks"].as_array().expect("inner hooks array");
114        assert_eq!(inner[0]["type"], "command");
115        assert_eq!(inner[0]["command"], "gw _claude-worktree-create");
116    }
117
118    #[test]
119    fn worktree_remove_entry_is_bare_gw() {
120        let entry = worktree_remove_entry().expect("ok");
121        // WorktreeRemove entries must NOT have a matcher key.
122        assert!(entry.get("matcher").is_none());
123        let inner = entry["hooks"].as_array().expect("inner hooks array");
124        assert_eq!(inner[0]["type"], "command");
125        assert_eq!(inner[0]["command"], "gw _claude-worktree-remove");
126    }
127
128    #[test]
129    fn is_legacy_recognizes_absolute_paths() {
130        assert!(is_legacy_gw_command(
131            "/usr/local/bin/gw guard --tool-input -",
132            " guard --tool-input -",
133        ));
134        assert!(is_legacy_gw_command(
135            "/opt/homebrew/bin/gw _claude-worktree-create",
136            " _claude-worktree-create",
137        ));
138        assert!(is_legacy_gw_command(
139            "/Users/dave/proj/target/release/gw _claude-worktree-remove",
140            " _claude-worktree-remove",
141        ));
142    }
143
144    #[test]
145    fn is_legacy_rejects_current_bare_form() {
146        // Our current form is what we'd insert this run — not legacy.
147        assert!(!is_legacy_gw_command(
148            "gw guard --tool-input -",
149            " guard --tool-input -",
150        ));
151        assert!(!is_legacy_gw_command(
152            "gw _claude-worktree-create",
153            " _claude-worktree-create",
154        ));
155    }
156
157    #[test]
158    fn is_legacy_rejects_unrelated_commands() {
159        assert!(!is_legacy_gw_command(
160            "/usr/local/bin/some-other-tool guard --tool-input -",
161            " _claude-worktree-create", // wrong suffix
162        ));
163        assert!(!is_legacy_gw_command("", " guard --tool-input -"));
164    }
165}