1use clap::Parser;
8
9use crate::cli::{Cli, Commands, ConfigAction};
10use crate::config;
11use crate::console as cwconsole;
12use crate::constants;
13use crate::cwshare_setup;
14use crate::error::{CwError, Result};
15use crate::operations::ai_tools::LaunchOptions;
16use crate::operations::{
17 ai_tools, config_ops, diagnostics, display, exec, guard, helpers, path_cmd, run, setup_claude,
18 spawn_spec, worktree,
19};
20use crate::resolve_prompt;
21use crate::shell_functions;
22use crate::tui;
23use crate::update;
24use std::io::{IsTerminal, Read};
25
26pub fn run() {
27 tui::install_panic_hook();
28 let cli = Cli::parse();
29
30 if let Some(ref shell_name) = cli.generate_completion {
31 generate_completions(shell_name);
32 return;
33 }
34
35 let is_internal = matches!(
40 &cli.command,
41 Some(
42 Commands::UpdateCache
43 | Commands::CompleteTargets
44 | Commands::Path { .. }
45 | Commands::ShellFunction { .. }
46 | Commands::SpawnAi { .. }
47 | Commands::Guard { .. }
48 )
49 );
50
51 if !is_internal {
52 crate::operations::spawn_spec::sweep_stale();
53 update::check_for_update_if_needed();
54 }
55
56 let skip_shell_completion_prompt =
64 is_internal || matches!(&cli.command, Some(Commands::Config { .. }));
65
66 if !skip_shell_completion_prompt {
67 config::prompt_shell_completion_setup();
68 }
69
70 let result = match cli.command {
71 Some(Commands::List) => display::list_worktrees(),
72 Some(Commands::Ls) => display::list_worktrees_tsv(),
73 Some(Commands::New {
74 name,
75 path,
76 base,
77 term,
78 prompt,
79 prompt_file,
80 no_env_forward,
81 forward_args,
82 }) => (|| -> Result<()> {
83 if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
88 return Err(CwError::Other(
89 "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
90 pick one or the other"
91 .to_string(),
92 ));
93 }
94 reject_gw_flags_in_forward(&forward_args)?;
98 let resolved = resolve_prompt(
102 prompt,
103 prompt_file.as_deref(),
104 || std::io::stdin().is_terminal(),
105 || {
106 let mut buf = String::new();
107 std::io::stdin().read_to_string(&mut buf)?;
108 Ok(buf)
109 },
110 )?;
111 let _ = config::parse_term_option(term.as_deref())?;
117 cwshare_setup::prompt_cwshare_setup();
118
119 let opts = LaunchOptions {
120 term_override: term.as_deref(),
121 forward_args: &forward_args,
122 no_env_forward,
123 };
124 worktree::create_worktree(
125 &name,
126 base.as_deref(),
127 path.as_deref(),
128 resolved.as_deref(),
129 &opts,
130 )?;
131 Ok(())
132 })(),
133
134 Some(Commands::Resume {
135 branch,
136 term,
137 no_env_forward,
138 forward_args,
139 }) => (|| -> Result<()> {
140 let (branch, forward_args) = lift_dash_target(branch, forward_args);
145 reject_gw_flags_in_forward(&forward_args)?;
150 let opts = LaunchOptions {
151 term_override: term.as_deref(),
152 forward_args: &forward_args,
153 no_env_forward,
154 };
155 ai_tools::resume_worktree(branch.as_deref(), &opts)
156 })(),
157
158 Some(Commands::Spawn {
159 target,
160 term,
161 prompt,
162 prompt_file,
163 no_env_forward,
164 forward_args,
165 }) => (|| -> Result<()> {
166 let (target, forward_args) = lift_dash_target(target, forward_args);
168 if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
170 return Err(CwError::Other(
171 "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
172 pick one or the other"
173 .to_string(),
174 ));
175 }
176 reject_gw_flags_in_forward(&forward_args)?;
178 let resolved_prompt = resolve_prompt(
179 prompt,
180 prompt_file.as_deref(),
181 || std::io::stdin().is_terminal(),
182 || {
183 let mut buf = String::new();
184 std::io::stdin().read_to_string(&mut buf)?;
185 Ok(buf)
186 },
187 )?;
188 let cwd = std::env::current_dir()?;
189 let target_path = match target {
190 Some(t) => {
191 let main_repo = crate::git::get_main_repo_root(Some(&cwd))?;
192 helpers::resolve_target_strict(&main_repo, &t)?.path
193 }
194 None => crate::git::get_repo_root(Some(&cwd))?,
195 };
196 let opts = LaunchOptions {
197 term_override: term.as_deref(),
198 forward_args: &forward_args,
199 no_env_forward,
200 };
201 ai_tools::spawn_in_worktree(&target_path, resolved_prompt.as_deref(), &opts)
202 })(),
203
204 Some(Commands::Rm {
205 targets,
206 interactive,
207 dry_run,
208 keep_branch,
209 delete_remote,
210 force,
211 no_force,
212 }) => {
213 let flags = crate::operations::worktree::RmFlags {
214 keep_branch,
215 delete_remote,
216 git_force: !no_force,
217 allow_busy: force,
218 };
219 match crate::operations::rm_batch::rm_worktrees(targets, interactive, dry_run, flags) {
220 Ok(0) => Ok(()),
221 Ok(code) => Err(crate::error::CwError::ExitCode(code)),
222 Err(e) => Err(e),
223 }
224 }
225
226 Some(Commands::Doctor {
227 session_start,
228 quiet,
229 }) => diagnostics::doctor(session_start, quiet),
230 Some(Commands::Run {
231 only,
232 no_main,
233 jobs,
234 continue_on_error,
235 cmd,
236 }) => (|| -> Result<()> {
237 let cwd = std::env::current_dir()?;
238 let code = run::run_in_scope(
239 &cwd,
240 &cmd,
241 only.as_deref(),
242 no_main,
243 jobs,
244 continue_on_error,
245 )?;
246 if code != 0 {
247 return Err(crate::error::CwError::ExitCode(code));
248 }
249 Ok(())
250 })(),
251
252 Some(Commands::Exec { target, cmd }) => (|| -> Result<()> {
253 let cwd = std::env::current_dir()?;
254 let mut out = std::io::stdout().lock();
255 let code = exec::exec_in_target(&cwd, &target, &cmd, &mut out)?;
256 if code != 0 {
257 return Err(crate::error::CwError::ExitCode(code));
258 }
259 Ok(())
260 })(),
261
262 Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
263 Some(Commands::SetupClaude) => setup_claude::setup_claude(),
264
265 Some(Commands::Config { action }) => match action {
266 ConfigAction::List => config_ops::list_cmd(),
267 ConfigAction::Get { key } => config_ops::get_cmd(key),
268 ConfigAction::Set { key, value, repo } => {
269 let scope = if repo {
270 config_ops::Scope::Repo
271 } else {
272 config_ops::Scope::Global
273 };
274 config_ops::set_cmd(key, &value, scope)
275 }
276 ConfigAction::Edit => crate::tui::config_editor::run(),
277 },
278
279 Some(Commands::Upgrade { yes }) => {
280 update::upgrade(yes);
281 Ok(())
282 }
283
284 Some(Commands::ShellSetup) => {
285 shell_setup();
286 Ok(())
287 }
288
289 Some(Commands::Path {
290 branch,
291 list_branches,
292 interactive,
293 }) => path_cmd::worktree_path(branch.as_deref(), list_branches, interactive),
294
295 Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
296 Some(output) => {
297 print!("{}", output);
298 Ok(())
299 }
300 None => Err(CwError::Config(format!(
301 "Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
302 shell
303 ))),
304 },
305
306 Some(Commands::UpdateCache) => {
307 update::refresh_cache();
308 Ok(())
309 }
310
311 Some(Commands::CompleteTargets) => crate::operations::complete::print_completion_targets(),
312
313 Some(Commands::SpawnAi { spec }) => {
314 let resolved = match spec {
320 Some(p) => p,
321 None => match spawn_spec::resolve_last_for_cwd() {
322 Ok(p) => p,
323 Err(e) => {
324 eprintln!("{}", e);
325 std::process::exit(127);
326 }
327 },
328 };
329 if let Err(e) = spawn_spec::execute(&resolved) {
330 eprintln!("{}", e);
331 std::process::exit(127);
332 }
333 Ok(())
334 }
335
336 None => Ok(()),
337 };
338
339 if let Err(e) = result {
340 if let CwError::ExitCode(code) = e {
345 std::process::exit(code);
346 }
347 cwconsole::print_error(&format!("Error: {}", e));
348 std::process::exit(1);
349 }
350}
351
352fn generate_completions(shell_name: &str) {
353 use clap::CommandFactory;
354 use clap_complete::{generate, Shell};
355
356 let shell = match shell_name.to_lowercase().as_str() {
357 "bash" => Shell::Bash,
358 "zsh" => Shell::Zsh,
359 "fish" => Shell::Fish,
360 "powershell" | "pwsh" => Shell::PowerShell,
361 "elvish" => Shell::Elvish,
362 _ => {
363 eprintln!(
364 "Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
365 shell_name
366 );
367 std::process::exit(1);
368 }
369 };
370
371 let mut cmd = Cli::command();
372 generate(shell, &mut cmd, "gw", &mut std::io::stdout());
373}
374
375fn shell_setup() {
376 let shell_env = std::env::var("SHELL").unwrap_or_default();
377 let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
378
379 let home = constants::home_dir_or_fallback();
380 let (shell_name, profile_path) = if shell_env.contains("zsh") {
381 ("zsh", Some(home.join(".zshrc")))
382 } else if shell_env.contains("bash") {
383 ("bash", Some(home.join(".bashrc")))
384 } else if shell_env.contains("fish") {
385 (
386 "fish",
387 Some(home.join(".config").join("fish").join("config.fish")),
388 )
389 } else if is_powershell {
390 ("powershell", None::<std::path::PathBuf>)
391 } else {
392 println!("Could not detect your shell automatically.\n");
393 println!("Please manually add the gw-cd function to your shell:\n");
394 println!(" bash/zsh: source <(gw _shell-function bash)");
395 println!(" fish: gw _shell-function fish | source");
396 println!(" PowerShell: gw _shell-function powershell | Out-String | Invoke-Expression");
397 return;
398 };
399
400 println!("Detected shell: {}\n", shell_name);
401
402 if shell_name == "powershell" {
403 println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
404 println!(" gw _shell-function powershell | Out-String | Invoke-Expression\n");
405 println!("To find your PowerShell profile location, run: $PROFILE");
406 println!(
407 "\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
408 );
409 return;
410 }
411
412 let shell_function_line = match shell_name {
413 "fish" => "gw _shell-function fish | source".to_string(),
414 _ => format!("source <(gw _shell-function {})", shell_name),
415 };
416
417 if let Some(ref path) = profile_path {
418 if path.exists() {
419 if let Ok(content) = std::fs::read_to_string(path) {
420 if content.contains("gw _shell-function") || content.contains("gw-cd") {
421 println!(
422 "{}",
423 console::style("Shell integration is already installed.").green()
424 );
425 println!(" Found in: {}\n", path.display());
426
427 refresh_shell_cache(shell_name);
428
429 println!("\nRestart your shell or run: source {}", path.display());
430 return;
431 }
432 }
433 }
434 }
435
436 println!("Setup shell integration?\n");
437 println!(
438 "This will add the following to {}:",
439 profile_path
440 .as_ref()
441 .map(|p| p.display().to_string())
442 .unwrap_or("your profile".to_string())
443 );
444
445 println!(
446 "\n # git-worktree-manager shell integration{}",
447 if matches!(shell_name, "zsh" | "bash") {
448 " (gw-cd + tab completion)"
449 } else {
450 ""
451 }
452 );
453 println!(" {}\n", shell_function_line);
454
455 print!("Add to your shell profile? [Y/n]: ");
456 use std::io::Write;
457 let _ = std::io::stdout().flush();
458
459 let mut input = String::new();
460 let _ = std::io::stdin().read_line(&mut input);
461 let input = input.trim().to_lowercase();
462
463 if !input.is_empty() && input != "y" && input != "yes" {
464 println!("\nSetup cancelled.");
465 return;
466 }
467
468 let Some(ref path) = profile_path else {
469 return;
470 };
471
472 if let Some(parent) = path.parent() {
473 let _ = std::fs::create_dir_all(parent);
474 }
475
476 let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
477 " (gw-cd + tab completion)"
478 } else {
479 ""
480 };
481 let append = format!(
482 "\n# git-worktree-manager shell integration{}\n{}\n",
483 comment_suffix, shell_function_line
484 );
485
486 match std::fs::OpenOptions::new()
487 .create(true)
488 .append(true)
489 .open(path)
490 {
491 Ok(mut f) => {
492 let _ = f.write_all(append.as_bytes());
493
494 if let Ok(mut cfg) = config::load_config() {
495 cfg.shell_completion.installed = true;
496 cfg.shell_completion.prompted = true;
497 let _ = config::save_config(&cfg);
498 }
499
500 println!("\n* Successfully added to {}", path.display());
501
502 refresh_shell_cache(shell_name);
503
504 println!("\nNext steps:");
505 println!(" 1. Restart your shell or run: source {}", path.display());
506 println!(" 2. Try directory navigation: gw-cd <branch-name>");
507 println!(" 3. Try tab completion: gw <TAB> or gw new <TAB>");
508 }
509 Err(e) => {
510 println!("\nError: Failed to update {}: {}", path.display(), e);
511 println!("\nTo install manually, add the lines shown above to your profile");
512 }
513 }
514}
515
516fn refresh_shell_cache(shell_name: &str) {
518 let home = constants::home_dir_or_fallback();
519
520 let cache_paths = [
521 home.join(".cache").join("gw-shell-function.zsh"),
522 home.join(".cache").join("gw-shell-function.bash"),
523 home.join(".cache").join("gw-shell-function.fish"),
524 ];
525
526 let mut refreshed = false;
527 for cache_path in &cache_paths {
528 if !cache_path.exists() {
529 continue;
530 }
531 let cache_shell = cache_path
532 .extension()
533 .and_then(|e| e.to_str())
534 .unwrap_or("");
535 if let Some(content) = shell_functions::generate(cache_shell) {
536 if std::fs::write(cache_path, content).is_ok() {
537 println!(
538 " {} {}",
539 console::style("Refreshed cache:").dim(),
540 cache_path.display()
541 );
542 refreshed = true;
543 }
544 }
545 }
546
547 if refreshed {
548 return;
549 }
550
551 let cache_path = home
552 .join(".cache")
553 .join(format!("gw-shell-function.{}", shell_name));
554 if let Some(content) = shell_functions::generate(shell_name) {
555 let _ = std::fs::create_dir_all(cache_path.parent().unwrap_or(&home));
556 if std::fs::write(&cache_path, &content).is_ok() {
557 println!(
558 " {} {}",
559 console::style("Created cache:").dim(),
560 cache_path.display()
561 );
562 }
563 }
564}
565
566fn reject_gw_flags_in_forward(forward_args: &[String]) -> Result<()> {
579 const GW_PROMPT_FLAGS: &[&str] = &["--prompt", "--prompt-file"];
580 for arg in forward_args {
581 if !arg.starts_with("--") {
582 continue;
583 }
584 let head = arg.split_once('=').map(|(h, _)| h).unwrap_or(arg.as_str());
585 if GW_PROMPT_FLAGS.contains(&head) {
586 return Err(CwError::Other(format!(
587 "{head} is a gw option, not an AI tool option — drop the `--` \
588 separator so gw consumes the flag itself (write `{head} \
589 <value>` without `--` in front of it)"
590 )));
591 }
592 }
593 Ok(())
594}
595
596fn lift_dash_target(
605 target: Option<String>,
606 forward_args: Vec<String>,
607) -> (Option<String>, Vec<String>) {
608 match target {
609 Some(t) if t.starts_with('-') => {
610 let mut lifted = Vec::with_capacity(forward_args.len() + 1);
611 lifted.push(t);
612 lifted.extend(forward_args);
613 (None, lifted)
614 }
615 other => (other, forward_args),
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::{lift_dash_target, reject_gw_flags_in_forward};
622
623 fn forward(args: &[&str]) -> Vec<String> {
624 args.iter().map(|s| (*s).to_string()).collect()
625 }
626
627 #[test]
628 fn reject_forward_args_passes_when_empty() {
629 reject_gw_flags_in_forward(&[]).unwrap();
630 }
631
632 #[test]
633 fn reject_forward_args_passes_ai_tool_flags() {
634 reject_gw_flags_in_forward(&forward(&["--model", "opus", "--resume"])).unwrap();
637 reject_gw_flags_in_forward(&forward(&["--print"])).unwrap();
638 }
639
640 #[test]
641 fn reject_forward_args_rejects_prompt_file_leading() {
642 let err = reject_gw_flags_in_forward(&forward(&["--prompt-file", "/tmp/p.txt"]))
643 .expect_err("must reject");
644 let msg = format!("{err}");
645 assert!(msg.contains("--prompt-file"), "unexpected msg: {msg}");
646 assert!(msg.contains("gw option"), "unexpected msg: {msg}");
647 }
648
649 #[test]
650 fn reject_forward_args_rejects_prompt_leading() {
651 let err =
652 reject_gw_flags_in_forward(&forward(&["--prompt", "hi"])).expect_err("must reject");
653 assert!(format!("{err}").contains("--prompt"));
654 }
655
656 #[test]
657 fn reject_forward_args_rejects_equals_form() {
658 let err = reject_gw_flags_in_forward(&forward(&["--prompt-file=/tmp/p.txt"]))
659 .expect_err("must reject");
660 assert!(format!("{err}").contains("--prompt-file"));
661 let err =
662 reject_gw_flags_in_forward(&forward(&["--prompt=hello"])).expect_err("must reject");
663 assert!(format!("{err}").contains("--prompt"));
664 }
665
666 #[test]
667 fn reject_forward_args_rejects_prompt_after_positional() {
668 let err = reject_gw_flags_in_forward(&forward(&["some-prompt", "--prompt-file", "/tmp/p"]))
672 .expect_err("must reject");
673 assert!(format!("{err}").contains("--prompt-file"));
674 }
675
676 #[test]
677 fn reject_forward_args_rejects_prompt_after_other_flags() {
678 let err =
681 reject_gw_flags_in_forward(&forward(&["--model", "opus", "--prompt-file", "/tmp/p"]))
682 .expect_err("must reject");
683 assert!(format!("{err}").contains("--prompt-file"));
684 }
685
686 #[test]
687 fn reject_forward_args_ignores_short_dash_and_bare_dash() {
688 reject_gw_flags_in_forward(&forward(&["-"])).unwrap();
691 reject_gw_flags_in_forward(&forward(&["-p", "hello"])).unwrap();
692 }
693
694 #[test]
695 fn lift_dash_target_lifts_hyphen_target() {
696 let (target, fwd) = lift_dash_target(
697 Some("--model".to_string()),
698 vec!["opus".to_string(), "--resume".to_string()],
699 );
700 assert_eq!(target, None);
701 assert_eq!(fwd, vec!["--model", "opus", "--resume"]);
702 }
703
704 #[test]
705 fn lift_dash_target_passes_through_normal_target() {
706 let (target, fwd) = lift_dash_target(
707 Some("feat-x".to_string()),
708 vec!["--model".to_string(), "opus".to_string()],
709 );
710 assert_eq!(target.as_deref(), Some("feat-x"));
711 assert_eq!(fwd, vec!["--model", "opus"]);
712 }
713
714 #[test]
715 fn lift_dash_target_handles_none_target() {
716 let (target, fwd) = lift_dash_target(None, vec![]);
717 assert_eq!(target, None);
718 assert!(fwd.is_empty());
719 }
720
721 #[test]
722 fn lift_dash_target_lifts_with_no_forward_args() {
723 let (target, fwd) = lift_dash_target(Some("--model".to_string()), vec![]);
725 assert_eq!(target, None);
726 assert_eq!(fwd, vec!["--model"]);
727 }
728}