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::{
16    ai_tools, diagnostics, display, exec, guard, helpers, path_cmd, run, setup_claude, spawn_spec,
17    worktree,
18};
19use crate::resolve_prompt;
20use crate::shell_functions;
21use crate::tui;
22use crate::update;
23use std::io::Read;
24
25pub fn run() {
26    tui::install_panic_hook();
27    let cli = Cli::parse();
28
29    if let Some(ref shell_name) = cli.generate_completion {
30        generate_completions(shell_name);
31        return;
32    }
33
34    // Skip startup checks for internal commands (shell-completion helpers,
35    // cache refresh) — they are invoked by the shell on every keystroke, so
36    // paying for update-check / prompts would compound latency and risk
37    // recursive re-entry into the update flow.
38    let is_internal = matches!(
39        &cli.command,
40        Some(
41            Commands::UpdateCache
42                | Commands::CompleteTargets
43                | Commands::Path { .. }
44                | Commands::ShellFunction { .. }
45                | Commands::SpawnAi { .. }
46                | Commands::Guard { .. }
47        )
48    );
49
50    if !is_internal {
51        crate::operations::spawn_spec::sweep_stale();
52        update::check_for_update_if_needed();
53    }
54
55    if !is_internal {
56        config::prompt_shell_completion_setup();
57    }
58
59    let result = match cli.command {
60        Some(Commands::List) => display::list_worktrees(),
61        Some(Commands::Ls) => display::list_worktrees_tsv(),
62        Some(Commands::New {
63            name,
64            path,
65            base,
66            no_term,
67            term,
68            prompt,
69            prompt_file,
70            prompt_stdin,
71        }) => (|| -> Result<()> {
72            // Resolve the prompt first so a missing file or unreadable stdin
73            // fails before any interactive side effects (worktree creation,
74            // AI-tool launch) leave the tree in a half-configured state.
75            let resolved = resolve_prompt(prompt, prompt_file.as_deref(), prompt_stdin, || {
76                let mut buf = String::new();
77                std::io::stdin().read_to_string(&mut buf)?;
78                Ok(buf)
79            })?;
80            // Pre-flight `-T <method>` so a typo (`-T does-not-exist`) errors
81            // before we create a worktree on disk. The launch path inside
82            // create_worktree swallows spawn errors with `let _ = …`, which
83            // would otherwise leave a phantom worktree on a bad alias.
84            if !no_term {
85                let _ = config::parse_term_option(term.as_deref())?;
86            }
87            cwshare_setup::prompt_cwshare_setup();
88
89            worktree::create_worktree(
90                &name,
91                base.as_deref(),
92                path.as_deref(),
93                no_term,
94                resolved.as_deref(),
95                term.as_deref(),
96            )?;
97            Ok(())
98        })(),
99
100        Some(Commands::Resume { branch, term }) => {
101            ai_tools::resume_worktree(branch.as_deref(), term.as_deref())
102        }
103
104        Some(Commands::Spawn {
105            target,
106            term,
107            prompt,
108            prompt_file,
109            prompt_stdin,
110        }) => (|| -> Result<()> {
111            let resolved_prompt =
112                resolve_prompt(prompt, prompt_file.as_deref(), prompt_stdin, || {
113                    let mut buf = String::new();
114                    std::io::stdin().read_to_string(&mut buf)?;
115                    Ok(buf)
116                })?;
117            let cwd = std::env::current_dir()?;
118            let target_path = match target {
119                Some(t) => {
120                    let main_repo = crate::git::get_main_repo_root(Some(&cwd))?;
121                    helpers::resolve_target_strict(&main_repo, &t)?.path
122                }
123                None => crate::git::get_repo_root(Some(&cwd))?,
124            };
125            ai_tools::spawn_in_worktree(&target_path, resolved_prompt.as_deref(), term.as_deref())
126        })(),
127
128        Some(Commands::Rm {
129            targets,
130            interactive,
131            dry_run,
132            keep_branch,
133            delete_remote,
134            force,
135            no_force,
136        }) => {
137            let flags = crate::operations::worktree::RmFlags {
138                keep_branch,
139                delete_remote,
140                git_force: !no_force,
141                allow_busy: force,
142            };
143            match crate::operations::rm_batch::rm_worktrees(targets, interactive, dry_run, flags) {
144                Ok(0) => Ok(()),
145                Ok(code) => Err(crate::error::CwError::ExitCode(code)),
146                Err(e) => Err(e),
147            }
148        }
149
150        Some(Commands::Doctor {
151            session_start,
152            quiet,
153        }) => diagnostics::doctor(session_start, quiet),
154        Some(Commands::Run {
155            only,
156            no_main,
157            jobs,
158            continue_on_error,
159            cmd,
160        }) => (|| -> Result<()> {
161            let cwd = std::env::current_dir()?;
162            let code = run::run_in_scope(
163                &cwd,
164                &cmd,
165                only.as_deref(),
166                no_main,
167                jobs,
168                continue_on_error,
169            )?;
170            if code != 0 {
171                return Err(crate::error::CwError::ExitCode(code));
172            }
173            Ok(())
174        })(),
175
176        Some(Commands::Exec { target, cmd }) => (|| -> Result<()> {
177            let cwd = std::env::current_dir()?;
178            let mut out = std::io::stdout().lock();
179            let code = exec::exec_in_target(&cwd, &target, &cmd, &mut out)?;
180            if code != 0 {
181                return Err(crate::error::CwError::ExitCode(code));
182            }
183            Ok(())
184        })(),
185
186        Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
187        Some(Commands::SetupClaude) => setup_claude::setup_claude(),
188
189        Some(Commands::Upgrade { yes }) => {
190            update::upgrade(yes);
191            Ok(())
192        }
193
194        Some(Commands::ShellSetup) => {
195            shell_setup();
196            Ok(())
197        }
198
199        Some(Commands::Path {
200            branch,
201            list_branches,
202            interactive,
203        }) => path_cmd::worktree_path(branch.as_deref(), list_branches, interactive),
204
205        Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
206            Some(output) => {
207                print!("{}", output);
208                Ok(())
209            }
210            None => Err(CwError::Config(format!(
211                "Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
212                shell
213            ))),
214        },
215
216        Some(Commands::UpdateCache) => {
217            update::refresh_cache();
218            Ok(())
219        }
220
221        Some(Commands::CompleteTargets) => crate::operations::complete::print_completion_targets(),
222
223        Some(Commands::SpawnAi { spec }) => {
224            // Pre-spawn failures (read/parse/chdir) exit 127 — the shell
225            // "command not found / could not start" convention. Post-spawn
226            // failures exit from inside `execute` directly, also with 127.
227            // Inner errors already carry the "spawn-ai:" prefix via their
228            // CwError::Other messages, so we print them verbatim.
229            let resolved = match spec {
230                Some(p) => p,
231                None => match spawn_spec::resolve_last_for_cwd() {
232                    Ok(p) => p,
233                    Err(e) => {
234                        eprintln!("{}", e);
235                        std::process::exit(127);
236                    }
237                },
238            };
239            if let Err(e) = spawn_spec::execute(&resolved) {
240                eprintln!("{}", e);
241                std::process::exit(127);
242            }
243            Ok(())
244        }
245
246        None => Ok(()),
247    };
248
249    if let Err(e) = result {
250        // ExitCode carries a specific exit status from callers that have
251        // already produced their own user-facing output (e.g. the multi-target
252        // delete orchestrator). Exit silently with that code instead of the
253        // generic "Error: …" print.
254        if let CwError::ExitCode(code) = e {
255            std::process::exit(code);
256        }
257        cwconsole::print_error(&format!("Error: {}", e));
258        std::process::exit(1);
259    }
260}
261
262fn generate_completions(shell_name: &str) {
263    use clap::CommandFactory;
264    use clap_complete::{generate, Shell};
265
266    let shell = match shell_name.to_lowercase().as_str() {
267        "bash" => Shell::Bash,
268        "zsh" => Shell::Zsh,
269        "fish" => Shell::Fish,
270        "powershell" | "pwsh" => Shell::PowerShell,
271        "elvish" => Shell::Elvish,
272        _ => {
273            eprintln!(
274                "Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
275                shell_name
276            );
277            std::process::exit(1);
278        }
279    };
280
281    let mut cmd = Cli::command();
282    generate(shell, &mut cmd, "gw", &mut std::io::stdout());
283}
284
285fn shell_setup() {
286    let shell_env = std::env::var("SHELL").unwrap_or_default();
287    let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
288
289    let home = constants::home_dir_or_fallback();
290    let (shell_name, profile_path) = if shell_env.contains("zsh") {
291        ("zsh", Some(home.join(".zshrc")))
292    } else if shell_env.contains("bash") {
293        ("bash", Some(home.join(".bashrc")))
294    } else if shell_env.contains("fish") {
295        (
296            "fish",
297            Some(home.join(".config").join("fish").join("config.fish")),
298        )
299    } else if is_powershell {
300        ("powershell", None::<std::path::PathBuf>)
301    } else {
302        println!("Could not detect your shell automatically.\n");
303        println!("Please manually add the gw-cd function to your shell:\n");
304        println!("  bash/zsh:    source <(gw _shell-function bash)");
305        println!("  fish:        gw _shell-function fish | source");
306        println!("  PowerShell:  gw _shell-function powershell | Out-String | Invoke-Expression");
307        return;
308    };
309
310    println!("Detected shell: {}\n", shell_name);
311
312    if shell_name == "powershell" {
313        println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
314        println!("  gw _shell-function powershell | Out-String | Invoke-Expression\n");
315        println!("To find your PowerShell profile location, run: $PROFILE");
316        println!(
317            "\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
318        );
319        return;
320    }
321
322    let shell_function_line = match shell_name {
323        "fish" => "gw _shell-function fish | source".to_string(),
324        _ => format!("source <(gw _shell-function {})", shell_name),
325    };
326
327    if let Some(ref path) = profile_path {
328        if path.exists() {
329            if let Ok(content) = std::fs::read_to_string(path) {
330                if content.contains("gw _shell-function") || content.contains("gw-cd") {
331                    println!(
332                        "{}",
333                        console::style("Shell integration is already installed.").green()
334                    );
335                    println!("  Found in: {}\n", path.display());
336
337                    refresh_shell_cache(shell_name);
338
339                    println!("\nRestart your shell or run: source {}", path.display());
340                    return;
341                }
342            }
343        }
344    }
345
346    println!("Setup shell integration?\n");
347    println!(
348        "This will add the following to {}:",
349        profile_path
350            .as_ref()
351            .map(|p| p.display().to_string())
352            .unwrap_or("your profile".to_string())
353    );
354
355    println!(
356        "\n  # git-worktree-manager shell integration{}",
357        if matches!(shell_name, "zsh" | "bash") {
358            " (gw-cd + tab completion)"
359        } else {
360            ""
361        }
362    );
363    println!("  {}\n", shell_function_line);
364
365    print!("Add to your shell profile? [Y/n]: ");
366    use std::io::Write;
367    let _ = std::io::stdout().flush();
368
369    let mut input = String::new();
370    let _ = std::io::stdin().read_line(&mut input);
371    let input = input.trim().to_lowercase();
372
373    if !input.is_empty() && input != "y" && input != "yes" {
374        println!("\nSetup cancelled.");
375        return;
376    }
377
378    let Some(ref path) = profile_path else {
379        return;
380    };
381
382    if let Some(parent) = path.parent() {
383        let _ = std::fs::create_dir_all(parent);
384    }
385
386    let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
387        " (gw-cd + tab completion)"
388    } else {
389        ""
390    };
391    let append = format!(
392        "\n# git-worktree-manager shell integration{}\n{}\n",
393        comment_suffix, shell_function_line
394    );
395
396    match std::fs::OpenOptions::new()
397        .create(true)
398        .append(true)
399        .open(path)
400    {
401        Ok(mut f) => {
402            let _ = f.write_all(append.as_bytes());
403
404            if let Ok(mut cfg) = config::load_config() {
405                cfg.shell_completion.installed = true;
406                cfg.shell_completion.prompted = true;
407                let _ = config::save_config(&cfg);
408            }
409
410            println!("\n* Successfully added to {}", path.display());
411
412            refresh_shell_cache(shell_name);
413
414            println!("\nNext steps:");
415            println!("  1. Restart your shell or run: source {}", path.display());
416            println!("  2. Try directory navigation: gw-cd <branch-name>");
417            println!("  3. Try tab completion: gw <TAB> or gw new <TAB>");
418        }
419        Err(e) => {
420            println!("\nError: Failed to update {}: {}", path.display(), e);
421            println!("\nTo install manually, add the lines shown above to your profile");
422        }
423    }
424}
425
426/// Refresh cached shell function files to pick up new features.
427fn refresh_shell_cache(shell_name: &str) {
428    let home = constants::home_dir_or_fallback();
429
430    let cache_paths = [
431        home.join(".cache").join("gw-shell-function.zsh"),
432        home.join(".cache").join("gw-shell-function.bash"),
433        home.join(".cache").join("gw-shell-function.fish"),
434    ];
435
436    let mut refreshed = false;
437    for cache_path in &cache_paths {
438        if !cache_path.exists() {
439            continue;
440        }
441        let cache_shell = cache_path
442            .extension()
443            .and_then(|e| e.to_str())
444            .unwrap_or("");
445        if let Some(content) = shell_functions::generate(cache_shell) {
446            if std::fs::write(cache_path, content).is_ok() {
447                println!(
448                    "  {} {}",
449                    console::style("Refreshed cache:").dim(),
450                    cache_path.display()
451                );
452                refreshed = true;
453            }
454        }
455    }
456
457    if refreshed {
458        return;
459    }
460
461    let cache_path = home
462        .join(".cache")
463        .join(format!("gw-shell-function.{}", shell_name));
464    if let Some(content) = shell_functions::generate(shell_name) {
465        let _ = std::fs::create_dir_all(cache_path.parent().unwrap_or(&home));
466        if std::fs::write(&cache_path, &content).is_ok() {
467            println!(
468                "  {} {}",
469                console::style("Created cache:").dim(),
470                cache_path.display()
471            );
472        }
473    }
474}