Skip to main content

git_worktree_manager/operations/
claude_worktree.rs

1//! Internal handlers for Claude Code's `WorktreeCreate` and `WorktreeRemove`
2//! hook events.
3//!
4//! These are invoked via the hidden `_claude-worktree-create` and
5//! `_claude-worktree-remove` subcommands. Both read a JSON payload from stdin
6//! and write only what Claude Code's hook contract requires to stdout.
7
8use std::io::Read;
9use std::path::Path;
10use std::process::{Command, Stdio};
11
12use crate::error::{CwError, Result};
13
14// ---------------------------------------------------------------------------
15// Payload types
16// ---------------------------------------------------------------------------
17
18#[derive(serde::Deserialize)]
19struct CreatePayload {
20    /// Branch / worktree identifier Claude Code proposes (e.g.
21    /// `agent-<hash>` or a derived branch name). This is the only field the
22    /// hook needs to drive `gw new <branch>`; `gw` sanitizes it. Current
23    /// Claude Code sends `worktree_name`; some builds emit `name`, so we
24    /// accept either. (A payload carrying *both* keys would be rejected as a
25    /// duplicate field, but no Claude Code build sends both.)
26    #[serde(alias = "name")]
27    worktree_name: String,
28    /// Commit / ref the worktree should be based on. Optional — `gw new`
29    /// falls back to the configured default base when absent. Current Claude
30    /// Code sends `base_commit`; older drafts used `base_ref`, so we accept
31    /// either.
32    #[serde(alias = "base_ref")]
33    base_commit: Option<String>,
34    /// Legacy/optional explicit path. Claude Code's WorktreeCreate payload
35    /// does NOT carry this (the hook is responsible for creating the worktree
36    /// and reporting its path), but we honor it if a build ever sends one so
37    /// the worktree lands exactly where requested.
38    worktree_path: Option<String>,
39    /// Repo the hook fired in. Used to relocate the inner `gw new` so it
40    /// discovers the right repo root.
41    cwd: Option<String>,
42    // All other fields (hook_event_name, session_id, transcript_path,
43    // isolation_type, …) are intentionally ignored.
44    #[serde(flatten)]
45    _other: serde_json::Value,
46}
47
48#[derive(serde::Deserialize)]
49struct RemovePayload {
50    worktree_path: String,
51    // All other fields (worktree_name, cwd, session_id, …) are intentionally
52    // ignored.
53    #[serde(flatten)]
54    _other: serde_json::Value,
55}
56
57// ---------------------------------------------------------------------------
58// _claude-worktree-create
59// ---------------------------------------------------------------------------
60
61/// Handle a Claude Code `WorktreeCreate` hook event.
62///
63/// Claude Code's payload carries `worktree_name` (and `cwd`/`base_commit`) but
64/// NOT a worktree path — the hook itself is responsible for creating the
65/// worktree and reporting where it landed. We read the payload from stdin,
66/// delegate to `gw new --emit json --term skip` (self-reexec), parse the
67/// resulting JSON, and print only the created `worktree_path` as a plain-text
68/// line to stdout — exactly what Claude Code's hook contract expects.
69///
70/// Human-readable progress output from the inner `gw new` call flows through
71/// to stderr unchanged, so the user can see what's happening.
72pub fn run_create() -> Result<()> {
73    // 1. Read and parse the hook payload from stdin.
74    let mut buf = String::new();
75    std::io::stdin().read_to_string(&mut buf)?;
76    let payload: CreatePayload = serde_json::from_str(&buf)
77        .map_err(|e| CwError::Other(format!("invalid WorktreeCreate hook payload: {e}")))?;
78
79    // 2. If cwd is provided, switch to it so the inner `gw new` discovers the
80    //    correct repo root (the hook may run from elsewhere).
81    if let Some(ref cwd) = payload.cwd {
82        std::env::set_current_dir(cwd)?;
83    }
84
85    // 3. Use the proposed worktree name as the branch. `gw new` sanitizes it
86    //    and chooses the worktree path via its default convention unless an
87    //    explicit `worktree_path` was supplied (legacy/optional).
88    let branch = payload.worktree_name.trim();
89    if branch.is_empty() {
90        return Err(CwError::Other(
91            "WorktreeCreate hook payload had an empty worktree_name/name".into(),
92        ));
93    }
94
95    // 4. Re-exec the current binary as `gw new <branch> [--path <path>] [--base
96    //    <commit>] --emit json --term skip`. stdout is piped so we can extract
97    //    `worktree_path`; stderr is inherited so the user sees human-readable
98    //    progress from the inner process.
99    let exe = std::env::current_exe()?;
100    let mut args = vec!["new".to_string(), branch.to_string()];
101    if let Some(path) = payload.worktree_path.as_deref().filter(|p| !p.is_empty()) {
102        args.push("--path".to_string());
103        args.push(path.to_string());
104    }
105    if let Some(base) = payload.base_commit.as_deref().filter(|b| !b.is_empty()) {
106        args.push("--base".to_string());
107        args.push(base.to_string());
108    }
109    args.extend(["--emit", "json", "--term", "skip"].map(str::to_string));
110    let output = Command::new(&exe)
111        .args(&args)
112        // Force the inner `gw new` to error rather than silently returning an
113        // existing worktree path with no JSON on stdout — the hook contract
114        // requires that we only succeed when a fresh worktree was created.
115        .env("CW_NON_INTERACTIVE", "1")
116        .stdin(Stdio::null())
117        .stdout(Stdio::piped())
118        .stderr(Stdio::inherit())
119        .output()?;
120
121    if !output.status.success() {
122        return Err(CwError::Other(format!(
123            "gw new failed (exit {})",
124            output.status.code().unwrap_or(-1)
125        )));
126    }
127
128    // 5. Parse the JSON line from the inner process and extract worktree_path.
129    let stdout = String::from_utf8_lossy(&output.stdout);
130    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
131        .map_err(|e| CwError::Other(format!("could not parse `gw new --emit json` output: {e}")))?;
132    let wt_path = parsed["worktree_path"]
133        .as_str()
134        .ok_or_else(|| CwError::Other("`worktree_path` missing in `gw new` JSON output".into()))?;
135
136    // 6. Emit only the plain-text path — what Claude Code's hook reads.
137    println!("{}", wt_path);
138    Ok(())
139}
140
141// ---------------------------------------------------------------------------
142// _claude-worktree-remove
143// ---------------------------------------------------------------------------
144
145/// Handle a Claude Code `WorktreeRemove` hook event.
146///
147/// Reads the JSON payload from stdin, extracts `worktree_path`, and runs the
148/// configured `pre_rm` hook in that directory as a best-effort advisory step.
149/// Hook failures are logged to stderr but never abort the handler — Claude Code
150/// owns the actual removal, and `WorktreeRemove` hooks cannot block it anyway.
151///
152/// Always exits 0.
153pub fn run_remove() -> Result<()> {
154    // 1. Read and parse the hook payload from stdin.
155    let mut buf = String::new();
156    std::io::stdin().read_to_string(&mut buf)?;
157    let payload: RemovePayload = serde_json::from_str(&buf)
158        .map_err(|e| CwError::Other(format!("invalid WorktreeRemove hook payload: {e}")))?;
159
160    let wt_path = Path::new(&payload.worktree_path);
161
162    // 2. Run pre_rm hook as advisory — log warning on failure, never propagate.
163    //    This mirrors the contract established in task #2 (hooks.rs).
164    if let Err(e) = crate::hooks::run_event("pre_rm", wt_path) {
165        eprintln!(
166            "warning: pre_rm hook failed for '{}' (advisory, removal proceeds): {}",
167            wt_path.display(),
168            e
169        );
170    }
171
172    // 3. Claude Code performs the actual worktree removal; we have nothing more to do.
173    Ok(())
174}