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