use clap::Parser;
use crate::cli::{Cli, Commands, ConfigAction, EmitFormat};
use crate::config;
use crate::console as cwconsole;
use crate::constants;
use crate::cwshare_setup;
use crate::error::{CwError, Result};
use crate::operations::ai_tools::LaunchOptions;
use crate::operations::{
ai_tools, claude_worktree, config_ops, diagnostics, display, exec, guard, helpers, path_cmd,
run, setup_claude, spawn_spec, worktree,
};
use crate::resolve_prompt;
use crate::shell_functions;
use crate::tui;
use crate::update;
use std::io::{IsTerminal, Read};
const ERR_PROMPT_AND_FORWARD: &str = "--prompt / --prompt-file cannot be combined with trailing \
AI tool args; pick one or the other";
pub fn run() {
tui::install_panic_hook();
let cli = Cli::parse();
if let Some(ref shell_name) = cli.generate_completion {
generate_completions(shell_name);
return;
}
let is_internal = matches!(
&cli.command,
Some(
Commands::UpdateCache
| Commands::CompleteTargets
| Commands::Path { .. }
| Commands::ShellFunction { .. }
| Commands::SpawnAi { .. }
| Commands::Guard { .. }
| Commands::ClaudeWorktreeCreate
| Commands::ClaudeWorktreeRemove
)
);
if !is_internal {
crate::operations::spawn_spec::sweep_stale();
update::check_for_update_if_needed();
}
let skip_shell_completion_prompt =
is_internal || matches!(&cli.command, Some(Commands::Config { .. }));
if !skip_shell_completion_prompt {
config::prompt_shell_completion_setup();
}
let result = match cli.command {
Some(Commands::List) => display::list_worktrees(),
Some(Commands::Ls) => display::list_worktrees_tsv(),
Some(Commands::New {
name,
path,
base,
term,
prompt,
prompt_file,
no_env_forward,
emit,
forward_args,
}) => (|| -> Result<()> {
if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
return Err(CwError::Other(ERR_PROMPT_AND_FORWARD.to_string()));
}
reject_gw_flags_in_forward(&forward_args)?;
let resolved = resolve_prompt(
prompt,
prompt_file.as_deref(),
|| std::io::stdin().is_terminal(),
|| {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
},
)?;
let _ = config::parse_term_option(term.as_deref())?;
cwshare_setup::prompt_cwshare_setup();
let effective_term = if emit == EmitFormat::Json && term.is_none() {
Some("skip".to_string())
} else {
term
};
let opts = LaunchOptions {
term_override: effective_term.as_deref(),
forward_args: &forward_args,
no_env_forward,
};
worktree::create_worktree(
&name,
base.as_deref(),
path.as_deref(),
resolved.as_deref(),
&opts,
emit,
)?;
Ok(())
})(),
Some(Commands::Resume {
branch,
term,
no_env_forward,
forward_args,
}) => (|| -> Result<()> {
let (branch, forward_args) = lift_dash_target(branch, forward_args);
reject_gw_flags_in_forward(&forward_args)?;
let opts = LaunchOptions {
term_override: term.as_deref(),
forward_args: &forward_args,
no_env_forward,
};
ai_tools::resume_worktree(branch.as_deref(), &opts)
})(),
Some(Commands::Spawn {
target,
term,
prompt,
prompt_file,
no_env_forward,
forward_args,
}) => (|| -> Result<()> {
let (target, forward_args) = lift_dash_target(target, forward_args);
if (prompt.is_some() || prompt_file.is_some()) && !forward_args.is_empty() {
return Err(CwError::Other(ERR_PROMPT_AND_FORWARD.to_string()));
}
reject_gw_flags_in_forward(&forward_args)?;
let resolved_prompt = resolve_prompt(
prompt,
prompt_file.as_deref(),
|| std::io::stdin().is_terminal(),
|| {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
},
)?;
let cwd = std::env::current_dir()?;
let target_path = match target {
Some(t) => {
let main_repo = crate::git::get_main_repo_root(Some(&cwd))?;
helpers::resolve_target_strict(&main_repo, &t)?.path
}
None => crate::git::get_repo_root(Some(&cwd))?,
};
let opts = LaunchOptions {
term_override: term.as_deref(),
forward_args: &forward_args,
no_env_forward,
};
ai_tools::spawn_in_worktree(&target_path, resolved_prompt.as_deref(), &opts)
})(),
Some(Commands::Rm {
targets,
interactive,
dry_run,
keep_branch,
delete_remote,
force,
no_force,
}) => {
let flags = crate::operations::worktree::RmFlags {
keep_branch,
delete_remote,
git_force: !no_force,
allow_busy: force,
};
match crate::operations::rm_batch::rm_worktrees(targets, interactive, dry_run, flags) {
Ok(0) => Ok(()),
Ok(code) => Err(crate::error::CwError::ExitCode(code)),
Err(e) => Err(e),
}
}
Some(Commands::Doctor {
session_start,
quiet,
}) => diagnostics::doctor(session_start, quiet),
Some(Commands::Run {
only,
no_main,
jobs,
continue_on_error,
cmd,
}) => (|| -> Result<()> {
let cwd = std::env::current_dir()?;
let code = run::run_in_scope(
&cwd,
&cmd,
only.as_deref(),
no_main,
jobs,
continue_on_error,
)?;
if code != 0 {
return Err(crate::error::CwError::ExitCode(code));
}
Ok(())
})(),
Some(Commands::Exec { target, cmd }) => (|| -> Result<()> {
let cwd = std::env::current_dir()?;
let mut out = std::io::stdout().lock();
let code = exec::exec_in_target(&cwd, &target, &cmd, &mut out)?;
if code != 0 {
return Err(crate::error::CwError::ExitCode(code));
}
Ok(())
})(),
Some(Commands::Guard { tool_input }) => guard::run(&tool_input),
Some(Commands::ClaudeWorktreeCreate) => claude_worktree::run_create(),
Some(Commands::ClaudeWorktreeRemove) => claude_worktree::run_remove(),
Some(Commands::SetupClaude) => setup_claude::setup_claude(),
Some(Commands::Config { action }) => match action {
ConfigAction::List => config_ops::list_cmd(),
ConfigAction::Get { key } => config_ops::get_cmd(key),
ConfigAction::Set { key, value, repo } => {
let scope = if repo {
config_ops::Scope::Repo
} else {
config_ops::Scope::Global
};
config_ops::set_cmd(key, &value, scope)
}
ConfigAction::Edit => crate::tui::config_editor::run(),
},
Some(Commands::Upgrade { yes }) => {
update::upgrade(yes);
Ok(())
}
Some(Commands::ShellSetup) => {
shell_setup();
Ok(())
}
Some(Commands::Path {
branch,
list_branches,
interactive,
}) => path_cmd::worktree_path(branch.as_deref(), list_branches, interactive),
Some(Commands::ShellFunction { shell }) => match shell_functions::generate(&shell) {
Some(output) => {
print!("{}", output);
Ok(())
}
None => Err(CwError::Config(format!(
"Unsupported shell: {}. Use bash, zsh, fish, or powershell.",
shell
))),
},
Some(Commands::UpdateCache) => {
update::refresh_cache();
Ok(())
}
Some(Commands::CompleteTargets) => crate::operations::complete::print_completion_targets(),
Some(Commands::SpawnAi { spec }) => {
let resolved = match spec {
Some(p) => p,
None => match spawn_spec::resolve_last_for_cwd() {
Ok(p) => p,
Err(e) => {
eprintln!("{}", e);
std::process::exit(127);
}
},
};
if let Err(e) = spawn_spec::execute(&resolved) {
eprintln!("{}", e);
std::process::exit(127);
}
Ok(())
}
None => Ok(()),
};
if let Err(e) = result {
if let CwError::ExitCode(code) = e {
std::process::exit(code);
}
cwconsole::print_error(&format!("Error: {}", e));
std::process::exit(1);
}
}
fn generate_completions(shell_name: &str) {
use clap::CommandFactory;
use clap_complete::{generate, Shell};
let shell = match shell_name.to_lowercase().as_str() {
"bash" => Shell::Bash,
"zsh" => Shell::Zsh,
"fish" => Shell::Fish,
"powershell" | "pwsh" => Shell::PowerShell,
"elvish" => Shell::Elvish,
_ => {
eprintln!(
"Unsupported shell: {}. Use bash, zsh, fish, powershell, or elvish.",
shell_name
);
std::process::exit(1);
}
};
let mut cmd = Cli::command();
generate(shell, &mut cmd, "gw", &mut std::io::stdout());
}
fn shell_setup() {
let shell_env = std::env::var("SHELL").unwrap_or_default();
let is_powershell = cfg!(target_os = "windows") || std::env::var("PSModulePath").is_ok();
let home = constants::home_dir_or_fallback();
let (shell_name, profile_path) = if shell_env.contains("zsh") {
("zsh", Some(home.join(".zshrc")))
} else if shell_env.contains("bash") {
("bash", Some(home.join(".bashrc")))
} else if shell_env.contains("fish") {
(
"fish",
Some(home.join(".config").join("fish").join("config.fish")),
)
} else if is_powershell {
("powershell", None::<std::path::PathBuf>)
} else {
println!("Could not detect your shell automatically.\n");
println!("Please manually add the gw-cd function to your shell:\n");
println!(" bash/zsh: source <(gw _shell-function bash)");
println!(" fish: gw _shell-function fish | source");
println!(" PowerShell: gw _shell-function powershell | Out-String | Invoke-Expression");
return;
};
println!("Detected shell: {}\n", shell_name);
if shell_name == "powershell" {
println!("To enable gw-cd in PowerShell, add the following to your $PROFILE:\n");
println!(" gw _shell-function powershell | Out-String | Invoke-Expression\n");
println!("To find your PowerShell profile location, run: $PROFILE");
println!(
"\nIf the profile file doesn't exist, create it with: New-Item -Path $PROFILE -ItemType File -Force"
);
return;
}
let shell_function_line = match shell_name {
"fish" => "gw _shell-function fish | source".to_string(),
_ => format!("source <(gw _shell-function {})", shell_name),
};
if let Some(ref path) = profile_path {
if path.exists() {
if let Ok(content) = std::fs::read_to_string(path) {
if content.contains("gw _shell-function") || content.contains("gw-cd") {
println!(
"{}",
console::style("Shell integration is already installed.").green()
);
println!(" Found in: {}\n", path.display());
refresh_shell_cache(shell_name);
println!("\nRestart your shell or run: source {}", path.display());
return;
}
}
}
}
println!("Setup shell integration?\n");
println!(
"This will add the following to {}:",
profile_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "your profile".to_string())
);
println!(
"\n # git-worktree-manager shell integration{}",
if matches!(shell_name, "zsh" | "bash") {
" (gw-cd + tab completion)"
} else {
""
}
);
println!(" {}\n", shell_function_line);
print!("Add to your shell profile? [Y/n]: ");
use std::io::Write;
let _ = std::io::stdout().flush();
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
let input = input.trim().to_lowercase();
if !input.is_empty() && input != "y" && input != "yes" {
println!("\nSetup cancelled.");
return;
}
let Some(ref path) = profile_path else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let comment_suffix = if matches!(shell_name, "zsh" | "bash") {
" (gw-cd + tab completion)"
} else {
""
};
let append = format!(
"\n# git-worktree-manager shell integration{}\n{}\n",
comment_suffix, shell_function_line
);
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
Ok(mut f) => {
let _ = f.write_all(append.as_bytes());
if let Ok(mut cfg) = config::load_config() {
cfg.shell_completion.installed = true;
cfg.shell_completion.prompted = true;
let _ = config::save_config(&cfg);
}
println!("\n* Successfully added to {}", path.display());
refresh_shell_cache(shell_name);
println!("\nNext steps:");
println!(" 1. Restart your shell or run: source {}", path.display());
println!(" 2. Try directory navigation: gw-cd <branch-name>");
println!(" 3. Try tab completion: gw <TAB> or gw new <TAB>");
}
Err(e) => {
println!("\nError: Failed to update {}: {}", path.display(), e);
println!("\nTo install manually, add the lines shown above to your profile");
}
}
}
fn refresh_shell_cache(shell_name: &str) {
let home = constants::home_dir_or_fallback();
let cache_paths = [
home.join(".cache").join("gw-shell-function.zsh"),
home.join(".cache").join("gw-shell-function.bash"),
home.join(".cache").join("gw-shell-function.fish"),
];
let mut refreshed = false;
for cache_path in &cache_paths {
if !cache_path.exists() {
continue;
}
let cache_shell = cache_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if let Some(content) = shell_functions::generate(cache_shell) {
if std::fs::write(cache_path, content).is_ok() {
println!(
" {} {}",
console::style("Refreshed cache:").dim(),
cache_path.display()
);
refreshed = true;
}
}
}
if refreshed {
return;
}
let cache_path = home
.join(".cache")
.join(format!("gw-shell-function.{}", shell_name));
if let Some(content) = shell_functions::generate(shell_name) {
if let Some(cache_dir) = cache_path.parent() {
let _ = std::fs::create_dir_all(cache_dir);
}
if std::fs::write(&cache_path, &content).is_ok() {
println!(
" {} {}",
console::style("Created cache:").dim(),
cache_path.display()
);
}
}
}
fn reject_gw_flags_in_forward(forward_args: &[String]) -> Result<()> {
const GW_PROMPT_FLAGS: &[&str] = &["--prompt", "--prompt-file"];
for arg in forward_args {
if !arg.starts_with("--") {
continue;
}
let head = arg.split_once('=').map(|(h, _)| h).unwrap_or(arg.as_str());
if GW_PROMPT_FLAGS.contains(&head) {
return Err(CwError::Other(format!(
"{head} is a gw option, not an AI tool option — drop the `--` \
separator so gw consumes the flag itself (write `{head} \
<value>` without `--` in front of it)"
)));
}
}
Ok(())
}
fn lift_dash_target(
target: Option<String>,
forward_args: Vec<String>,
) -> (Option<String>, Vec<String>) {
match target {
Some(t) if t.starts_with('-') => {
let mut lifted = Vec::with_capacity(forward_args.len() + 1);
lifted.push(t);
lifted.extend(forward_args);
(None, lifted)
}
other => (other, forward_args),
}
}
#[cfg(test)]
mod tests {
use super::{lift_dash_target, reject_gw_flags_in_forward};
fn forward(args: &[&str]) -> Vec<String> {
args.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn reject_forward_args_passes_when_empty() {
reject_gw_flags_in_forward(&[]).unwrap();
}
#[test]
fn reject_forward_args_passes_ai_tool_flags() {
reject_gw_flags_in_forward(&forward(&["--model", "opus", "--resume"])).unwrap();
reject_gw_flags_in_forward(&forward(&["--print"])).unwrap();
}
#[test]
fn reject_forward_args_rejects_prompt_file_leading() {
let err = reject_gw_flags_in_forward(&forward(&["--prompt-file", "/tmp/p.txt"]))
.expect_err("must reject");
let msg = format!("{err}");
assert!(msg.contains("--prompt-file"), "unexpected msg: {msg}");
assert!(msg.contains("gw option"), "unexpected msg: {msg}");
}
#[test]
fn reject_forward_args_rejects_prompt_leading() {
let err =
reject_gw_flags_in_forward(&forward(&["--prompt", "hi"])).expect_err("must reject");
assert!(format!("{err}").contains("--prompt"));
}
#[test]
fn reject_forward_args_rejects_equals_form() {
let err = reject_gw_flags_in_forward(&forward(&["--prompt-file=/tmp/p.txt"]))
.expect_err("must reject");
assert!(format!("{err}").contains("--prompt-file"));
let err =
reject_gw_flags_in_forward(&forward(&["--prompt=hello"])).expect_err("must reject");
assert!(format!("{err}").contains("--prompt"));
}
#[test]
fn reject_forward_args_rejects_prompt_after_positional() {
let err = reject_gw_flags_in_forward(&forward(&["some-prompt", "--prompt-file", "/tmp/p"]))
.expect_err("must reject");
assert!(format!("{err}").contains("--prompt-file"));
}
#[test]
fn reject_forward_args_rejects_prompt_after_other_flags() {
let err =
reject_gw_flags_in_forward(&forward(&["--model", "opus", "--prompt-file", "/tmp/p"]))
.expect_err("must reject");
assert!(format!("{err}").contains("--prompt-file"));
}
#[test]
fn reject_forward_args_ignores_short_dash_and_bare_dash() {
reject_gw_flags_in_forward(&forward(&["-"])).unwrap();
reject_gw_flags_in_forward(&forward(&["-p", "hello"])).unwrap();
}
#[test]
fn lift_dash_target_lifts_hyphen_target() {
let (target, fwd) = lift_dash_target(
Some("--model".to_string()),
vec!["opus".to_string(), "--resume".to_string()],
);
assert_eq!(target, None);
assert_eq!(fwd, vec!["--model", "opus", "--resume"]);
}
#[test]
fn lift_dash_target_passes_through_normal_target() {
let (target, fwd) = lift_dash_target(
Some("feat-x".to_string()),
vec!["--model".to_string(), "opus".to_string()],
);
assert_eq!(target.as_deref(), Some("feat-x"));
assert_eq!(fwd, vec!["--model", "opus"]);
}
#[test]
fn lift_dash_target_handles_none_target() {
let (target, fwd) = lift_dash_target(None, vec![]);
assert_eq!(target, None);
assert!(fwd.is_empty());
}
#[test]
fn lift_dash_target_lifts_with_no_forward_args() {
let (target, fwd) = lift_dash_target(Some("--model".to_string()), vec![]);
assert_eq!(target, None);
assert_eq!(fwd, vec!["--model"]);
}
}