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
26const ERR_PROMPT_AND_FORWARD: &str = "--prompt / --prompt-file cannot be combined with trailing \
30 AI tool args; pick one or the other";
31
32pub fn run() {
33 tui::install_panic_hook();
34 let cli = Cli::parse();
35
36 if let Some(ref shell_name) = cli.generate_completion {
37 generate_completions(shell_name);
38 return;
39 }
40
41 let is_internal = matches!(
46 &cli.command,
47 Some(
48 Commands::UpdateCache
49 | Commands::CompleteTargets
50 | Commands::Path { .. }
51 | Commands::ShellFunction { .. }
52 | Commands::SpawnAi { .. }
53 | Commands::Guard { .. }
54 | Commands::ClaudeWorktreeCreate
55 | Commands::ClaudeWorktreeRemove
56 )
57 );
58
59 if !is_internal {
60 crate::operations::spawn_spec::sweep_stale();
61 update::check_for_update_if_needed();
62 }
63
64 let skip_shell_completion_prompt =
72 is_internal || matches!(&cli.command, Some(Commands::Config { .. }));
73
74 if !skip_shell_completion_prompt {
75 config::prompt_shell_completion_setup();
76 }
77
78 let result = match cli.command {
79 Some(Commands::List) => display::list_worktrees(),
80 Some(Commands::Ls) => display::list_worktrees_tsv(),
81 Some(Commands::New {
82 name,
83 path,
84 base,
85 term,
86 prompt,
87 prompt_file,
88 no_env_forward,
89 emit,
90 forward_args,
91 }) => (|| -> Result<()> {
92 if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
97 return Err(CwError::Other(ERR_PROMPT_AND_FORWARD.to_string()));
98 }
99 reject_gw_flags_in_forward(&forward_args)?;
103 let resolved = resolve_prompt(
107 prompt,
108 prompt_file.as_deref(),
109 || std::io::stdin().is_terminal(),
110 || {
111 let mut buf = String::new();
112 std::io::stdin().read_to_string(&mut buf)?;
113 Ok(buf)
114 },
115 )?;
116 let _ = config::parse_term_option(term.as_deref())?;
122 cwshare_setup::prompt_cwshare_setup();
123
124 let effective_term = if emit == EmitFormat::Json && term.is_none() {
127 Some("skip".to_string())
128 } else {
129 term
130 };
131 let opts = LaunchOptions {
132 term_override: effective_term.as_deref(),
133 forward_args: &forward_args,
134 no_env_forward,
135 };
136 worktree::create_worktree(
137 &name,
138 base.as_deref(),
139 path.as_deref(),
140 resolved.as_deref(),
141 &opts,
142 emit,
143 )?;
144 Ok(())
145 })(),
146
147 Some(Commands::Resume {
148 branch,
149 term,
150 no_env_forward,
151 forward_args,
152 }) => (|| -> Result<()> {
153 let (branch, forward_args) = lift_dash_target(branch, forward_args);
158 reject_gw_flags_in_forward(&forward_args)?;
163 let opts = LaunchOptions {
164 term_override: term.as_deref(),
165 forward_args: &forward_args,
166 no_env_forward,
167 };
168 ai_tools::resume_worktree(branch.as_deref(), &opts)
169 })(),
170
171 Some(Commands::Spawn {
172 target,
173 term,
174 prompt,
175 prompt_file,
176 no_env_forward,
177 forward_args,
178 }) => (|| -> Result<()> {
179 let (target, forward_args) = lift_dash_target(target, forward_args);
181 if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
183 return Err(CwError::Other(ERR_PROMPT_AND_FORWARD.to_string()));
184 }
185 reject_gw_flags_in_forward(&forward_args)?;
187 let resolved_prompt = resolve_prompt(
188 prompt,
189 prompt_file.as_deref(),
190 || std::io::stdin().is_terminal(),
191 || {
192 let mut buf = String::new();
193 std::io::stdin().read_to_string(&mut buf)?;
194 Ok(buf)
195 },
196 )?;
197 let cwd = std::env::current_dir()?;
198 let target_path = match target {
199 Some(t) => {
200 let main_repo = crate::git::get_main_repo_root(Some(&cwd))?;
201 helpers::resolve_target_strict(&main_repo, &t)?.path
202 }
203 None => crate::git::get_repo_root(Some(&cwd))?,
204 };
205 let opts = LaunchOptions {
206 term_override: term.as_deref(),
207 forward_args: &forward_args,
208 no_env_forward,
209 };
210 ai_tools::spawn_in_worktree(&target_path, resolved_prompt.as_deref(), &opts)
211 })(),
212
213 Some(Commands::Rm {
214 targets,
215 interactive,
216 dry_run,
217 keep_branch,
218 delete_remote,
219 force,
220 no_force,
221 }) => {
222 let flags = crate::operations::worktree::RmFlags {
223 keep_branch,
224 delete_remote,
225 git_force: !no_force,
226 allow_busy: force,
227 };
228 match crate::operations::rm_batch::rm_worktrees(targets, interactive, dry_run, flags) {
229 Ok(0) => Ok(()),
230 Ok(code) => Err(crate::error::CwError::ExitCode(code)),
231 Err(e) => Err(e),
232 }
233 }
234
235 Some(Commands::Doctor {
236 session_start,
237 quiet,
238 }) => diagnostics::doctor(session_start, quiet),
239 Some(Commands::Run {
240 only,
241 no_main,
242 jobs,
243 continue_on_error,
244 cmd,
245 }) => (|| -> Result<()> {
246 let cwd = std::env::current_dir()?;
247 let code = run::run_in_scope(
248 &cwd,
249 &cmd,
250 only.as_deref(),
251 no_main,
252 jobs,
253 continue_on_error,
254 )?;
255 if code != 0 {
256 return Err(crate::error::CwError::ExitCode(code));
257 }
258 Ok(())
259 })(),
260
261 Some(Commands::Exec { target, cmd }) => (|| -> Result<()> {
262 let cwd = std::env::current_dir()?;
263 let mut out = std::io::stdout().lock();
264 let code = exec::exec_in_target(&cwd, &target, &cmd, &mut out)?;
265 if code != 0 {
266 return Err(crate::error::CwError::ExitCode(code));
267 }
268 Ok(())
269 })(),
270
271 Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
272
273 Some(Commands::ClaudeWorktreeCreate) => claude_worktree::run_create(),
274 Some(Commands::ClaudeWorktreeRemove) => claude_worktree::run_remove(),
275
276 Some(Commands::SetupClaude) => setup_claude::setup_claude(),
277
278 Some(Commands::Config { action }) => match action {
279 ConfigAction::List => config_ops::list_cmd(),
280 ConfigAction::Get { key } => config_ops::get_cmd(key),
281 ConfigAction::Set { key, value, repo } => {
282 let scope = if repo {
283 config_ops::Scope::Repo
284 } else {
285 config_ops::Scope::Global
286 };
287 config_ops::set_cmd(key, &value, scope)
288 }
289 ConfigAction::Edit => crate::tui::config_editor::run(),
290 },
291
292 Some(Commands::Upgrade { yes }) => {
293 update::upgrade(yes);
294 Ok(())
295 }
296
297 Some(Commands::ShellSetup) => {
298 shell_setup();
299 Ok(())
300 }
301
302 Some(Commands::Path {
303 branch,
304 list_branches,
305 interactive,
306 }) => path_cmd::worktree_path(branch.as_deref(), list_branches, interactive),
307
308 Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
309 Some(output) => {
310 print!("{}", output);
311 Ok(())
312 }
313 None => Err(CwError::Config(format!(
314 "Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
315 shell
316 ))),
317 },
318
319 Some(Commands::UpdateCache) => {
320 update::refresh_cache();
321 Ok(())
322 }
323
324 Some(Commands::CompleteTargets) => crate::operations::complete::print_completion_targets(),
325
326 Some(Commands::SpawnAi { spec }) => {
327 let resolved = match spec {
333 Some(p) => p,
334 None => match spawn_spec::resolve_last_for_cwd() {
335 Ok(p) => p,
336 Err(e) => {
337 eprintln!("{}", e);
338 std::process::exit(127);
339 }
340 },
341 };
342 if let Err(e) = spawn_spec::execute(&resolved) {
343 eprintln!("{}", e);
344 std::process::exit(127);
345 }
346 Ok(())
347 }
348
349 None => Ok(()),
350 };
351
352 if let Err(e) = result {
353 if let CwError::ExitCode(code) = e {
358 std::process::exit(code);
359 }
360 cwconsole::print_error(&format!("Error: {}", e));
361 std::process::exit(1);
362 }
363}
364
365fn generate_completions(shell_name: &str) {
366 use clap::CommandFactory;
367 use clap_complete::{generate, Shell};
368
369 let shell = match shell_name.to_lowercase().as_str() {
370 "bash" => Shell::Bash,
371 "zsh" => Shell::Zsh,
372 "fish" => Shell::Fish,
373 "powershell" | "pwsh" => Shell::PowerShell,
374 "elvish" => Shell::Elvish,
375 _ => {
376 eprintln!(
377 "Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
378 shell_name
379 );
380 std::process::exit(1);
381 }
382 };
383
384 let mut cmd = Cli::command();
385 generate(shell, &mut cmd, "gw", &mut std::io::stdout());
386}
387
388fn shell_setup() {
389 let shell_env = std::env::var("SHELL").unwrap_or_default();
390 let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
391
392 let home = constants::home_dir_or_fallback();
393 let (shell_name, profile_path) = if shell_env.contains("zsh") {
394 ("zsh", Some(home.join(".zshrc")))
395 } else if shell_env.contains("bash") {
396 ("bash", Some(home.join(".bashrc")))
397 } else if shell_env.contains("fish") {
398 (
399 "fish",
400 Some(home.join(".config").join("fish").join("config.fish")),
401 )
402 } else if is_powershell {
403 ("powershell", None::<std::path::PathBuf>)
404 } else {
405 println!("Could not detect your shell automatically.\n");
406 println!("Please manually add the gw-cd function to your shell:\n");
407 println!(" bash/zsh: source <(gw _shell-function bash)");
408 println!(" fish: gw _shell-function fish | source");
409 println!(" PowerShell: gw _shell-function powershell | Out-String | Invoke-Expression");
410 return;
411 };
412
413 println!("Detected shell: {}\n", shell_name);
414
415 if shell_name == "powershell" {
416 println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
417 println!(" gw _shell-function powershell | Out-String | Invoke-Expression\n");
418 println!("To find your PowerShell profile location, run: $PROFILE");
419 println!(
420 "\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
421 );
422 return;
423 }
424
425 let shell_function_line = match shell_name {
426 "fish" => "gw _shell-function fish | source".to_string(),
427 _ => format!("source <(gw _shell-function {})", shell_name),
428 };
429
430 if let Some(ref path) = profile_path {
431 if path.exists() {
432 if let Ok(content) = std::fs::read_to_string(path) {
433 if content.contains("gw _shell-function") || content.contains("gw-cd") {
434 println!(
435 "{}",
436 console::style("Shell integration is already installed.").green()
437 );
438 println!(" Found in: {}\n", path.display());
439
440 refresh_shell_cache(shell_name);
441
442 println!("\nRestart your shell or run: source {}", path.display());
443 return;
444 }
445 }
446 }
447 }
448
449 println!("Setup shell integration?\n");
450 println!(
451 "This will add the following to {}:",
452 profile_path
453 .as_ref()
454 .map(|p| p.display().to_string())
455 .unwrap_or_else(|| "your profile".to_string())
456 );
457
458 println!(
459 "\n # git-worktree-manager shell integration{}",
460 if matches!(shell_name, "zsh" | "bash") {
461 " (gw-cd + tab completion)"
462 } else {
463 ""
464 }
465 );
466 println!(" {}\n", shell_function_line);
467
468 print!("Add to your shell profile? [Y/n]: ");
469 use std::io::Write;
470 let _ = std::io::stdout().flush();
471
472 let mut input = String::new();
473 let _ = std::io::stdin().read_line(&mut input);
474 let input = input.trim().to_lowercase();
475
476 if !input.is_empty() && input != "y" && input != "yes" {
477 println!("\nSetup cancelled.");
478 return;
479 }
480
481 let Some(ref path) = profile_path else {
482 return;
483 };
484
485 if let Some(parent) = path.parent() {
486 let _ = std::fs::create_dir_all(parent);
487 }
488
489 let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
490 " (gw-cd + tab completion)"
491 } else {
492 ""
493 };
494 let append = format!(
495 "\n# git-worktree-manager shell integration{}\n{}\n",
496 comment_suffix, shell_function_line
497 );
498
499 match std::fs::OpenOptions::new()
500 .create(true)
501 .append(true)
502 .open(path)
503 {
504 Ok(mut f) => {
505 let _ = f.write_all(append.as_bytes());
506
507 if let Ok(mut cfg) = config::load_config() {
508 cfg.shell_completion.installed = true;
509 cfg.shell_completion.prompted = true;
510 let _ = config::save_config(&cfg);
511 }
512
513 println!("\n* Successfully added to {}", path.display());
514
515 refresh_shell_cache(shell_name);
516
517 println!("\nNext steps:");
518 println!(" 1. Restart your shell or run: source {}", path.display());
519 println!(" 2. Try directory navigation: gw-cd <branch-name>");
520 println!(" 3. Try tab completion: gw <TAB> or gw new <TAB>");
521 }
522 Err(e) => {
523 println!("\nError: Failed to update {}: {}", path.display(), e);
524 println!("\nTo install manually, add the lines shown above to your profile");
525 }
526 }
527}
528
529fn refresh_shell_cache(shell_name: &str) {
531 let home = constants::home_dir_or_fallback();
532
533 let cache_paths = [
534 home.join(".cache").join("gw-shell-function.zsh"),
535 home.join(".cache").join("gw-shell-function.bash"),
536 home.join(".cache").join("gw-shell-function.fish"),
537 ];
538
539 let mut refreshed = false;
540 for cache_path in &cache_paths {
541 if !cache_path.exists() {
542 continue;
543 }
544 let cache_shell = cache_path
545 .extension()
546 .and_then(|e| e.to_str())
547 .unwrap_or("");
548 if let Some(content) = shell_functions::generate(cache_shell) {
549 if std::fs::write(cache_path, content).is_ok() {
550 println!(
551 " {} {}",
552 console::style("Refreshed cache:").dim(),
553 cache_path.display()
554 );
555 refreshed = true;
556 }
557 }
558 }
559
560 if refreshed {
561 return;
562 }
563
564 let cache_path = home
565 .join(".cache")
566 .join(format!("gw-shell-function.{}", shell_name));
567 if let Some(content) = shell_functions::generate(shell_name) {
568 if let Some(cache_dir) = cache_path.parent() {
569 let _ = std::fs::create_dir_all(cache_dir);
570 }
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}