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