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