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};
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    // Skip startup checks for internal commands (shell-completion helpers,
36    // cache refresh) — they are invoked by the shell on every keystroke, so
37    // paying for update-check / prompts would compound latency and risk
38    // recursive re-entry into the update flow.
39    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    // `gw config` is the user's tool for inspecting/editing the same file the
57    // one-time shell-completion hint persists to. Triggering the prompt here
58    // would seed a fresh global config file as a side-effect of `gw config
59    // list` (or even `gw config get`), surprising the user — and worse, makes
60    // every `[default]` row of `list` render as `[global]` once the autosave
61    // lands. Skip the hint for the `config` family; users who want it can
62    // discover it via any other command (or `gw shell-setup` directly).
63    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            // Reject --prompt + trailing forward args at dispatch time —
84            // both ultimately set the AI tool's prompt, so allowing both
85            // would silently produce two prompts. Surface this before
86            // anything touches disk.
87            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            // Catch `gw new <name> -- --prompt-file <path>` — clap would
95            // forward those gw-owned flags verbatim into the AI tool's
96            // argv, and `claude` would then die with "unknown option".
97            reject_gw_flags_in_forward(&forward_args)?;
98            // Resolve the prompt first so a missing file or unreadable stdin
99            // fails before any interactive side effects (worktree creation,
100            // AI-tool launch) leave the tree in a half-configured state.
101            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            // Pre-flight `-T <method>` so a typo (`-T does-not-exist`) errors
112            // before we create a worktree on disk. The launch path inside
113            // create_worktree swallows spawn errors with `let _ = …`, which
114            // would otherwise leave a phantom worktree on a bad alias.
115            // Skip / none / noop is a valid value here, so this still parses.
116            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            // clap's trailing_var_arg + Option<positional> combo lets `--`
141            // get absorbed by the optional positional: `gw resume -- --model
142            // opus` parses as branch=Some("--model"), forward_args=["opus"].
143            // Detect a hyphen-led "branch" and lift it into forward_args.
144            let (branch, forward_args) = lift_dash_target(branch, forward_args);
145            // `gw resume` has no `--prompt` of its own, but we still want
146            // `gw resume <branch> -- --prompt-file <path>` to fail fast
147            // here rather than inside the spawned terminal where the user
148            // can't see the AI tool's stderr.
149            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            // See Resume arm: clap absorbs `--` into the optional positional.
167            let (target, forward_args) = lift_dash_target(target, forward_args);
168            // Same prompt/forward conflict as `gw new`.
169            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            // Same `-- --prompt-file` leak as `gw new`.
177            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            // Pre-spawn failures (read/parse/chdir) exit 127 — the shell
315            // "command not found / could not start" convention. Post-spawn
316            // failures exit from inside `execute` directly, also with 127.
317            // Inner errors already carry the "spawn-ai:" prefix via their
318            // CwError::Other messages, so we print them verbatim.
319            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        // ExitCode carries a specific exit status from callers that have
341        // already produced their own user-facing output (e.g. the multi-target
342        // delete orchestrator). Exit silently with that code instead of the
343        // generic "Error: …" print.
344        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
516/// Refresh cached shell function files to pick up new features.
517fn 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
566/// Reject gw's own `--prompt` / `--prompt-file` flags when they leak into
567/// the trailing forward-args slot (typically because the caller wrote
568/// `gw new <name> -- --prompt-file <path>`, which clap dutifully forwards
569/// to the AI tool — `claude` then rejects the unknown option). We surface
570/// the error at dispatch time instead of letting it fail inside the spawned
571/// terminal where the user can't see it.
572///
573/// We scan every element rather than stopping at the first non-flag: these
574/// tokens are gw-owned and have no meaning to any AI tool we support, so
575/// catching them no matter where they sit in the forward list is strictly
576/// safer than letting the spawned process fail. If a real AI tool ever
577/// gains an identically-named flag, this is a one-line carve-out.
578fn 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
596/// `gw spawn -- --model opus` and `gw resume -- --model opus` parse, under
597/// clap's `trailing_var_arg=true` + `Option<String>` positional combo, as
598/// `target=Some("--model")`, `forward_args=["opus"]` — clap silently absorbs
599/// the `--` into the optional positional. A real worktree name can never
600/// start with `-` (git rejects it), so a hyphen-led "target" is unambiguously
601/// a misparsed forward arg. Lift it (and any captured forward args) back into
602/// forward_args, with `target` cleared so the dispatcher falls through to
603/// "current worktree".
604fn 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        // `--model opus`, `--resume`, `--continue`, `--print` — all legitimate
635        // claude/codex flags; must flow through untouched.
636        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        // Catch even when the leaked gw flag follows a positional or another
669        // flag's value — claude/codex/gemini have no `--prompt-file` of their
670        // own, so any occurrence is a misroute regardless of position.
671        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        // Mixed: a legitimate AI-tool flag and its value, followed by a
679        // leaked gw flag.
680        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        // `-` (read stdin convention) and `-x`-style short flags must not
689        // trip the guard — we only match the two specific long flags.
690        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        // `gw spawn -- --model` (no value) — pathological but still well-defined.
724        let (target, fwd) = lift_dash_target(Some("--model".to_string()), vec![]);
725        assert_eq!(target, None);
726        assert_eq!(fwd, vec!["--model"]);
727    }
728}