Skip to main content

git_worktree_manager/
entrypoint.rs

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