Skip to main content

git_worktree_manager/
entrypoint.rs

1//! CLI entrypoint for the `gw` binary.
2//!
3//! `src/bin/gw.rs` delegates to [`run`]. The library entrypoint stays
4//! separate from the binary so unit tests in this module compile without
5//! pulling in the binary stack-size shim.
6
7use clap::Parser;
8
9use crate::cli::{Cli, Commands};
10use crate::config;
11use crate::console as cwconsole;
12use crate::constants;
13use crate::cwshare_setup;
14use crate::error::{CwError, Result};
15use crate::operations::ai_tools::LaunchOptions;
16use crate::operations::{
17    ai_tools, diagnostics, display, exec, guard, helpers, path_cmd, run, setup_claude, spawn_spec,
18    worktree,
19};
20use crate::resolve_prompt;
21use crate::shell_functions;
22use crate::tui;
23use crate::update;
24use std::io::{IsTerminal, Read};
25
26pub fn run() {
27    tui::install_panic_hook();
28    let cli = Cli::parse();
29
30    if let Some(ref shell_name) = cli.generate_completion {
31        generate_completions(shell_name);
32        return;
33    }
34
35    // Skip startup checks for internal commands (shell-completion helpers,
36    // cache refresh) — they are invoked by the shell on every keystroke, so
37    // paying for update-check / prompts would compound latency and risk
38    // recursive re-entry into the update flow.
39    let is_internal = matches!(
40        &cli.command,
41        Some(
42            Commands::UpdateCache
43                | Commands::CompleteTargets
44                | Commands::Path { .. }
45                | Commands::ShellFunction { .. }
46                | Commands::SpawnAi { .. }
47                | Commands::Guard { .. }
48        )
49    );
50
51    if !is_internal {
52        crate::operations::spawn_spec::sweep_stale();
53        update::check_for_update_if_needed();
54    }
55
56    if !is_internal {
57        config::prompt_shell_completion_setup();
58    }
59
60    let result = match cli.command {
61        Some(Commands::List) => display::list_worktrees(),
62        Some(Commands::Ls) => display::list_worktrees_tsv(),
63        Some(Commands::New {
64            name,
65            path,
66            base,
67            term,
68            prompt,
69            prompt_file,
70            no_env_forward,
71            forward_args,
72        }) => (|| -> Result<()> {
73            // Reject --prompt + trailing forward args at dispatch time —
74            // both ultimately set the AI tool's prompt, so allowing both
75            // would silently produce two prompts. Surface this before
76            // anything touches disk.
77            if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
78                return Err(CwError::Other(
79                    "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
80                     pick one or the other"
81                        .to_string(),
82                ));
83            }
84            // Resolve the prompt first so a missing file or unreadable stdin
85            // fails before any interactive side effects (worktree creation,
86            // AI-tool launch) leave the tree in a half-configured state.
87            let resolved = resolve_prompt(
88                prompt,
89                prompt_file.as_deref(),
90                || std::io::stdin().is_terminal(),
91                || {
92                    let mut buf = String::new();
93                    std::io::stdin().read_to_string(&mut buf)?;
94                    Ok(buf)
95                },
96            )?;
97            // Pre-flight `-T <method>` so a typo (`-T does-not-exist`) errors
98            // before we create a worktree on disk. The launch path inside
99            // create_worktree swallows spawn errors with `let _ = …`, which
100            // would otherwise leave a phantom worktree on a bad alias.
101            // Skip / none / noop is a valid value here, so this still parses.
102            let _ = config::parse_term_option(term.as_deref())?;
103            cwshare_setup::prompt_cwshare_setup();
104
105            let opts = LaunchOptions {
106                term_override: term.as_deref(),
107                forward_args: &forward_args,
108                no_env_forward,
109            };
110            worktree::create_worktree(
111                &name,
112                base.as_deref(),
113                path.as_deref(),
114                resolved.as_deref(),
115                &opts,
116            )?;
117            Ok(())
118        })(),
119
120        Some(Commands::Resume {
121            branch,
122            term,
123            no_env_forward,
124            forward_args,
125        }) => {
126            // clap's trailing_var_arg + Option<positional> combo lets `--`
127            // get absorbed by the optional positional: `gw resume -- --model
128            // opus` parses as branch=Some("--model"), forward_args=["opus"].
129            // Detect a hyphen-led "branch" and lift it into forward_args.
130            let (branch, forward_args) = lift_dash_target(branch, forward_args);
131            let opts = LaunchOptions {
132                term_override: term.as_deref(),
133                forward_args: &forward_args,
134                no_env_forward,
135            };
136            ai_tools::resume_worktree(branch.as_deref(), &opts)
137        }
138
139        Some(Commands::Spawn {
140            target,
141            term,
142            prompt,
143            prompt_file,
144            no_env_forward,
145            forward_args,
146        }) => (|| -> Result<()> {
147            // See Resume arm: clap absorbs `--` into the optional positional.
148            let (target, forward_args) = lift_dash_target(target, forward_args);
149            // Same prompt/forward conflict as `gw new`.
150            if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
151                return Err(CwError::Other(
152                    "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
153                     pick one or the other"
154                        .to_string(),
155                ));
156            }
157            let resolved_prompt = resolve_prompt(
158                prompt,
159                prompt_file.as_deref(),
160                || std::io::stdin().is_terminal(),
161                || {
162                    let mut buf = String::new();
163                    std::io::stdin().read_to_string(&mut buf)?;
164                    Ok(buf)
165                },
166            )?;
167            let cwd = std::env::current_dir()?;
168            let target_path = match target {
169                Some(t) => {
170                    let main_repo = crate::git::get_main_repo_root(Some(&cwd))?;
171                    helpers::resolve_target_strict(&main_repo, &t)?.path
172                }
173                None => crate::git::get_repo_root(Some(&cwd))?,
174            };
175            let opts = LaunchOptions {
176                term_override: term.as_deref(),
177                forward_args: &forward_args,
178                no_env_forward,
179            };
180            ai_tools::spawn_in_worktree(&target_path, resolved_prompt.as_deref(), &opts)
181        })(),
182
183        Some(Commands::Rm {
184            targets,
185            interactive,
186            dry_run,
187            keep_branch,
188            delete_remote,
189            force,
190            no_force,
191        }) => {
192            let flags = crate::operations::worktree::RmFlags {
193                keep_branch,
194                delete_remote,
195                git_force: !no_force,
196                allow_busy: force,
197            };
198            match crate::operations::rm_batch::rm_worktrees(targets, interactive, dry_run, flags) {
199                Ok(0) => Ok(()),
200                Ok(code) => Err(crate::error::CwError::ExitCode(code)),
201                Err(e) => Err(e),
202            }
203        }
204
205        Some(Commands::Doctor {
206            session_start,
207            quiet,
208        }) => diagnostics::doctor(session_start, quiet),
209        Some(Commands::Run {
210            only,
211            no_main,
212            jobs,
213            continue_on_error,
214            cmd,
215        }) => (|| -> Result<()> {
216            let cwd = std::env::current_dir()?;
217            let code = run::run_in_scope(
218                &cwd,
219                &cmd,
220                only.as_deref(),
221                no_main,
222                jobs,
223                continue_on_error,
224            )?;
225            if code != 0 {
226                return Err(crate::error::CwError::ExitCode(code));
227            }
228            Ok(())
229        })(),
230
231        Some(Commands::Exec { target, cmd }) => (|| -> Result<()> {
232            let cwd = std::env::current_dir()?;
233            let mut out = std::io::stdout().lock();
234            let code = exec::exec_in_target(&cwd, &target, &cmd, &mut out)?;
235            if code != 0 {
236                return Err(crate::error::CwError::ExitCode(code));
237            }
238            Ok(())
239        })(),
240
241        Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
242        Some(Commands::SetupClaude) => setup_claude::setup_claude(),
243
244        Some(Commands::Upgrade { yes }) => {
245            update::upgrade(yes);
246            Ok(())
247        }
248
249        Some(Commands::ShellSetup) => {
250            shell_setup();
251            Ok(())
252        }
253
254        Some(Commands::Path {
255            branch,
256            list_branches,
257            interactive,
258        }) => path_cmd::worktree_path(branch.as_deref(), list_branches, interactive),
259
260        Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
261            Some(output) => {
262                print!("{}", output);
263                Ok(())
264            }
265            None => Err(CwError::Config(format!(
266                "Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
267                shell
268            ))),
269        },
270
271        Some(Commands::UpdateCache) => {
272            update::refresh_cache();
273            Ok(())
274        }
275
276        Some(Commands::CompleteTargets) => crate::operations::complete::print_completion_targets(),
277
278        Some(Commands::SpawnAi { spec }) => {
279            // Pre-spawn failures (read/parse/chdir) exit 127 — the shell
280            // "command not found / could not start" convention. Post-spawn
281            // failures exit from inside `execute` directly, also with 127.
282            // Inner errors already carry the "spawn-ai:" prefix via their
283            // CwError::Other messages, so we print them verbatim.
284            let resolved = match spec {
285                Some(p) => p,
286                None => match spawn_spec::resolve_last_for_cwd() {
287                    Ok(p) => p,
288                    Err(e) => {
289                        eprintln!("{}", e);
290                        std::process::exit(127);
291                    }
292                },
293            };
294            if let Err(e) = spawn_spec::execute(&resolved) {
295                eprintln!("{}", e);
296                std::process::exit(127);
297            }
298            Ok(())
299        }
300
301        None => Ok(()),
302    };
303
304    if let Err(e) = result {
305        // ExitCode carries a specific exit status from callers that have
306        // already produced their own user-facing output (e.g. the multi-target
307        // delete orchestrator). Exit silently with that code instead of the
308        // generic "Error: …" print.
309        if let CwError::ExitCode(code) = e {
310            std::process::exit(code);
311        }
312        cwconsole::print_error(&format!("Error: {}", e));
313        std::process::exit(1);
314    }
315}
316
317fn generate_completions(shell_name: &str) {
318    use clap::CommandFactory;
319    use clap_complete::{generate, Shell};
320
321    let shell = match shell_name.to_lowercase().as_str() {
322        "bash" => Shell::Bash,
323        "zsh" => Shell::Zsh,
324        "fish" => Shell::Fish,
325        "powershell" | "pwsh" => Shell::PowerShell,
326        "elvish" => Shell::Elvish,
327        _ => {
328            eprintln!(
329                "Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
330                shell_name
331            );
332            std::process::exit(1);
333        }
334    };
335
336    let mut cmd = Cli::command();
337    generate(shell, &mut cmd, "gw", &mut std::io::stdout());
338}
339
340fn shell_setup() {
341    let shell_env = std::env::var("SHELL").unwrap_or_default();
342    let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
343
344    let home = constants::home_dir_or_fallback();
345    let (shell_name, profile_path) = if shell_env.contains("zsh") {
346        ("zsh", Some(home.join(".zshrc")))
347    } else if shell_env.contains("bash") {
348        ("bash", Some(home.join(".bashrc")))
349    } else if shell_env.contains("fish") {
350        (
351            "fish",
352            Some(home.join(".config").join("fish").join("config.fish")),
353        )
354    } else if is_powershell {
355        ("powershell", None::<std::path::PathBuf>)
356    } else {
357        println!("Could not detect your shell automatically.\n");
358        println!("Please manually add the gw-cd function to your shell:\n");
359        println!("  bash/zsh:    source <(gw _shell-function bash)");
360        println!("  fish:        gw _shell-function fish | source");
361        println!("  PowerShell:  gw _shell-function powershell | Out-String | Invoke-Expression");
362        return;
363    };
364
365    println!("Detected shell: {}\n", shell_name);
366
367    if shell_name == "powershell" {
368        println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
369        println!("  gw _shell-function powershell | Out-String | Invoke-Expression\n");
370        println!("To find your PowerShell profile location, run: $PROFILE");
371        println!(
372            "\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
373        );
374        return;
375    }
376
377    let shell_function_line = match shell_name {
378        "fish" => "gw _shell-function fish | source".to_string(),
379        _ => format!("source <(gw _shell-function {})", shell_name),
380    };
381
382    if let Some(ref path) = profile_path {
383        if path.exists() {
384            if let Ok(content) = std::fs::read_to_string(path) {
385                if content.contains("gw _shell-function") || content.contains("gw-cd") {
386                    println!(
387                        "{}",
388                        console::style("Shell integration is already installed.").green()
389                    );
390                    println!("  Found in: {}\n", path.display());
391
392                    refresh_shell_cache(shell_name);
393
394                    println!("\nRestart your shell or run: source {}", path.display());
395                    return;
396                }
397            }
398        }
399    }
400
401    println!("Setup shell integration?\n");
402    println!(
403        "This will add the following to {}:",
404        profile_path
405            .as_ref()
406            .map(|p| p.display().to_string())
407            .unwrap_or("your profile".to_string())
408    );
409
410    println!(
411        "\n  # git-worktree-manager shell integration{}",
412        if matches!(shell_name, "zsh" | "bash") {
413            " (gw-cd + tab completion)"
414        } else {
415            ""
416        }
417    );
418    println!("  {}\n", shell_function_line);
419
420    print!("Add to your shell profile? [Y/n]: ");
421    use std::io::Write;
422    let _ = std::io::stdout().flush();
423
424    let mut input = String::new();
425    let _ = std::io::stdin().read_line(&mut input);
426    let input = input.trim().to_lowercase();
427
428    if !input.is_empty() && input != "y" && input != "yes" {
429        println!("\nSetup cancelled.");
430        return;
431    }
432
433    let Some(ref path) = profile_path else {
434        return;
435    };
436
437    if let Some(parent) = path.parent() {
438        let _ = std::fs::create_dir_all(parent);
439    }
440
441    let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
442        " (gw-cd + tab completion)"
443    } else {
444        ""
445    };
446    let append = format!(
447        "\n# git-worktree-manager shell integration{}\n{}\n",
448        comment_suffix, shell_function_line
449    );
450
451    match std::fs::OpenOptions::new()
452        .create(true)
453        .append(true)
454        .open(path)
455    {
456        Ok(mut f) => {
457            let _ = f.write_all(append.as_bytes());
458
459            if let Ok(mut cfg) = config::load_config() {
460                cfg.shell_completion.installed = true;
461                cfg.shell_completion.prompted = true;
462                let _ = config::save_config(&cfg);
463            }
464
465            println!("\n* Successfully added to {}", path.display());
466
467            refresh_shell_cache(shell_name);
468
469            println!("\nNext steps:");
470            println!("  1. Restart your shell or run: source {}", path.display());
471            println!("  2. Try directory navigation: gw-cd <branch-name>");
472            println!("  3. Try tab completion: gw <TAB> or gw new <TAB>");
473        }
474        Err(e) => {
475            println!("\nError: Failed to update {}: {}", path.display(), e);
476            println!("\nTo install manually, add the lines shown above to your profile");
477        }
478    }
479}
480
481/// Refresh cached shell function files to pick up new features.
482fn refresh_shell_cache(shell_name: &str) {
483    let home = constants::home_dir_or_fallback();
484
485    let cache_paths = [
486        home.join(".cache").join("gw-shell-function.zsh"),
487        home.join(".cache").join("gw-shell-function.bash"),
488        home.join(".cache").join("gw-shell-function.fish"),
489    ];
490
491    let mut refreshed = false;
492    for cache_path in &cache_paths {
493        if !cache_path.exists() {
494            continue;
495        }
496        let cache_shell = cache_path
497            .extension()
498            .and_then(|e| e.to_str())
499            .unwrap_or("");
500        if let Some(content) = shell_functions::generate(cache_shell) {
501            if std::fs::write(cache_path, content).is_ok() {
502                println!(
503                    "  {} {}",
504                    console::style("Refreshed cache:").dim(),
505                    cache_path.display()
506                );
507                refreshed = true;
508            }
509        }
510    }
511
512    if refreshed {
513        return;
514    }
515
516    let cache_path = home
517        .join(".cache")
518        .join(format!("gw-shell-function.{}", shell_name));
519    if let Some(content) = shell_functions::generate(shell_name) {
520        let _ = std::fs::create_dir_all(cache_path.parent().unwrap_or(&home));
521        if std::fs::write(&cache_path, &content).is_ok() {
522            println!(
523                "  {} {}",
524                console::style("Created cache:").dim(),
525                cache_path.display()
526            );
527        }
528    }
529}
530
531/// `gw spawn -- --model opus` and `gw resume -- --model opus` parse, under
532/// clap's `trailing_var_arg=true` + `Option<String>` positional combo, as
533/// `target=Some("--model")`, `forward_args=["opus"]` — clap silently absorbs
534/// the `--` into the optional positional. A real worktree name can never
535/// start with `-` (git rejects it), so a hyphen-led "target" is unambiguously
536/// a misparsed forward arg. Lift it (and any captured forward args) back into
537/// forward_args, with `target` cleared so the dispatcher falls through to
538/// "current worktree".
539fn lift_dash_target(
540    target: Option<String>,
541    forward_args: Vec<String>,
542) -> (Option<String>, Vec<String>) {
543    match target {
544        Some(t) if t.starts_with('-') => {
545            let mut lifted = Vec::with_capacity(forward_args.len() + 1);
546            lifted.push(t);
547            lifted.extend(forward_args);
548            (None, lifted)
549        }
550        other => (other, forward_args),
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::lift_dash_target;
557
558    #[test]
559    fn lift_dash_target_lifts_hyphen_target() {
560        let (target, fwd) = lift_dash_target(
561            Some("--model".to_string()),
562            vec!["opus".to_string(), "--resume".to_string()],
563        );
564        assert_eq!(target, None);
565        assert_eq!(fwd, vec!["--model", "opus", "--resume"]);
566    }
567
568    #[test]
569    fn lift_dash_target_passes_through_normal_target() {
570        let (target, fwd) = lift_dash_target(
571            Some("feat-x".to_string()),
572            vec!["--model".to_string(), "opus".to_string()],
573        );
574        assert_eq!(target.as_deref(), Some("feat-x"));
575        assert_eq!(fwd, vec!["--model", "opus"]);
576    }
577
578    #[test]
579    fn lift_dash_target_handles_none_target() {
580        let (target, fwd) = lift_dash_target(None, vec![]);
581        assert_eq!(target, None);
582        assert!(fwd.is_empty());
583    }
584
585    #[test]
586    fn lift_dash_target_lifts_with_no_forward_args() {
587        // `gw spawn -- --model` (no value) — pathological but still well-defined.
588        let (target, fwd) = lift_dash_target(Some("--model".to_string()), vec![]);
589        assert_eq!(target, None);
590        assert_eq!(fwd, vec!["--model"]);
591    }
592}