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            // Catch `gw new <name> -- --prompt-file <path>` — clap would
85            // forward those gw-owned flags verbatim into the AI tool's
86            // argv, and `claude` would then die with "unknown option".
87            reject_gw_flags_in_forward(&forward_args)?;
88            // Resolve the prompt first so a missing file or unreadable stdin
89            // fails before any interactive side effects (worktree creation,
90            // AI-tool launch) leave the tree in a half-configured state.
91            let resolved = resolve_prompt(
92                prompt,
93                prompt_file.as_deref(),
94                || std::io::stdin().is_terminal(),
95                || {
96                    let mut buf = String::new();
97                    std::io::stdin().read_to_string(&mut buf)?;
98                    Ok(buf)
99                },
100            )?;
101            // Pre-flight `-T <method>` so a typo (`-T does-not-exist`) errors
102            // before we create a worktree on disk. The launch path inside
103            // create_worktree swallows spawn errors with `let _ = …`, which
104            // would otherwise leave a phantom worktree on a bad alias.
105            // Skip / none / noop is a valid value here, so this still parses.
106            let _ = config::parse_term_option(term.as_deref())?;
107            cwshare_setup::prompt_cwshare_setup();
108
109            let opts = LaunchOptions {
110                term_override: term.as_deref(),
111                forward_args: &forward_args,
112                no_env_forward,
113            };
114            worktree::create_worktree(
115                &name,
116                base.as_deref(),
117                path.as_deref(),
118                resolved.as_deref(),
119                &opts,
120            )?;
121            Ok(())
122        })(),
123
124        Some(Commands::Resume {
125            branch,
126            term,
127            no_env_forward,
128            forward_args,
129        }) => (|| -> Result<()> {
130            // clap's trailing_var_arg + Option<positional> combo lets `--`
131            // get absorbed by the optional positional: `gw resume -- --model
132            // opus` parses as branch=Some("--model"), forward_args=["opus"].
133            // Detect a hyphen-led "branch" and lift it into forward_args.
134            let (branch, forward_args) = lift_dash_target(branch, forward_args);
135            // `gw resume` has no `--prompt` of its own, but we still want
136            // `gw resume <branch> -- --prompt-file <path>` to fail fast
137            // here rather than inside the spawned terminal where the user
138            // can't see the AI tool's stderr.
139            reject_gw_flags_in_forward(&forward_args)?;
140            let opts = LaunchOptions {
141                term_override: term.as_deref(),
142                forward_args: &forward_args,
143                no_env_forward,
144            };
145            ai_tools::resume_worktree(branch.as_deref(), &opts)
146        })(),
147
148        Some(Commands::Spawn {
149            target,
150            term,
151            prompt,
152            prompt_file,
153            no_env_forward,
154            forward_args,
155        }) => (|| -> Result<()> {
156            // See Resume arm: clap absorbs `--` into the optional positional.
157            let (target, forward_args) = lift_dash_target(target, forward_args);
158            // Same prompt/forward conflict as `gw new`.
159            if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
160                return Err(CwError::Other(
161                    "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
162                     pick one or the other"
163                        .to_string(),
164                ));
165            }
166            // Same `-- --prompt-file` leak as `gw new`.
167            reject_gw_flags_in_forward(&forward_args)?;
168            let resolved_prompt = resolve_prompt(
169                prompt,
170                prompt_file.as_deref(),
171                || std::io::stdin().is_terminal(),
172                || {
173                    let mut buf = String::new();
174                    std::io::stdin().read_to_string(&mut buf)?;
175                    Ok(buf)
176                },
177            )?;
178            let cwd = std::env::current_dir()?;
179            let target_path = match target {
180                Some(t) => {
181                    let main_repo = crate::git::get_main_repo_root(Some(&cwd))?;
182                    helpers::resolve_target_strict(&main_repo, &t)?.path
183                }
184                None => crate::git::get_repo_root(Some(&cwd))?,
185            };
186            let opts = LaunchOptions {
187                term_override: term.as_deref(),
188                forward_args: &forward_args,
189                no_env_forward,
190            };
191            ai_tools::spawn_in_worktree(&target_path, resolved_prompt.as_deref(), &opts)
192        })(),
193
194        Some(Commands::Rm {
195            targets,
196            interactive,
197            dry_run,
198            keep_branch,
199            delete_remote,
200            force,
201            no_force,
202        }) => {
203            let flags = crate::operations::worktree::RmFlags {
204                keep_branch,
205                delete_remote,
206                git_force: !no_force,
207                allow_busy: force,
208            };
209            match crate::operations::rm_batch::rm_worktrees(targets, interactive, dry_run, flags) {
210                Ok(0) => Ok(()),
211                Ok(code) => Err(crate::error::CwError::ExitCode(code)),
212                Err(e) => Err(e),
213            }
214        }
215
216        Some(Commands::Doctor {
217            session_start,
218            quiet,
219        }) => diagnostics::doctor(session_start, quiet),
220        Some(Commands::Run {
221            only,
222            no_main,
223            jobs,
224            continue_on_error,
225            cmd,
226        }) => (|| -> Result<()> {
227            let cwd = std::env::current_dir()?;
228            let code = run::run_in_scope(
229                &cwd,
230                &cmd,
231                only.as_deref(),
232                no_main,
233                jobs,
234                continue_on_error,
235            )?;
236            if code != 0 {
237                return Err(crate::error::CwError::ExitCode(code));
238            }
239            Ok(())
240        })(),
241
242        Some(Commands::Exec { target, cmd }) => (|| -> Result<()> {
243            let cwd = std::env::current_dir()?;
244            let mut out = std::io::stdout().lock();
245            let code = exec::exec_in_target(&cwd, &target, &cmd, &mut out)?;
246            if code != 0 {
247                return Err(crate::error::CwError::ExitCode(code));
248            }
249            Ok(())
250        })(),
251
252        Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
253        Some(Commands::SetupClaude) => setup_claude::setup_claude(),
254
255        Some(Commands::Upgrade { yes }) => {
256            update::upgrade(yes);
257            Ok(())
258        }
259
260        Some(Commands::ShellSetup) => {
261            shell_setup();
262            Ok(())
263        }
264
265        Some(Commands::Path {
266            branch,
267            list_branches,
268            interactive,
269        }) => path_cmd::worktree_path(branch.as_deref(), list_branches, interactive),
270
271        Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
272            Some(output) => {
273                print!("{}", output);
274                Ok(())
275            }
276            None => Err(CwError::Config(format!(
277                "Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
278                shell
279            ))),
280        },
281
282        Some(Commands::UpdateCache) => {
283            update::refresh_cache();
284            Ok(())
285        }
286
287        Some(Commands::CompleteTargets) => crate::operations::complete::print_completion_targets(),
288
289        Some(Commands::SpawnAi { spec }) => {
290            // Pre-spawn failures (read/parse/chdir) exit 127 — the shell
291            // "command not found / could not start" convention. Post-spawn
292            // failures exit from inside `execute` directly, also with 127.
293            // Inner errors already carry the "spawn-ai:" prefix via their
294            // CwError::Other messages, so we print them verbatim.
295            let resolved = match spec {
296                Some(p) => p,
297                None => match spawn_spec::resolve_last_for_cwd() {
298                    Ok(p) => p,
299                    Err(e) => {
300                        eprintln!("{}", e);
301                        std::process::exit(127);
302                    }
303                },
304            };
305            if let Err(e) = spawn_spec::execute(&resolved) {
306                eprintln!("{}", e);
307                std::process::exit(127);
308            }
309            Ok(())
310        }
311
312        None => Ok(()),
313    };
314
315    if let Err(e) = result {
316        // ExitCode carries a specific exit status from callers that have
317        // already produced their own user-facing output (e.g. the multi-target
318        // delete orchestrator). Exit silently with that code instead of the
319        // generic "Error: …" print.
320        if let CwError::ExitCode(code) = e {
321            std::process::exit(code);
322        }
323        cwconsole::print_error(&format!("Error: {}", e));
324        std::process::exit(1);
325    }
326}
327
328fn generate_completions(shell_name: &str) {
329    use clap::CommandFactory;
330    use clap_complete::{generate, Shell};
331
332    let shell = match shell_name.to_lowercase().as_str() {
333        "bash" => Shell::Bash,
334        "zsh" => Shell::Zsh,
335        "fish" => Shell::Fish,
336        "powershell" | "pwsh" => Shell::PowerShell,
337        "elvish" => Shell::Elvish,
338        _ => {
339            eprintln!(
340                "Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
341                shell_name
342            );
343            std::process::exit(1);
344        }
345    };
346
347    let mut cmd = Cli::command();
348    generate(shell, &mut cmd, "gw", &mut std::io::stdout());
349}
350
351fn shell_setup() {
352    let shell_env = std::env::var("SHELL").unwrap_or_default();
353    let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
354
355    let home = constants::home_dir_or_fallback();
356    let (shell_name, profile_path) = if shell_env.contains("zsh") {
357        ("zsh", Some(home.join(".zshrc")))
358    } else if shell_env.contains("bash") {
359        ("bash", Some(home.join(".bashrc")))
360    } else if shell_env.contains("fish") {
361        (
362            "fish",
363            Some(home.join(".config").join("fish").join("config.fish")),
364        )
365    } else if is_powershell {
366        ("powershell", None::<std::path::PathBuf>)
367    } else {
368        println!("Could not detect your shell automatically.\n");
369        println!("Please manually add the gw-cd function to your shell:\n");
370        println!("  bash/zsh:    source <(gw _shell-function bash)");
371        println!("  fish:        gw _shell-function fish | source");
372        println!("  PowerShell:  gw _shell-function powershell | Out-String | Invoke-Expression");
373        return;
374    };
375
376    println!("Detected shell: {}\n", shell_name);
377
378    if shell_name == "powershell" {
379        println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
380        println!("  gw _shell-function powershell | Out-String | Invoke-Expression\n");
381        println!("To find your PowerShell profile location, run: $PROFILE");
382        println!(
383            "\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
384        );
385        return;
386    }
387
388    let shell_function_line = match shell_name {
389        "fish" => "gw _shell-function fish | source".to_string(),
390        _ => format!("source <(gw _shell-function {})", shell_name),
391    };
392
393    if let Some(ref path) = profile_path {
394        if path.exists() {
395            if let Ok(content) = std::fs::read_to_string(path) {
396                if content.contains("gw _shell-function") || content.contains("gw-cd") {
397                    println!(
398                        "{}",
399                        console::style("Shell integration is already installed.").green()
400                    );
401                    println!("  Found in: {}\n", path.display());
402
403                    refresh_shell_cache(shell_name);
404
405                    println!("\nRestart your shell or run: source {}", path.display());
406                    return;
407                }
408            }
409        }
410    }
411
412    println!("Setup shell integration?\n");
413    println!(
414        "This will add the following to {}:",
415        profile_path
416            .as_ref()
417            .map(|p| p.display().to_string())
418            .unwrap_or("your profile".to_string())
419    );
420
421    println!(
422        "\n  # git-worktree-manager shell integration{}",
423        if matches!(shell_name, "zsh" | "bash") {
424            " (gw-cd + tab completion)"
425        } else {
426            ""
427        }
428    );
429    println!("  {}\n", shell_function_line);
430
431    print!("Add to your shell profile? [Y/n]: ");
432    use std::io::Write;
433    let _ = std::io::stdout().flush();
434
435    let mut input = String::new();
436    let _ = std::io::stdin().read_line(&mut input);
437    let input = input.trim().to_lowercase();
438
439    if !input.is_empty() && input != "y" && input != "yes" {
440        println!("\nSetup cancelled.");
441        return;
442    }
443
444    let Some(ref path) = profile_path else {
445        return;
446    };
447
448    if let Some(parent) = path.parent() {
449        let _ = std::fs::create_dir_all(parent);
450    }
451
452    let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
453        " (gw-cd + tab completion)"
454    } else {
455        ""
456    };
457    let append = format!(
458        "\n# git-worktree-manager shell integration{}\n{}\n",
459        comment_suffix, shell_function_line
460    );
461
462    match std::fs::OpenOptions::new()
463        .create(true)
464        .append(true)
465        .open(path)
466    {
467        Ok(mut f) => {
468            let _ = f.write_all(append.as_bytes());
469
470            if let Ok(mut cfg) = config::load_config() {
471                cfg.shell_completion.installed = true;
472                cfg.shell_completion.prompted = true;
473                let _ = config::save_config(&cfg);
474            }
475
476            println!("\n* Successfully added to {}", path.display());
477
478            refresh_shell_cache(shell_name);
479
480            println!("\nNext steps:");
481            println!("  1. Restart your shell or run: source {}", path.display());
482            println!("  2. Try directory navigation: gw-cd <branch-name>");
483            println!("  3. Try tab completion: gw <TAB> or gw new <TAB>");
484        }
485        Err(e) => {
486            println!("\nError: Failed to update {}: {}", path.display(), e);
487            println!("\nTo install manually, add the lines shown above to your profile");
488        }
489    }
490}
491
492/// Refresh cached shell function files to pick up new features.
493fn refresh_shell_cache(shell_name: &str) {
494    let home = constants::home_dir_or_fallback();
495
496    let cache_paths = [
497        home.join(".cache").join("gw-shell-function.zsh"),
498        home.join(".cache").join("gw-shell-function.bash"),
499        home.join(".cache").join("gw-shell-function.fish"),
500    ];
501
502    let mut refreshed = false;
503    for cache_path in &cache_paths {
504        if !cache_path.exists() {
505            continue;
506        }
507        let cache_shell = cache_path
508            .extension()
509            .and_then(|e| e.to_str())
510            .unwrap_or("");
511        if let Some(content) = shell_functions::generate(cache_shell) {
512            if std::fs::write(cache_path, content).is_ok() {
513                println!(
514                    "  {} {}",
515                    console::style("Refreshed cache:").dim(),
516                    cache_path.display()
517                );
518                refreshed = true;
519            }
520        }
521    }
522
523    if refreshed {
524        return;
525    }
526
527    let cache_path = home
528        .join(".cache")
529        .join(format!("gw-shell-function.{}", shell_name));
530    if let Some(content) = shell_functions::generate(shell_name) {
531        let _ = std::fs::create_dir_all(cache_path.parent().unwrap_or(&home));
532        if std::fs::write(&cache_path, &content).is_ok() {
533            println!(
534                "  {} {}",
535                console::style("Created cache:").dim(),
536                cache_path.display()
537            );
538        }
539    }
540}
541
542/// Reject gw's own `--prompt` / `--prompt-file` flags when they leak into
543/// the trailing forward-args slot (typically because the caller wrote
544/// `gw new <name> -- --prompt-file <path>`, which clap dutifully forwards
545/// to the AI tool — `claude` then rejects the unknown option). We surface
546/// the error at dispatch time instead of letting it fail inside the spawned
547/// terminal where the user can't see it.
548///
549/// We scan every element rather than stopping at the first non-flag: these
550/// tokens are gw-owned and have no meaning to any AI tool we support, so
551/// catching them no matter where they sit in the forward list is strictly
552/// safer than letting the spawned process fail. If a real AI tool ever
553/// gains an identically-named flag, this is a one-line carve-out.
554fn reject_gw_flags_in_forward(forward_args: &[String]) -> Result<()> {
555    const GW_PROMPT_FLAGS: &[&str] = &["--prompt", "--prompt-file"];
556    for arg in forward_args {
557        if !arg.starts_with("--") {
558            continue;
559        }
560        let head = arg.split_once('=').map(|(h, _)| h).unwrap_or(arg.as_str());
561        if GW_PROMPT_FLAGS.contains(&head) {
562            return Err(CwError::Other(format!(
563                "{head} is a gw option, not an AI tool option — drop the `--` \
564                 separator so gw consumes the flag itself (write `{head} \
565                 <value>` without `--` in front of it)"
566            )));
567        }
568    }
569    Ok(())
570}
571
572/// `gw spawn -- --model opus` and `gw resume -- --model opus` parse, under
573/// clap's `trailing_var_arg=true` + `Option<String>` positional combo, as
574/// `target=Some("--model")`, `forward_args=["opus"]` — clap silently absorbs
575/// the `--` into the optional positional. A real worktree name can never
576/// start with `-` (git rejects it), so a hyphen-led "target" is unambiguously
577/// a misparsed forward arg. Lift it (and any captured forward args) back into
578/// forward_args, with `target` cleared so the dispatcher falls through to
579/// "current worktree".
580fn lift_dash_target(
581    target: Option<String>,
582    forward_args: Vec<String>,
583) -> (Option<String>, Vec<String>) {
584    match target {
585        Some(t) if t.starts_with('-') => {
586            let mut lifted = Vec::with_capacity(forward_args.len() + 1);
587            lifted.push(t);
588            lifted.extend(forward_args);
589            (None, lifted)
590        }
591        other => (other, forward_args),
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::{lift_dash_target, reject_gw_flags_in_forward};
598
599    fn forward(args: &[&str]) -> Vec<String> {
600        args.iter().map(|s| (*s).to_string()).collect()
601    }
602
603    #[test]
604    fn reject_forward_args_passes_when_empty() {
605        reject_gw_flags_in_forward(&[]).unwrap();
606    }
607
608    #[test]
609    fn reject_forward_args_passes_ai_tool_flags() {
610        // `--model opus`, `--resume`, `--continue`, `--print` — all legitimate
611        // claude/codex flags; must flow through untouched.
612        reject_gw_flags_in_forward(&forward(&["--model", "opus", "--resume"])).unwrap();
613        reject_gw_flags_in_forward(&forward(&["--print"])).unwrap();
614    }
615
616    #[test]
617    fn reject_forward_args_rejects_prompt_file_leading() {
618        let err = reject_gw_flags_in_forward(&forward(&["--prompt-file", "/tmp/p.txt"]))
619            .expect_err("must reject");
620        let msg = format!("{err}");
621        assert!(msg.contains("--prompt-file"), "unexpected msg: {msg}");
622        assert!(msg.contains("gw option"), "unexpected msg: {msg}");
623    }
624
625    #[test]
626    fn reject_forward_args_rejects_prompt_leading() {
627        let err =
628            reject_gw_flags_in_forward(&forward(&["--prompt", "hi"])).expect_err("must reject");
629        assert!(format!("{err}").contains("--prompt"));
630    }
631
632    #[test]
633    fn reject_forward_args_rejects_equals_form() {
634        let err = reject_gw_flags_in_forward(&forward(&["--prompt-file=/tmp/p.txt"]))
635            .expect_err("must reject");
636        assert!(format!("{err}").contains("--prompt-file"));
637        let err =
638            reject_gw_flags_in_forward(&forward(&["--prompt=hello"])).expect_err("must reject");
639        assert!(format!("{err}").contains("--prompt"));
640    }
641
642    #[test]
643    fn reject_forward_args_rejects_prompt_after_positional() {
644        // Catch even when the leaked gw flag follows a positional or another
645        // flag's value — claude/codex/gemini have no `--prompt-file` of their
646        // own, so any occurrence is a misroute regardless of position.
647        let err = reject_gw_flags_in_forward(&forward(&["some-prompt", "--prompt-file", "/tmp/p"]))
648            .expect_err("must reject");
649        assert!(format!("{err}").contains("--prompt-file"));
650    }
651
652    #[test]
653    fn reject_forward_args_rejects_prompt_after_other_flags() {
654        // Mixed: a legitimate AI-tool flag and its value, followed by a
655        // leaked gw flag.
656        let err =
657            reject_gw_flags_in_forward(&forward(&["--model", "opus", "--prompt-file", "/tmp/p"]))
658                .expect_err("must reject");
659        assert!(format!("{err}").contains("--prompt-file"));
660    }
661
662    #[test]
663    fn reject_forward_args_ignores_short_dash_and_bare_dash() {
664        // `-` (read stdin convention) and `-x`-style short flags must not
665        // trip the guard — we only match the two specific long flags.
666        reject_gw_flags_in_forward(&forward(&["-"])).unwrap();
667        reject_gw_flags_in_forward(&forward(&["-p", "hello"])).unwrap();
668    }
669
670    #[test]
671    fn lift_dash_target_lifts_hyphen_target() {
672        let (target, fwd) = lift_dash_target(
673            Some("--model".to_string()),
674            vec!["opus".to_string(), "--resume".to_string()],
675        );
676        assert_eq!(target, None);
677        assert_eq!(fwd, vec!["--model", "opus", "--resume"]);
678    }
679
680    #[test]
681    fn lift_dash_target_passes_through_normal_target() {
682        let (target, fwd) = lift_dash_target(
683            Some("feat-x".to_string()),
684            vec!["--model".to_string(), "opus".to_string()],
685        );
686        assert_eq!(target.as_deref(), Some("feat-x"));
687        assert_eq!(fwd, vec!["--model", "opus"]);
688    }
689
690    #[test]
691    fn lift_dash_target_handles_none_target() {
692        let (target, fwd) = lift_dash_target(None, vec![]);
693        assert_eq!(target, None);
694        assert!(fwd.is_empty());
695    }
696
697    #[test]
698    fn lift_dash_target_lifts_with_no_forward_args() {
699        // `gw spawn -- --model` (no value) — pathological but still well-defined.
700        let (target, fwd) = lift_dash_target(Some("--model".to_string()), vec![]);
701        assert_eq!(target, None);
702        assert_eq!(fwd, vec!["--model"]);
703    }
704}