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