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_name: String,
21    base_path: Option<String>,
22    // All other fields (hook_event_name, session_id, cwd, worktree_path,
23    // isolation_mode, …) are intentionally ignored.
24    #[serde(flatten)]
25    _other: serde_json::Value,
26}
27
28#[derive(serde::Deserialize)]
29struct RemovePayload {
30    worktree_path: String,
31    // All other fields are intentionally ignored.
32    #[serde(flatten)]
33    _other: serde_json::Value,
34}
35
36// ---------------------------------------------------------------------------
37// _claude-worktree-create
38// ---------------------------------------------------------------------------
39
40/// Handle a Claude Code `WorktreeCreate` hook event.
41///
42/// Reads the JSON payload from stdin, delegates to `gw new --emit json
43/// --term skip` (self-reexec), parses the resulting JSON, and prints only the
44/// `worktree_path` as a plain-text line to stdout — exactly what Claude Code's
45/// hook contract expects.
46///
47/// Human-readable progress output from the inner `gw new` call flows through
48/// to stderr unchanged, so the user can see what's happening.
49pub fn run_create() -> Result<()> {
50    // 1. Read and parse the hook payload from stdin.
51    let mut buf = String::new();
52    std::io::stdin().read_to_string(&mut buf)?;
53    let payload: CreatePayload = serde_json::from_str(&buf)
54        .map_err(|e| CwError::Other(format!("invalid WorktreeCreate hook payload: {e}")))?;
55
56    // 2. If base_path is provided and differs from cwd, switch to it so that
57    //    the inner `gw new` discovers the correct repo root.
58    if let Some(ref bp) = payload.base_path {
59        std::env::set_current_dir(bp)?;
60    }
61
62    // 3. Re-exec the current binary as `gw new <branch> --emit json --term skip`.
63    //    stdout is piped so we can extract `worktree_path`; stderr is inherited
64    //    so the user sees human-readable progress from the inner process.
65    let exe = std::env::current_exe()?;
66    let output = Command::new(&exe)
67        .args([
68            "new",
69            &payload.branch_name,
70            "--emit",
71            "json",
72            "--term",
73            "skip",
74        ])
75        // Force the inner `gw new` to error rather than silently returning an
76        // existing worktree path with no JSON on stdout — the hook contract
77        // requires that we only succeed when a fresh worktree was created.
78        .env("CW_NON_INTERACTIVE", "1")
79        .stdin(Stdio::null())
80        .stdout(Stdio::piped())
81        .stderr(Stdio::inherit())
82        .output()?;
83
84    if !output.status.success() {
85        return Err(CwError::Other(format!(
86            "gw new failed (exit {})",
87            output.status.code().unwrap_or(-1)
88        )));
89    }
90
91    // 4. Parse the JSON line from the inner process and extract worktree_path.
92    let stdout = String::from_utf8_lossy(&output.stdout);
93    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
94        .map_err(|e| CwError::Other(format!("could not parse `gw new --emit json` output: {e}")))?;
95    let wt_path = parsed["worktree_path"]
96        .as_str()
97        .ok_or_else(|| CwError::Other("`worktree_path` missing in `gw new` JSON output".into()))?;
98
99    // 5. Emit only the plain-text path — what Claude Code's hook reads.
100    println!("{}", wt_path);
101    Ok(())
102}
103
104// ---------------------------------------------------------------------------
105// _claude-worktree-remove
106// ---------------------------------------------------------------------------
107
108/// Handle a Claude Code `WorktreeRemove` hook event.
109///
110/// Reads the JSON payload from stdin, extracts `worktree_path`, and runs the
111/// configured `pre_rm` hook in that directory as a best-effort advisory step.
112/// Hook failures are logged to stderr but never abort the handler — Claude Code
113/// owns the actual removal, and `WorktreeRemove` hooks cannot block it anyway.
114///
115/// Always exits 0.
116pub fn run_remove() -> Result<()> {
117    // 1. Read and parse the hook payload from stdin.
118    let mut buf = String::new();
119    std::io::stdin().read_to_string(&mut buf)?;
120    let payload: RemovePayload = serde_json::from_str(&buf)
121        .map_err(|e| CwError::Other(format!("invalid WorktreeRemove hook payload: {e}")))?;
122
123    let wt_path = Path::new(&payload.worktree_path);
124
125    // 2. Run pre_rm hook as advisory — log warning on failure, never propagate.
126    //    This mirrors the contract established in task #2 (hooks.rs).
127    if let Err(e) = crate::hooks::run_event("pre_rm", wt_path) {
128        eprintln!(
129            "warning: pre_rm hook failed for '{}' (advisory, removal proceeds): {}",
130            wt_path.display(),
131            e
132        );
133    }
134
135    // 3. Claude Code performs the actual worktree removal; we have nothing more to do.
136    Ok(())
137}