use std::ffi::OsString;
use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand};
use clap_complete::Shell;
use crate::cx::Cx;
use crate::error::{Error, Result};
use crate::output::color::ColorChoice;
#[derive(Debug, Parser)]
#[command(
name = "wt",
version = crate::version::long_version(),
about = "Git worktree and GitHub PR manager",
propagate_version = true,
disable_help_subcommand = true
)]
pub(crate) struct Cli {
#[command(flatten)]
pub(crate) global: GlobalFlags,
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
#[derive(Debug, Args)]
pub(crate) struct GlobalFlags {
#[arg(long, global = true)]
pub(crate) json: bool,
#[arg(long, global = true, value_name = "WHEN")]
pub(crate) color: Option<ColorChoice>,
#[arg(long = "no-pager", global = true)]
pub(crate) no_pager: bool,
#[arg(short = 'C', long = "directory", global = true, value_name = "PATH")]
pub(crate) directory: Option<PathBuf>,
#[arg(short = 'v', long = "verbose", global = true, action = clap::ArgAction::Count)]
pub(crate) verbose: u8,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
New(NewArgs),
#[command(visible_alias = "co")]
Checkout(CheckoutArgs),
Sync(SyncArgs),
#[command(visible_alias = "ls")]
List(ListArgs),
#[command(visible_alias = "sw")]
Switch(SwitchArgs),
#[command(visible_alias = "rm")]
Remove(RemoveArgs),
Drop(DropArgs),
Prune(PruneArgs),
#[command(args_conflicts_with_subcommands = true)]
Pr(PrArgs),
Status(StatusArgs),
Path(PathArgs),
Root,
Init(InitArgs),
Config(ConfigArgs),
Completions(CompletionsArgs),
#[command(name = "shell-init")]
ShellInit(ShellInitArgs),
#[command(visible_alias = "tui")]
Ui,
#[command(name = "__complete", hide = true)]
Complete(CompleteArgs),
}
#[derive(Debug, Args)]
pub(crate) struct NewArgs {
pub(crate) branch: String,
#[arg(long, value_name = "REF")]
pub(crate) from: Option<String>,
#[arg(long, value_name = "REF", conflicts_with = "no_track")]
pub(crate) track: Option<String>,
#[arg(long = "no-track")]
pub(crate) no_track: bool,
#[arg(long = "no-switch")]
pub(crate) no_switch: bool,
#[arg(long = "no-hooks")]
pub(crate) no_hooks: bool,
#[arg(long = "copy-from", value_name = "QUERY")]
pub(crate) copy_from: Option<String>,
#[arg(long = "init-submodules", conflicts_with = "no_init_submodules")]
pub(crate) init_submodules: bool,
#[arg(long = "no-init-submodules")]
pub(crate) no_init_submodules: bool,
}
impl NewArgs {
pub(crate) fn submodule_override(&self) -> Option<bool> {
submodule_override(self.init_submodules, self.no_init_submodules)
}
}
#[derive(Debug, Args)]
pub(crate) struct CheckoutArgs {
pub(crate) branch: String,
#[arg(long = "no-switch")]
pub(crate) no_switch: bool,
#[arg(long)]
pub(crate) force: bool,
#[arg(long = "init-submodules", conflicts_with = "no_init_submodules")]
pub(crate) init_submodules: bool,
#[arg(long = "no-init-submodules")]
pub(crate) no_init_submodules: bool,
}
impl CheckoutArgs {
pub(crate) fn submodule_override(&self) -> Option<bool> {
submodule_override(self.init_submodules, self.no_init_submodules)
}
}
#[derive(Debug, Args)]
pub(crate) struct SyncArgs {
pub(crate) query: Option<String>,
#[arg(long)]
pub(crate) all: bool,
#[arg(long = "no-push")]
pub(crate) no_push: bool,
#[arg(long = "init-submodules", conflicts_with = "no_init_submodules")]
pub(crate) init_submodules: bool,
#[arg(long = "no-init-submodules")]
pub(crate) no_init_submodules: bool,
}
impl SyncArgs {
pub(crate) fn submodule_override(&self) -> Option<bool> {
submodule_override(self.init_submodules, self.no_init_submodules)
}
}
fn submodule_override(init: bool, no_init: bool) -> Option<bool> {
match (init, no_init) {
(true, _) => Some(true),
(_, true) => Some(false),
_ => None,
}
}
#[derive(Debug, Args)]
pub(crate) struct ListArgs {
#[arg(long, value_name = "FIELD")]
pub(crate) sort: Option<String>,
#[arg(long, value_name = "QUERY")]
pub(crate) filter: Option<String>,
}
#[derive(Debug, Args)]
pub(crate) struct SwitchArgs {
pub(crate) query: Option<String>,
#[arg(long = "print-path")]
pub(crate) print_path: bool,
}
#[derive(Debug, Args)]
pub(crate) struct RemoveArgs {
pub(crate) query: String,
#[arg(long)]
pub(crate) force: bool,
#[arg(long = "keep-branch")]
pub(crate) keep_branch: bool,
#[arg(long = "no-hooks")]
pub(crate) no_hooks: bool,
}
#[derive(Debug, Args)]
pub(crate) struct DropArgs {
#[arg(long)]
pub(crate) force: bool,
#[arg(long = "no-hooks")]
pub(crate) no_hooks: bool,
}
#[derive(Debug, Args)]
pub(crate) struct PruneArgs {
#[arg(long)]
pub(crate) merged: bool,
#[arg(long)]
pub(crate) gone: bool,
#[arg(long = "dry-run")]
pub(crate) dry_run: bool,
#[arg(long)]
pub(crate) force: bool,
}
#[derive(Debug, Args)]
pub(crate) struct PrArgs {
pub(crate) target: Option<String>,
#[arg(long = "no-switch")]
pub(crate) no_switch: bool,
#[arg(long = "no-hooks")]
pub(crate) no_hooks: bool,
#[command(subcommand)]
pub(crate) sub: Option<PrSub>,
}
#[derive(Debug, Subcommand)]
pub(crate) enum PrSub {
List,
Open(PrOpenArgs),
}
#[derive(Debug, Args)]
pub(crate) struct PrOpenArgs {
#[arg(long)]
pub(crate) title: Option<String>,
#[arg(long, conflicts_with = "body_file")]
pub(crate) body: Option<String>,
#[arg(long = "body-file", conflicts_with = "body")]
pub(crate) body_file: Option<String>,
#[arg(long)]
pub(crate) draft: bool,
#[arg(long)]
pub(crate) ai: bool,
#[arg(long, value_name = "MODEL")]
pub(crate) model: Option<String>,
#[arg(long, value_name = "LEVEL")]
pub(crate) effort: Option<String>,
#[arg(short = 'y', long)]
pub(crate) yes: bool,
#[arg(long, value_name = "REF")]
pub(crate) base: Option<String>,
#[arg(long, conflicts_with = "new")]
pub(crate) update: bool,
#[arg(long = "new", conflicts_with = "update")]
pub(crate) new: bool,
}
#[derive(Debug, Args)]
pub(crate) struct StatusArgs {
pub(crate) query: Option<String>,
#[arg(long)]
pub(crate) all: bool,
}
#[derive(Debug, Args)]
pub(crate) struct PathArgs {
pub(crate) query: String,
}
#[derive(Debug, Args)]
pub(crate) struct InitArgs {
#[arg(long = "path-template", value_name = "TMPL")]
pub(crate) path_template: Option<String>,
}
#[derive(Debug, Args)]
pub(crate) struct ConfigArgs {
#[command(subcommand)]
pub(crate) action: ConfigAction,
#[arg(long, global = true)]
pub(crate) global: bool,
}
#[derive(Debug, Subcommand)]
pub(crate) enum ConfigAction {
Get {
key: String,
},
Set {
key: String,
value: String,
},
List,
Edit,
}
#[derive(Debug, Args)]
pub(crate) struct CompletionsArgs {
pub(crate) shell: Shell,
}
#[derive(Debug, Args)]
pub(crate) struct ShellInitArgs {
pub(crate) shell: Shell,
}
#[derive(Debug, Args)]
pub(crate) struct CompleteArgs {
pub(crate) kind: String,
pub(crate) partial: Option<String>,
}
impl Cli {
fn command_supports_json(&self) -> bool {
match &self.command {
Some(
Command::New(_)
| Command::Checkout(_)
| Command::Sync(_)
| Command::List(_)
| Command::Remove(_)
| Command::Drop(_)
| Command::Prune(_)
| Command::Pr(_)
| Command::Status(_),
) => true,
Some(Command::Config(c)) => matches!(c.action, ConfigAction::List),
_ => false,
}
}
fn command_label(&self) -> &'static str {
match &self.command {
Some(Command::New(_)) => "new",
Some(Command::Checkout(_)) => "checkout",
Some(Command::Sync(_)) => "sync",
Some(Command::List(_)) => "list",
Some(Command::Switch(_)) => "switch",
Some(Command::Remove(_)) => "remove",
Some(Command::Drop(_)) => "drop",
Some(Command::Prune(_)) => "prune",
Some(Command::Pr(_)) => "pr",
Some(Command::Status(_)) => "status",
Some(Command::Path(_)) => "path",
Some(Command::Root) => "root",
Some(Command::Init(_)) => "init",
Some(Command::Config(_)) => "config",
Some(Command::Completions(_)) => "completions",
Some(Command::ShellInit(_)) => "shell-init",
Some(Command::Ui) | None => "ui",
Some(Command::Complete(_)) => "__complete",
}
}
}
pub(crate) fn dispatch(args: Vec<String>, cx: &mut Cx) -> Result<u8> {
let mut argv: Vec<OsString> = Vec::with_capacity(args.len() + 1);
argv.push(OsString::from("wt"));
argv.extend(args.into_iter().map(OsString::from));
let cli = match Cli::try_parse_from(argv) {
Ok(cli) => cli,
Err(e) => return report_parse_error(&e, cx),
};
if let Some(dir) = cli.global.directory.clone() {
apply_directory(cx, &dir);
}
cx.color_flag = cli.global.color;
cx.no_pager = cli.global.no_pager;
cx.verbose = cli.global.verbose;
if cli.global.json && !cli.command_supports_json() {
return Err(Error::usage(format!(
"--json is not supported by `wt {}`",
cli.command_label()
)));
}
route(cli, cx)
}
fn report_parse_error(error: &clap::Error, cx: &mut Cx) -> Result<u8> {
use clap::error::ErrorKind;
let rendered = error.render().to_string();
match error.kind() {
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => {
cx.out.text(&rendered)?;
Ok(0)
}
_ => {
cx.err.text(&rendered)?;
Ok(2)
}
}
}
fn apply_directory(cx: &mut Cx, dir: &Path) {
cx.cwd = if dir.is_absolute() {
dir.to_path_buf()
} else {
cx.cwd.join(dir)
};
}
fn route(cli: Cli, cx: &mut Cx) -> Result<u8> {
let json = cli.global.json;
match cli.command {
None | Some(Command::Ui) => crate::commands::switch::launch_picker(cx),
Some(Command::New(args)) => {
crate::commands::new::run(cx, &crate::hooks::RealHookRunner, &args, json)
}
Some(Command::Checkout(args)) => crate::commands::checkout::run(cx, &args, json),
Some(Command::Sync(args)) => crate::commands::sync::run(cx, &args, json),
Some(Command::List(args)) => crate::commands::list::run(cx, &args, json),
Some(Command::Switch(args)) => crate::commands::switch::run(cx, &args),
Some(Command::Remove(args)) => {
crate::commands::remove::run(cx, &crate::hooks::RealHookRunner, &args, json)
}
Some(Command::Drop(args)) => {
crate::commands::drop::run(cx, &crate::hooks::RealHookRunner, &args, json)
}
Some(Command::Prune(args)) => crate::commands::prune::run(cx, &args, json),
Some(Command::Pr(args)) => {
crate::commands::pr::run(cx, &crate::hooks::RealHookRunner, &args, json)
}
Some(Command::Status(args)) => crate::commands::status_cmd::run(cx, &args, json),
Some(Command::Path(args)) => crate::commands::path::run(cx, &args),
Some(Command::Root) => crate::commands::root::run(cx),
Some(Command::Init(args)) => crate::commands::init::run(cx, &args),
Some(Command::Config(args)) => crate::commands::config_cmd::run(cx, &args, json),
Some(Command::Completions(args)) => crate::commands::completions::run(cx, &args),
Some(Command::ShellInit(args)) => crate::commands::shell_init::run(cx, &args),
Some(Command::Complete(args)) => crate::commands::complete::run(cx, &args),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::test_cx;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_string()).collect()
}
fn parse(parts: &[&str]) -> std::result::Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("wt").chain(parts.iter().copied()))
}
#[test]
fn help_renders_to_stdout_exit_zero() {
let mut t = test_cx(&[], "/tmp");
let code = dispatch(argv(&["--help"]), &mut t.cx);
assert_eq!(code.unwrap(), 0);
assert!(t.out.contents().contains("Usage"));
assert!(t.err.contents().is_empty());
}
#[test]
fn version_renders_to_stdout_exit_zero() {
let mut t = test_cx(&[], "/tmp");
let code = dispatch(argv(&["--version"]), &mut t.cx).unwrap();
assert_eq!(code, 0);
assert!(t.out.contents().contains("wt"));
}
#[test]
fn unknown_command_is_usage_error_exit_two() {
let mut t = test_cx(&[], "/tmp");
let code = dispatch(argv(&["bogus"]), &mut t.cx).unwrap();
assert_eq!(code, 2);
assert!(!t.err.contents().is_empty());
assert!(t.out.contents().is_empty());
}
#[test]
fn missing_required_arg_is_usage_error() {
let mut t = test_cx(&[], "/tmp");
assert_eq!(dispatch(argv(&["new"]), &mut t.cx).unwrap(), 2);
}
#[test]
fn aliases_resolve() {
assert!(matches!(
parse(&["ls"]).unwrap().command,
Some(Command::List(_))
));
assert!(matches!(
parse(&["sw", "q"]).unwrap().command,
Some(Command::Switch(_))
));
assert!(matches!(
parse(&["rm", "q"]).unwrap().command,
Some(Command::Remove(_))
));
assert!(matches!(
parse(&["co", "branch"]).unwrap().command,
Some(Command::Checkout(_))
));
assert!(matches!(
parse(&["tui"]).unwrap().command,
Some(Command::Ui)
));
}
#[test]
fn checkout_parses_with_flags() {
let cli = parse(&["checkout", "feature/x", "--no-switch", "--force"]).unwrap();
match cli.command {
Some(Command::Checkout(a)) => {
assert_eq!(a.branch, "feature/x");
assert!(a.no_switch);
assert!(a.force);
}
_ => panic!("expected checkout"),
}
let mut t = test_cx(&[], "/tmp");
assert_eq!(dispatch(argv(&["checkout"]), &mut t.cx).unwrap(), 2);
}
#[test]
fn sync_parses_with_flags() {
let cli = parse(&["sync", "feature/x", "--all", "--no-push"]).unwrap();
match cli.command {
Some(Command::Sync(a)) => {
assert_eq!(a.query.as_deref(), Some("feature/x"));
assert!(a.all);
assert!(a.no_push);
}
_ => panic!("expected sync"),
}
match parse(&["sync"]).unwrap().command {
Some(Command::Sync(a)) => {
assert!(a.query.is_none());
assert!(!a.all && !a.no_push);
}
_ => panic!("expected sync"),
}
assert!(parse(&["sync", "--init-submodules", "--no-init-submodules"]).is_err());
}
#[test]
fn no_subcommand_is_tui() {
assert!(parse(&[]).unwrap().command.is_none());
}
#[test]
fn drop_parses_with_flags_and_takes_no_query() {
let cli = parse(&["drop", "--force", "--no-hooks"]).unwrap();
match cli.command {
Some(Command::Drop(a)) => {
assert!(a.force);
assert!(a.no_hooks);
}
_ => panic!("expected drop"),
}
assert!(matches!(
parse(&["drop"]).unwrap().command,
Some(Command::Drop(_))
));
assert!(parse(&["drop", "somequery"]).is_err());
}
#[test]
fn pr_forms_parse_distinctly() {
let cli = parse(&["pr", "list"]).unwrap();
match cli.command {
Some(Command::Pr(a)) => {
assert!(a.target.is_none());
assert!(matches!(a.sub, Some(PrSub::List)));
}
_ => panic!("expected pr"),
}
let cli = parse(&["pr", "123"]).unwrap();
match cli.command {
Some(Command::Pr(a)) => {
assert_eq!(a.target.as_deref(), Some("123"));
assert!(a.sub.is_none());
}
_ => panic!("expected pr"),
}
let cli = parse(&["pr"]).unwrap();
match cli.command {
Some(Command::Pr(a)) => {
assert!(a.target.is_none() && a.sub.is_none());
}
_ => panic!("expected pr"),
}
let cli = parse(&[
"pr", "open", "--title", "X", "--draft", "--ai", "--model", "opus", "--effort", "high",
])
.unwrap();
match cli.command {
Some(Command::Pr(a)) => match a.sub {
Some(PrSub::Open(o)) => {
assert_eq!(o.title.as_deref(), Some("X"));
assert!(o.draft);
assert!(o.ai);
assert_eq!(o.model.as_deref(), Some("opus"));
assert_eq!(o.effort.as_deref(), Some("high"));
assert!(!o.update && !o.new);
}
_ => panic!("expected pr open"),
},
_ => panic!("expected pr"),
}
assert!(parse(&["pr", "open", "--update", "--new"]).is_err());
assert!(parse(&["pr", "open", "--body", "b", "--body-file", "f"]).is_err());
}
#[test]
fn global_flags_parse_before_and_after_subcommand() {
assert!(parse(&["--json", "list"]).unwrap().global.json);
assert!(parse(&["list", "--json"]).unwrap().global.json);
let cli = parse(&["-C", "/repo", "status"]).unwrap();
assert_eq!(cli.global.directory.as_deref(), Some(Path::new("/repo")));
assert_eq!(parse(&["-vv", "list"]).unwrap().global.verbose, 2);
assert_eq!(
parse(&["--color", "never", "list"]).unwrap().global.color,
Some(ColorChoice::Never)
);
}
#[test]
fn json_rejected_for_unsupported_commands() {
for cmd in [
vec!["switch", "q"],
vec!["path", "q"],
vec!["root"],
vec!["init"],
vec!["completions", "bash"],
vec!["shell-init", "bash"],
vec!["ui"],
] {
let mut t = test_cx(&[], "/tmp");
let mut a = vec!["--json"];
a.extend(cmd.iter().copied());
let code = crate::run(argv(&a), &mut t.cx);
assert_eq!(code, 2, "expected --json rejected for {cmd:?}");
assert!(t.err.contents().contains("--json is not supported"));
}
}
#[test]
fn json_accepted_for_supported_commands() {
for cmd in [
vec!["list"],
vec!["status"],
vec!["new", "b"],
vec!["checkout", "b"],
vec!["sync"],
vec!["remove", "q"],
vec!["drop"],
vec!["prune", "--merged"],
vec!["pr", "list"],
vec!["config", "list"],
] {
let mut t = test_cx(&[], "/tmp");
let mut a = vec!["--json"];
a.extend(cmd.iter().copied());
let err = dispatch(argv(&a), &mut t.cx).unwrap_err();
assert!(
!matches!(err, Error::Usage(_)),
"expected --json accepted for {cmd:?}, got usage error {err:?}"
);
}
}
#[test]
fn config_get_rejects_json_but_list_accepts() {
let mut t = test_cx(&[], "/tmp");
assert_eq!(
crate::run(argv(&["--json", "config", "get", "k"]), &mut t.cx),
2
);
let mut t2 = test_cx(&[], "/tmp");
let err = dispatch(argv(&["--json", "config", "list"]), &mut t2.cx).unwrap_err();
assert!(!matches!(err, Error::Usage(_)));
}
#[test]
fn directory_flag_updates_cwd() {
let mut t = test_cx(&[], "/start");
let _ = dispatch(argv(&["-C", "sub", "root"]), &mut t.cx);
assert_eq!(t.cx.cwd, PathBuf::from("/start/sub"));
let mut t = test_cx(&[], "/start");
let _ = dispatch(argv(&["-C", "/abs", "root"]), &mut t.cx);
assert_eq!(t.cx.cwd, PathBuf::from("/abs"));
}
#[test]
fn repo_scoped_commands_fail_outside_a_repo() {
for parts in [vec!["switch"], vec!["ui"], vec![]] {
let mut t = test_cx(&[], "/tmp");
let err = dispatch(argv(&parts), &mut t.cx).unwrap_err();
assert!(matches!(err, Error::NotInRepo), "for {parts:?}");
}
}
}