Skip to main content

git_worktree_manager/
entrypoint.rs

1//! CLI entrypoint for the `gw` binary.
2//!
3//! `src/bin/gw.rs` delegates to [`run`]. The library entrypoint stays
4//! separate from the binary so unit tests in this module compile without
5//! pulling in the binary stack-size shim.
6
7use 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
26/// Error returned when `--prompt`/`--prompt-file` and trailing AI-tool args
27/// are both provided. Both ultimately set the AI tool's prompt, so mixing them
28/// would silently produce two conflicting prompts.
29const 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    // Skip startup checks for internal commands (shell-completion helpers,
42    // cache refresh) — they are invoked by the shell on every keystroke, so
43    // paying for update-check / prompts would compound latency and risk
44    // recursive re-entry into the update flow.
45    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    // `gw config` is the user's tool for inspecting/editing the same file the
65    // one-time shell-completion hint persists to. Triggering the prompt here
66    // would seed a fresh global config file as a side-effect of `gw config
67    // list` (or even `gw config get`), surprising the user — and worse, makes
68    // every `[default]` row of `list` render as `[global]` once the autosave
69    // lands. Skip the hint for the `config` family; users who want it can
70    // discover it via any other command (or `gw shell-setup` directly).
71    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            // Reject --prompt + trailing forward args at dispatch time —
93            // both ultimately set the AI tool's prompt, so allowing both
94            // would silently produce two prompts. Surface this before
95            // anything touches disk.
96            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            // Catch `gw new <name> -- --prompt-file <path>` — clap would
100            // forward those gw-owned flags verbatim into the AI tool's
101            // argv, and `claude` would then die with "unknown option".
102            reject_gw_flags_in_forward(&forward_args)?;
103            // Resolve the prompt first so a missing file or unreadable stdin
104            // fails before any interactive side effects (worktree creation,
105            // AI-tool launch) leave the tree in a half-configured state.
106            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            // Pre-flight `-T <method>` so a typo (`-T does-not-exist`) errors
117            // before we create a worktree on disk. The launch path inside
118            // create_worktree swallows spawn errors with `let _ = …`, which
119            // would otherwise leave a phantom worktree on a bad alias.
120            // Skip / none / noop is a valid value here, so this still parses.
121            let _ = config::parse_term_option(term.as_deref())?;
122            cwshare_setup::prompt_cwshare_setup();
123
124            // --emit json implies -T skip: the caller reads worktree_path from
125            // stdout, so spawning a terminal would race with that contract.
126            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            // clap's trailing_var_arg + Option<positional> combo lets `--`
154            // get absorbed by the optional positional: `gw resume -- --model
155            // opus` parses as branch=Some("--model"), forward_args=["opus"].
156            // Detect a hyphen-led "branch" and lift it into forward_args.
157            let (branch, forward_args) = lift_dash_target(branch, forward_args);
158            // `gw resume` has no `--prompt` of its own, but we still want
159            // `gw resume <branch> -- --prompt-file <path>` to fail fast
160            // here rather than inside the spawned terminal where the user
161            // can't see the AI tool's stderr.
162            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            // See Resume arm: clap absorbs `--` into the optional positional.
180            let (target, forward_args) = lift_dash_target(target, forward_args);
181            // Same prompt/forward conflict as `gw new`.
182            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            // Same `-- --prompt-file` leak as `gw new`.
186            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            // Pre-spawn failures (read/parse/chdir) exit 127 — the shell
328            // "command not found / could not start" convention. Post-spawn
329            // failures exit from inside `execute` directly, also with 127.
330            // Inner errors already carry the "spawn-ai:" prefix via their
331            // CwError::Other messages, so we print them verbatim.
332            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        // ExitCode carries a specific exit status from callers that have
354        // already produced their own user-facing output (e.g. the multi-target
355        // delete orchestrator). Exit silently with that code instead of the
356        // generic "Error: …" print.
357        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
529/// Refresh cached shell function files to pick up new features.
530fn 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
581/// Reject gw's own `--prompt` / `--prompt-file` flags when they leak into
582/// the trailing forward-args slot (typically because the caller wrote
583/// `gw new <name> -- --prompt-file <path>`, which clap dutifully forwards
584/// to the AI tool — `claude` then rejects the unknown option). We surface
585/// the error at dispatch time instead of letting it fail inside the spawned
586/// terminal where the user can't see it.
587///
588/// We scan every element rather than stopping at the first non-flag: these
589/// tokens are gw-owned and have no meaning to any AI tool we support, so
590/// catching them no matter where they sit in the forward list is strictly
591/// safer than letting the spawned process fail. If a real AI tool ever
592/// gains an identically-named flag, this is a one-line carve-out.
593fn 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
611/// `gw spawn -- --model opus` and `gw resume -- --model opus` parse, under
612/// clap's `trailing_var_arg=true` + `Option<String>` positional combo, as
613/// `target=Some("--model")`, `forward_args=["opus"]` — clap silently absorbs
614/// the `--` into the optional positional. A real worktree name can never
615/// start with `-` (git rejects it), so a hyphen-led "target" is unambiguously
616/// a misparsed forward arg. Lift it (and any captured forward args) back into
617/// forward_args, with `target` cleared so the dispatcher falls through to
618/// "current worktree".
619fn 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        // `--model opus`, `--resume`, `--continue`, `--print` — all legitimate
650        // claude/codex flags; must flow through untouched.
651        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        // Catch even when the leaked gw flag follows a positional or another
684        // flag's value — claude/codex/gemini have no `--prompt-file` of their
685        // own, so any occurrence is a misroute regardless of position.
686        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        // Mixed: a legitimate AI-tool flag and its value, followed by a
694        // leaked gw flag.
695        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        // `-` (read stdin convention) and `-x`-style short flags must not
704        // trip the guard — we only match the two specific long flags.
705        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        // `gw spawn -- --model` (no value) — pathological but still well-defined.
739        let (target, fwd) = lift_dash_target(Some("--model".to_string()), vec![]);
740        assert_eq!(target, None);
741        assert_eq!(fwd, vec!["--model"]);
742    }
743}