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