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