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