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}