Skip to main content

git_worktree_manager/operations/
ai_tools.rs

1/// AI tool integration operations.
2///
3/// Handles launching AI coding assistants in various terminal environments.
4use std::path::Path;
5
6use console::style;
7
8use crate::config::{self, get_ai_tool_command, get_ai_tool_resume_command, is_claude_tool};
9use crate::constants::{LaunchMethod, MAX_SESSION_NAME_LENGTH};
10use crate::error::Result;
11use crate::git;
12use crate::messages;
13use crate::session;
14
15use super::helpers::{resolve_target_strict, resolve_worktree_target};
16use super::launchers;
17use super::spawn_spec::{self, SpawnSpec};
18
19/// Dispatch a pre-materialized command to the configured launcher.
20///
21/// Both `launch_ai_tool` and `spawn_in_worktree` share this block; keeping it
22/// in one place means any launcher added in the future is automatically
23/// available to both callers.
24fn dispatch_launch(
25    path: &Path,
26    method: LaunchMethod,
27    session_name: Option<String>,
28    cmd: &str,
29    ai_tool_name: &str,
30) -> Result<()> {
31    match method {
32        LaunchMethod::Foreground => {
33            println!(
34                "{}\n",
35                style(messages::starting_ai_tool_foreground(ai_tool_name)).cyan()
36            );
37            // `_session_lock` binding is intentional: RAII guard lives for
38            // the foreground AI process lifetime; dropped on return.
39            let _session_lock = match crate::operations::lockfile::acquire(path, ai_tool_name) {
40                Ok(lock) => Some(lock),
41                Err(err @ crate::operations::lockfile::AcquireError::ForeignLock(_)) => {
42                    return Err(crate::error::CwError::Other(format!(
43                        "{}; exit that session first",
44                        err
45                    )));
46                }
47                Err(e) => {
48                    eprintln!(
49                        "{} could not write session lock: {}",
50                        style("warning:").yellow(),
51                        e
52                    );
53                    None
54                }
55            };
56            launchers::foreground::run(path, cmd);
57        }
58        LaunchMethod::Detach => {
59            launchers::detached::run(path, cmd);
60            println!(
61                "{} {} detached (survives terminal close)\n",
62                style("*").green().bold(),
63                ai_tool_name
64            );
65        }
66        // iTerm
67        LaunchMethod::ItermWindow => launchers::iterm::launch_window(path, cmd, ai_tool_name)?,
68        LaunchMethod::ItermTab => launchers::iterm::launch_tab(path, cmd, ai_tool_name)?,
69        LaunchMethod::ItermPaneH => launchers::iterm::launch_pane(path, cmd, ai_tool_name, true)?,
70        LaunchMethod::ItermPaneV => launchers::iterm::launch_pane(path, cmd, ai_tool_name, false)?,
71        // tmux
72        LaunchMethod::Tmux => {
73            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
74            launchers::tmux::launch_session(path, cmd, ai_tool_name, &sn)?;
75        }
76        LaunchMethod::TmuxWindow => launchers::tmux::launch_window(path, cmd, ai_tool_name)?,
77        LaunchMethod::TmuxPaneH => launchers::tmux::launch_pane(path, cmd, ai_tool_name, true)?,
78        LaunchMethod::TmuxPaneV => launchers::tmux::launch_pane(path, cmd, ai_tool_name, false)?,
79        // Zellij
80        LaunchMethod::Zellij => {
81            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
82            launchers::zellij::launch_session(path, cmd, ai_tool_name, &sn)?;
83        }
84        LaunchMethod::ZellijTab => launchers::zellij::launch_tab(path, cmd, ai_tool_name)?,
85        LaunchMethod::ZellijPaneH => launchers::zellij::launch_pane(path, cmd, ai_tool_name, true)?,
86        LaunchMethod::ZellijPaneV => {
87            launchers::zellij::launch_pane(path, cmd, ai_tool_name, false)?
88        }
89        // WezTerm
90        LaunchMethod::WeztermWindow => launchers::wezterm::launch_window(path, cmd, ai_tool_name)?,
91        LaunchMethod::WeztermTab => launchers::wezterm::launch_tab(path, cmd, ai_tool_name)?,
92        LaunchMethod::WeztermTabBg => launchers::wezterm::launch_tab_bg(path, cmd, ai_tool_name)?,
93        LaunchMethod::WeztermPaneH => {
94            launchers::wezterm::launch_pane(path, cmd, ai_tool_name, true)?
95        }
96        LaunchMethod::WeztermPaneV => {
97            launchers::wezterm::launch_pane(path, cmd, ai_tool_name, false)?
98        }
99    }
100
101    Ok(())
102}
103
104/// Launch AI coding assistant in the specified directory.
105pub fn launch_ai_tool(path: &Path, resume: bool, term_override: Option<&str>) -> Result<()> {
106    let (method, session_name) = config::resolve_term_option(term_override, path)?;
107
108    // Determine command
109    let ai_cmd_parts = if resume {
110        get_ai_tool_resume_command()?
111    } else if is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(path) {
112        eprintln!("Found existing Claude session, using --continue");
113        get_ai_tool_resume_command()?
114    } else {
115        get_ai_tool_command()?
116    };
117
118    if ai_cmd_parts.is_empty() {
119        return Ok(());
120    }
121
122    let ai_tool_name = ai_cmd_parts[0].clone();
123
124    if !git::has_command(&ai_tool_name) {
125        println!(
126            "{} {} not detected. Install it or update config with 'cw config set ai-tool <tool>'.\n",
127            style("!").yellow(),
128            ai_tool_name,
129        );
130        return Ok(());
131    }
132
133    // See `spawn_spec` module docstring for why the emitted line is
134    // `gw _spawn-ai <path>` (no `exec` prefix) and how the raw argv flows
135    // through a 0600 temp file rather than the shell line.
136    let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf());
137    // The spec file is cleaned up by `spawn_spec::execute` after read; the 24h
138    // `sweep_stale` at startup is the safety net for crashes between those points.
139    let (cmd, _) = spawn_spec::materialize(&spec)?;
140
141    // Dispatch to launcher. Foreground blocks on the AI process, so an RAII
142    // lockfile spans the full session. Other launchers detach to a terminal
143    // emulator / multiplexer and return immediately, so a lock acquired here
144    // would be released before the AI session really starts — for those we
145    // rely on process-cwd scanning in `busy::detect_busy` instead.
146    dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
147}
148
149/// Resume AI work in a worktree with context restoration.
150///
151/// Target resolution uses strict ordered rules: exact worktree name → exact branch
152/// name → exact path. When no target is given, the current working directory is used.
153pub fn resume_worktree(worktree: Option<&str>, term_override: Option<&str>) -> Result<()> {
154    let (worktree_path, branch_name) = if let Some(target) = worktree {
155        let main_repo = git::get_main_repo_root(None)?;
156        let strict = resolve_target_strict(&main_repo, target)?;
157        let branch_name = strict.branch.unwrap_or_else(|| {
158            strict
159                .path
160                .file_name()
161                .map(|n| n.to_string_lossy().into_owned())
162                .unwrap_or_else(|| "(detached)".into())
163        });
164        (strict.path, branch_name)
165    } else {
166        // No target — use current working directory.
167        let resolved = resolve_worktree_target(None, None)?;
168        (resolved.path, resolved.branch)
169    };
170
171    // Change directory if specified
172    if worktree.is_some() {
173        let _ = std::env::set_current_dir(&worktree_path);
174        println!(
175            "{}\n",
176            style(messages::switched_to_worktree(&worktree_path)).dim()
177        );
178    }
179
180    // Check for existing session
181    let has_session =
182        is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(&worktree_path);
183
184    if has_session {
185        println!(
186            "{} Found session for branch: {}",
187            style("*").green(),
188            style(&branch_name).bold()
189        );
190
191        if let Some(metadata) = session::load_session_metadata(&branch_name) {
192            println!("  AI tool: {}", style(&metadata.ai_tool).dim());
193            println!("  Last updated: {}", style(&metadata.updated_at).dim());
194        }
195
196        if let Some(context) = session::load_context(&branch_name) {
197            println!("\n{}", style("Previous context:").cyan());
198            println!("{}", style(&context).dim());
199        }
200        println!();
201    } else {
202        println!(
203            "{} No previous session found for branch: {}",
204            style("i").yellow(),
205            style(&branch_name).bold()
206        );
207        println!("{}\n", style("Starting fresh session...").dim());
208    }
209
210    // Save metadata and launch
211    let ai_cmd = if has_session {
212        get_ai_tool_resume_command()?
213    } else {
214        get_ai_tool_command()?
215    };
216
217    if !ai_cmd.is_empty() {
218        let ai_tool_name = &ai_cmd[0];
219        let _ = session::save_session_metadata(
220            &branch_name,
221            ai_tool_name,
222            &worktree_path.to_string_lossy(),
223        );
224
225        if has_session {
226            println!(
227                "{} {}\n",
228                style(messages::resuming_ai_tool_in(ai_tool_name)).cyan(),
229                worktree_path.display()
230            );
231        } else {
232            println!(
233                "{} {}\n",
234                style(messages::starting_ai_tool_in(ai_tool_name)).cyan(),
235                worktree_path.display()
236            );
237        }
238
239        launch_ai_tool(&worktree_path, has_session, term_override)?;
240    }
241
242    Ok(())
243}
244
245/// Launch the configured AI tool inside an existing worktree.
246///
247/// Used by both `gw new` (after worktree creation) and `gw spawn`. Honors the
248/// resolved launch method (CLI override > env > config > default).
249pub fn spawn_in_worktree(
250    worktree_path: &Path,
251    prompt: Option<&str>,
252    term_override: Option<&str>,
253) -> Result<()> {
254    let (method, session_name) = config::resolve_term_option(term_override, worktree_path)?;
255
256    let ai_cmd_parts = if let Some(p) = prompt {
257        config::get_ai_tool_merge_command(p)?
258    } else {
259        get_ai_tool_command()?
260    };
261
262    if ai_cmd_parts.is_empty() {
263        return Ok(());
264    }
265
266    let ai_tool_name = ai_cmd_parts[0].clone();
267
268    if !git::has_command(&ai_tool_name) {
269        println!(
270            "{} {} not detected. Install it or update config with 'cw config set ai-tool <tool>'.\n",
271            style("!").yellow(),
272            ai_tool_name,
273        );
274        return Ok(());
275    }
276
277    let spec = SpawnSpec::new(ai_cmd_parts, worktree_path.to_path_buf());
278    let (cmd, _) = spawn_spec::materialize(&spec)?;
279
280    dispatch_launch(
281        worktree_path,
282        method,
283        session_name,
284        &cmd,
285        ai_tool_name.as_str(),
286    )
287}
288
289/// Generate a session name from path with length limit.
290fn generate_session_name(path: &Path) -> String {
291    let config = config::load_config().unwrap_or_default();
292    let prefix = &config.launch.tmux_session_prefix;
293    let dir_name = path
294        .file_name()
295        .map(|n| n.to_string_lossy().to_string())
296        .unwrap_or_else(|| "worktree".to_string());
297
298    let name = format!("{}-{}", prefix, dir_name);
299    if name.len() > MAX_SESSION_NAME_LENGTH {
300        name[..MAX_SESSION_NAME_LENGTH].to_string()
301    } else {
302        name
303    }
304}