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