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::{BackupAction, Cli, Commands, ConfigAction, HookAction, StashAction};
11use crate::config;
12use crate::console as cwconsole;
13use crate::constants;
14use crate::cwshare_setup;
15use crate::error::{CwError, Result};
16use crate::hooks;
17use crate::operations::{
18    ai_tools, backup, clean, config_ops, diagnostics, display, git_ops, global_ops, guard, helpers,
19    path_cmd, setup_claude, shell, spawn_spec, stash, worktree,
20};
21use crate::resolve_prompt;
22use crate::shell_functions;
23use crate::tui;
24use crate::update;
25use std::io::Read;
26
27pub fn run() {
28    tui::install_panic_hook();
29    let cli = Cli::parse();
30
31    if let Some(ref shell_name) = cli.generate_completion {
32        generate_completions(shell_name);
33        return;
34    }
35
36    // Skip startup checks for internal commands (shell-completion helpers,
37    // cache refresh) — they are invoked by the shell on every keystroke, so
38    // paying for update-check / prompts would compound latency and risk
39    // recursive re-entry into the update flow.
40    let is_internal = matches!(
41        &cli.command,
42        Some(
43            Commands::UpdateCache
44                | Commands::ConfigKeys
45                | Commands::TermValues
46                | Commands::PresetNames
47                | Commands::HookEvents
48                | Commands::Path { .. }
49                | Commands::ShellFunction { .. }
50                | Commands::SpawnAi { .. }
51                | Commands::Guard { .. }
52        )
53    );
54
55    if !is_internal {
56        crate::operations::spawn_spec::sweep_stale();
57        update::check_for_update_if_needed();
58    }
59
60    if !is_internal {
61        config::prompt_shell_completion_setup();
62    }
63
64    helpers::set_global_mode(cli.global);
65
66    let result = match cli.command {
67        Some(Commands::List { cache }) => {
68            let no_cache = cache.no_cache;
69            if cli.global {
70                global_ops::global_list_worktrees(no_cache)
71            } else {
72                display::list_worktrees(no_cache)
73            }
74        }
75        Some(Commands::Status { cache }) => display::show_status(cache.no_cache),
76        Some(Commands::Tree { cache }) => display::show_tree(cache.no_cache),
77        Some(Commands::Stats { cache }) => display::show_stats(cache.no_cache),
78        Some(Commands::Diff {
79            branch1,
80            branch2,
81            summary,
82            files,
83        }) => display::diff_worktrees(&branch1, &branch2, summary, files),
84
85        Some(Commands::Config { action }) => match action {
86            ConfigAction::Show => config::show_config().map(|output| println!("{}", output)),
87            ConfigAction::List => config::list_config(),
88            ConfigAction::Get { key } => config::get_config_value(&key),
89            ConfigAction::Set { key, value } => config::set_config_value(&key, &value),
90            ConfigAction::UsePreset { name } => config::use_preset(&name),
91            ConfigAction::ListPresets => {
92                println!("{}", config::list_presets());
93                Ok(())
94            }
95            ConfigAction::Reset => config::reset_config(),
96        },
97
98        Some(Commands::New {
99            name,
100            path,
101            base,
102            no_term,
103            term,
104            bg: _,
105            prompt,
106            prompt_file,
107            prompt_stdin,
108        }) => (|| -> Result<()> {
109            // Resolve the prompt first so a missing file or unreadable stdin
110            // fails before any interactive side effects (worktree creation,
111            // AI-tool launch) leave the tree in a half-configured state.
112            let resolved = 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
118            cwshare_setup::prompt_cwshare_setup();
119
120            worktree::create_worktree(
121                &name,
122                base.as_deref(),
123                path.as_deref(),
124                term.as_deref(),
125                no_term,
126                resolved.as_deref(),
127            )?;
128            Ok(())
129        })(),
130
131        Some(Commands::Pr {
132            branch,
133            title,
134            body,
135            draft,
136            no_push,
137            worktree: is_worktree,
138            by_branch,
139        }) => {
140            let lookup_mode = resolve_lookup_mode(is_worktree, by_branch);
141            git_ops::create_pr_worktree(
142                branch.as_deref(),
143                !no_push,
144                title.as_deref(),
145                body.as_deref(),
146                draft,
147                lookup_mode,
148            )
149        }
150
151        Some(Commands::Merge {
152            branch,
153            interactive,
154            dry_run,
155            push,
156            ai_merge,
157            worktree: is_worktree,
158        }) => {
159            let lookup_mode = if is_worktree { Some("worktree") } else { None };
160            git_ops::merge_worktree(
161                branch.as_deref(),
162                push,
163                interactive,
164                dry_run,
165                ai_merge,
166                lookup_mode,
167            )
168        }
169
170        Some(Commands::Resume {
171            branch,
172            term,
173            bg: _,
174            worktree: is_worktree,
175            by_branch,
176        }) => {
177            let lookup_mode = resolve_lookup_mode(is_worktree, by_branch);
178            ai_tools::resume_worktree(branch.as_deref(), term.as_deref(), lookup_mode)
179        }
180
181        Some(Commands::Shell { worktree, args }) => {
182            let cmd = if args.is_empty() { None } else { Some(args) };
183            shell::shell_worktree(worktree.as_deref(), cmd)
184        }
185
186        Some(Commands::Delete {
187            targets,
188            interactive,
189            dry_run,
190            keep_branch,
191            delete_remote,
192            force,
193            no_force,
194            worktree: is_worktree,
195            branch: is_branch,
196        }) => {
197            let lookup_mode = resolve_lookup_mode(is_worktree, is_branch);
198            let flags = crate::operations::worktree::DeleteFlags {
199                keep_branch,
200                delete_remote,
201                git_force: !no_force,
202                allow_busy: force,
203            };
204            match crate::operations::delete_batch::delete_worktrees(
205                targets,
206                interactive,
207                dry_run,
208                flags,
209                lookup_mode,
210            ) {
211                Ok(0) => Ok(()),
212                Ok(code) => Err(crate::error::CwError::ExitCode(code)),
213                Err(e) => Err(e),
214            }
215        }
216
217        Some(Commands::Clean {
218            cache,
219            merged,
220            older_than,
221            interactive,
222            dry_run,
223            force,
224        }) => clean::clean_worktrees(
225            cache.no_cache,
226            merged,
227            older_than,
228            interactive,
229            dry_run,
230            force,
231        ),
232
233        Some(Commands::Sync {
234            branch,
235            all,
236            fetch_only,
237            ai_merge,
238            worktree: is_worktree,
239            by_branch,
240        }) => {
241            let lookup_mode = resolve_lookup_mode(is_worktree, by_branch);
242            worktree::sync_worktree(branch.as_deref(), all, fetch_only, ai_merge, lookup_mode)
243        }
244
245        Some(Commands::ChangeBase {
246            new_base,
247            branch,
248            dry_run,
249            interactive,
250            worktree: is_worktree,
251            by_branch,
252        }) => {
253            let lookup_mode = resolve_lookup_mode(is_worktree, by_branch);
254            config_ops::change_base_branch(
255                &new_base,
256                branch.as_deref(),
257                dry_run,
258                interactive,
259                lookup_mode,
260            )
261        }
262
263        Some(Commands::Backup { action }) => match action {
264            BackupAction::Create {
265                branch,
266                all,
267                output: _,
268            } => backup::backup_worktree(branch.as_deref(), all),
269            BackupAction::List { branch, all } => backup::list_backups(branch.as_deref(), all),
270            BackupAction::Restore { branch, path, id } => {
271                backup::restore_worktree(&branch, path.as_deref(), id.as_deref())
272            }
273        },
274
275        Some(Commands::Stash { action }) => match action {
276            StashAction::Save { message } => stash::stash_save(message.as_deref()),
277            StashAction::List => stash::stash_list(),
278            StashAction::Apply {
279                target_branch,
280                stash: stash_ref,
281            } => stash::stash_apply(&target_branch, &stash_ref),
282        },
283
284        Some(Commands::Hook { action }) => match action {
285            HookAction::Add {
286                event,
287                command,
288                id,
289                description,
290            } => hooks::add_hook(&event, &command, id.as_deref(), description.as_deref()).map(
291                |hook_id| {
292                    println!("* Added hook '{}' for {}", hook_id, event);
293                },
294            ),
295            HookAction::Remove { event, hook_id } => hooks::remove_hook(&event, &hook_id),
296            HookAction::List { event } => {
297                list_hooks(event.as_deref());
298                Ok(())
299            }
300            HookAction::Enable { event, hook_id } => {
301                hooks::set_hook_enabled(&event, &hook_id, true)
302            }
303            HookAction::Disable { event, hook_id } => {
304                hooks::set_hook_enabled(&event, &hook_id, false)
305            }
306            HookAction::Run { event, dry_run } => run_hooks_manual(&event, dry_run),
307        },
308
309        Some(Commands::Export { output }) => config_ops::export_config(output.as_deref()),
310        Some(Commands::Import { import_file, apply }) => {
311            config_ops::import_config(&import_file, apply)
312        }
313
314        Some(Commands::Scan { dir }) => global_ops::global_scan(dir.as_deref()),
315        Some(Commands::Prune) => global_ops::global_prune(),
316        Some(Commands::Doctor {
317            session_start,
318            quiet,
319        }) => diagnostics::doctor(session_start, quiet),
320        Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
321        Some(Commands::SetupClaude) => setup_claude::setup_claude(),
322
323        Some(Commands::Upgrade { yes }) => {
324            update::upgrade(yes);
325            Ok(())
326        }
327
328        Some(Commands::ShellSetup) => {
329            shell_setup();
330            Ok(())
331        }
332
333        Some(Commands::Path {
334            branch,
335            list_branches,
336            interactive,
337        }) => path_cmd::worktree_path(branch.as_deref(), cli.global, list_branches, interactive),
338
339        Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
340            Some(output) => {
341                print!("{}", output);
342                Ok(())
343            }
344            None => Err(CwError::Config(format!(
345                "Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
346                shell
347            ))),
348        },
349
350        Some(Commands::UpdateCache) => {
351            update::refresh_cache();
352            Ok(())
353        }
354
355        Some(Commands::ConfigKeys) => {
356            for (key, _desc) in config::CONFIG_KEYS {
357                println!("{}", key);
358            }
359            Ok(())
360        }
361
362        Some(Commands::TermValues) => {
363            for v in constants::all_term_values() {
364                println!("{}", v);
365            }
366            Ok(())
367        }
368
369        Some(Commands::PresetNames) => {
370            for name in constants::PRESET_NAMES {
371                println!("{}", name);
372            }
373            Ok(())
374        }
375
376        Some(Commands::HookEvents) => {
377            for evt in constants::HOOK_EVENTS {
378                println!("{}", evt);
379            }
380            Ok(())
381        }
382
383        Some(Commands::SpawnAi { spec }) => {
384            // Pre-spawn failures (read/parse/chdir) exit 127 — the shell
385            // "command not found / could not start" convention. Post-spawn
386            // failures exit from inside `execute` directly, also with 127.
387            // Inner errors already carry the "spawn-ai:" prefix via their
388            // CwError::Other messages, so we print them verbatim.
389            if let Err(e) = spawn_spec::execute(&spec) {
390                eprintln!("{}", e);
391                std::process::exit(127);
392            }
393            Ok(())
394        }
395
396        None => Ok(()),
397    };
398
399    if let Err(e) = result {
400        // ExitCode carries a specific exit status from callers that have
401        // already produced their own user-facing output (e.g. the multi-target
402        // delete orchestrator). Exit silently with that code instead of the
403        // generic "Error: …" print.
404        if let CwError::ExitCode(code) = e {
405            std::process::exit(code);
406        }
407        cwconsole::print_error(&format!("Error: {}", e));
408        std::process::exit(1);
409    }
410}
411
412fn resolve_lookup_mode(is_worktree: bool, is_branch: bool) -> Option<&'static str> {
413    if is_worktree {
414        Some("worktree")
415    } else if is_branch {
416        Some("branch")
417    } else {
418        None
419    }
420}
421
422fn generate_completions(shell_name: &str) {
423    use clap::CommandFactory;
424    use clap_complete::{generate, Shell};
425
426    let shell = match shell_name.to_lowercase().as_str() {
427        "bash" => Shell::Bash,
428        "zsh" => Shell::Zsh,
429        "fish" => Shell::Fish,
430        "powershell" | "pwsh" => Shell::PowerShell,
431        "elvish" => Shell::Elvish,
432        _ => {
433            eprintln!(
434                "Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
435                shell_name
436            );
437            std::process::exit(1);
438        }
439    };
440
441    let mut cmd = Cli::command();
442    generate(shell, &mut cmd, "gw", &mut std::io::stdout());
443}
444
445fn list_hooks(event: Option<&str>) {
446    let events: Vec<&str> = if let Some(e) = event {
447        vec![e]
448    } else {
449        hooks::HOOK_EVENTS.to_vec()
450    };
451
452    let mut has_any = false;
453    for evt in &events {
454        let hook_list = hooks::get_hooks(evt, None);
455        if hook_list.is_empty() && event.is_none() {
456            continue;
457        }
458        if !hook_list.is_empty() {
459            has_any = true;
460            println!("\n{}:", evt);
461            for h in &hook_list {
462                let status = if h.enabled { "enabled" } else { "disabled" };
463                let desc = if h.description.is_empty() {
464                    String::new()
465                } else {
466                    format!(" - {}", h.description)
467                };
468                println!("  {} [{}]: {}{}", h.id, status, h.command, desc);
469            }
470        } else {
471            println!("\n{}:", evt);
472            println!("  (no hooks)");
473        }
474    }
475
476    if event.is_none() && !has_any {
477        println!("No hooks configured. Use 'gw hook add' to add one.");
478    }
479}
480
481fn run_hooks_manual(event: &str, dry_run: bool) -> Result<()> {
482    let hook_list = hooks::get_hooks(event, None);
483    if hook_list.is_empty() {
484        println!("No hooks configured for {}", event);
485        return Ok(());
486    }
487
488    let enabled: Vec<_> = hook_list.iter().filter(|h| h.enabled).collect();
489    if enabled.is_empty() {
490        println!("All hooks for {} are disabled", event);
491        return Ok(());
492    }
493
494    if dry_run {
495        println!("Would run {} hook(s) for {}:", enabled.len(), event);
496        for h in &hook_list {
497            let status = if h.enabled {
498                "enabled"
499            } else {
500                "disabled (skipped)"
501            };
502            let desc = if h.description.is_empty() {
503                String::new()
504            } else {
505                format!(" - {}", h.description)
506            };
507            println!("  {} [{}]: {}{}", h.id, status, h.command, desc);
508        }
509        return Ok(());
510    }
511
512    let cwd = std::env::current_dir().unwrap_or_default();
513    let context = helpers::build_hook_context("", "", &cwd, &cwd, event, "manual");
514
515    hooks::run_hooks(event, &context, Some(&cwd), None)?;
516    Ok(())
517}
518
519fn shell_setup() {
520    let shell_env = std::env::var("SHELL").unwrap_or_default();
521    let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
522
523    let home = constants::home_dir_or_fallback();
524    let (shell_name, profile_path) = if shell_env.contains("zsh") {
525        ("zsh", Some(home.join(".zshrc")))
526    } else if shell_env.contains("bash") {
527        ("bash", Some(home.join(".bashrc")))
528    } else if shell_env.contains("fish") {
529        (
530            "fish",
531            Some(home.join(".config").join("fish").join("config.fish")),
532        )
533    } else if is_powershell {
534        ("powershell", None::<std::path::PathBuf>)
535    } else {
536        println!("Could not detect your shell automatically.\n");
537        println!("Please manually add the gw-cd function to your shell:\n");
538        println!("  bash/zsh:    source <(gw _shell-function bash)");
539        println!("  fish:        gw _shell-function fish | source");
540        println!("  PowerShell:  gw _shell-function powershell | Out-String | Invoke-Expression");
541        return;
542    };
543
544    println!("Detected shell: {}\n", shell_name);
545
546    if shell_name == "powershell" {
547        println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
548        println!("  gw _shell-function powershell | Out-String | Invoke-Expression\n");
549        println!("To find your PowerShell profile location, run: $PROFILE");
550        println!(
551            "\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
552        );
553        return;
554    }
555
556    let shell_function_line = match shell_name {
557        "fish" => "gw _shell-function fish | source".to_string(),
558        _ => format!("source <(gw _shell-function {})", shell_name),
559    };
560
561    if let Some(ref path) = profile_path {
562        if path.exists() {
563            if let Ok(content) = std::fs::read_to_string(path) {
564                if content.contains("gw _shell-function") || content.contains("gw-cd") {
565                    println!(
566                        "{}",
567                        console::style("Shell integration is already installed.").green()
568                    );
569                    println!("  Found in: {}\n", path.display());
570
571                    refresh_shell_cache(shell_name);
572
573                    println!("\nRestart your shell or run: source {}", path.display());
574                    return;
575                }
576            }
577        }
578    }
579
580    println!("Setup shell integration?\n");
581    println!(
582        "This will add the following to {}:",
583        profile_path
584            .as_ref()
585            .map(|p| p.display().to_string())
586            .unwrap_or("your profile".to_string())
587    );
588
589    println!(
590        "\n  # git-worktree-manager shell integration{}",
591        if matches!(shell_name, "zsh" | "bash") {
592            " (gw-cd + tab completion)"
593        } else {
594            ""
595        }
596    );
597    println!("  {}\n", shell_function_line);
598
599    print!("Add to your shell profile? [Y/n]: ");
600    use std::io::Write;
601    let _ = std::io::stdout().flush();
602
603    let mut input = String::new();
604    let _ = std::io::stdin().read_line(&mut input);
605    let input = input.trim().to_lowercase();
606
607    if !input.is_empty() && input != "y" && input != "yes" {
608        println!("\nSetup cancelled.");
609        return;
610    }
611
612    let Some(ref path) = profile_path else {
613        return;
614    };
615
616    if let Some(parent) = path.parent() {
617        let _ = std::fs::create_dir_all(parent);
618    }
619
620    let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
621        " (gw-cd + tab completion)"
622    } else {
623        ""
624    };
625    let append = format!(
626        "\n# git-worktree-manager shell integration{}\n{}\n",
627        comment_suffix, shell_function_line
628    );
629
630    match std::fs::OpenOptions::new()
631        .create(true)
632        .append(true)
633        .open(path)
634    {
635        Ok(mut f) => {
636            let _ = f.write_all(append.as_bytes());
637
638            if let Ok(mut cfg) = config::load_config() {
639                cfg.shell_completion.installed = true;
640                cfg.shell_completion.prompted = true;
641                let _ = config::save_config(&cfg);
642            }
643
644            println!("\n* Successfully added to {}", path.display());
645
646            refresh_shell_cache(shell_name);
647
648            println!("\nNext steps:");
649            println!("  1. Restart your shell or run: source {}", path.display());
650            println!("  2. Try directory navigation: gw-cd <branch-name>");
651            println!("  3. Try tab completion: gw <TAB> or gw new <TAB>");
652        }
653        Err(e) => {
654            println!("\nError: Failed to update {}: {}", path.display(), e);
655            println!("\nTo install manually, add the lines shown above to your profile");
656        }
657    }
658}
659
660/// Refresh cached shell function files to pick up new features.
661fn refresh_shell_cache(shell_name: &str) {
662    let home = constants::home_dir_or_fallback();
663
664    let cache_paths = [
665        home.join(".cache").join("gw-shell-function.zsh"),
666        home.join(".cache").join("gw-shell-function.bash"),
667        home.join(".cache").join("gw-shell-function.fish"),
668    ];
669
670    let mut refreshed = false;
671    for cache_path in &cache_paths {
672        if !cache_path.exists() {
673            continue;
674        }
675        let cache_shell = cache_path
676            .extension()
677            .and_then(|e| e.to_str())
678            .unwrap_or("");
679        if let Some(content) = shell_functions::generate(cache_shell) {
680            if std::fs::write(cache_path, content).is_ok() {
681                println!(
682                    "  {} {}",
683                    console::style("Refreshed cache:").dim(),
684                    cache_path.display()
685                );
686                refreshed = true;
687            }
688        }
689    }
690
691    if refreshed {
692        return;
693    }
694
695    let cache_path = home
696        .join(".cache")
697        .join(format!("gw-shell-function.{}", shell_name));
698    if let Some(content) = shell_functions::generate(shell_name) {
699        let _ = std::fs::create_dir_all(cache_path.parent().unwrap_or(&home));
700        if std::fs::write(&cache_path, &content).is_ok() {
701            println!(
702                "  {} {}",
703                console::style("Created cache:").dim(),
704                cache_path.display()
705            );
706        }
707    }
708}