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