use crate::config::OnSecrets;
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "git-agent", version, args_conflicts_with_subcommands = true)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[command(flatten)]
pub generate: GenerateArgs,
#[command(flatten)]
pub settings: SettingArgs,
}
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[arg(short, long, conflicts_with = "raw")]
pub commit: bool,
#[arg(short, long, conflicts_with = "raw")]
pub push: bool,
#[arg(short, long)]
pub raw: bool,
#[arg(short, long)]
pub all: bool,
#[arg(short = 'H', long, value_name = "TEXT")]
pub hint: Option<String>,
#[arg(last = true, value_name = "GIT_COMMIT_ARGS")]
pub git_args: Vec<String>,
}
#[derive(Args, Debug, Default)]
pub struct SettingArgs {
#[arg(long, global = true, env = "GIT_AGENT_CONFIG", value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(short, long, global = true, env = "GIT_AGENT_MODEL")]
pub model: Option<String>,
#[arg(
long,
global = true,
env = "GIT_AGENT_CONTEXT_LENGTH",
value_name = "TOKENS"
)]
pub context_length: Option<usize>,
#[arg(
long,
global = true,
env = "GIT_AGENT_MAX_OUTPUT_TOKENS",
value_name = "TOKENS"
)]
pub max_output_tokens: Option<usize>,
#[arg(long, global = true, env = "GIT_AGENT_BASE_URL", value_name = "URL")]
pub base_url: Option<String>,
#[arg(long, global = true, env = "GIT_AGENT_API_KEY", hide_env_values = true)]
pub api_key: Option<String>,
#[arg(short, long, global = true)]
pub temperature: Option<f32>,
#[arg(
long,
global = true,
env = "GIT_AGENT_ON_SECRETS",
value_name = "ACTION",
value_enum
)]
pub on_secrets: Option<OnSecrets>,
}
#[derive(Args, Debug)]
pub struct PrArgs {
#[arg(short, long, value_name = "BRANCH")]
pub base: Option<String>,
#[arg(short, long, conflicts_with = "raw")]
pub draft: bool,
#[arg(short, long, conflicts_with = "raw")]
pub create: bool,
#[arg(short, long)]
pub raw: bool,
#[arg(short = 'H', long, value_name = "TEXT")]
pub hint: Option<String>,
#[arg(last = true, value_name = "GH_ARGS")]
pub gh_args: Vec<String>,
}
#[derive(Args, Debug)]
pub struct SplitArgs {
#[arg(short, long)]
pub all: bool,
#[arg(short, long)]
pub commit: bool,
#[arg(short, long)]
pub push: bool,
#[arg(short = 'H', long, value_name = "TEXT")]
pub hint: Option<String>,
#[arg(last = true, value_name = "GIT_COMMIT_ARGS")]
pub git_args: Vec<String>,
}
#[derive(Args, Debug)]
pub struct BranchArgs {
#[arg(value_name = "DESCRIPTION")]
pub description: Vec<String>,
#[arg(long, value_name = "COMMIT")]
pub from: Option<String>,
#[arg(short, long, conflicts_with = "raw")]
pub create: bool,
#[arg(short, long)]
pub raw: bool,
}
#[derive(Args, Debug)]
pub struct TidyArgs {
#[arg(long, value_name = "COMMIT")]
pub base: Option<String>,
#[arg(long)]
pub apply: bool,
#[arg(short = 'H', long, value_name = "TEXT")]
pub hint: Option<String>,
}
#[derive(Args, Debug)]
pub struct WhyArgs {
#[arg(value_name = "FILE:LINE")]
pub target: String,
#[arg(value_name = "QUESTION")]
pub question: Option<String>,
}
#[derive(Args, Debug)]
pub struct ExplainArgs {
#[arg(value_name = "COMMIT")]
pub target: Option<String>,
#[arg(short, long)]
pub branch: bool,
#[arg(long, value_name = "BRANCH", requires = "branch")]
pub base: Option<String>,
#[arg(long, conflicts_with_all = ["target", "branch"])]
pub pull: bool,
#[arg(short, long, value_name = "TEXT")]
pub question: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Split(SplitArgs),
Pr(PrArgs),
Branch(BranchArgs),
Tidy(TidyArgs),
Why(WhyArgs),
Explain(ExplainArgs),
Models,
Config {
#[command(subcommand)]
command: Option<ConfigCommand>,
},
}
#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
Init {
#[arg(long)]
force: bool,
},
Show,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn passes_trailing_args_to_git_commit() {
let cli = Cli::try_parse_from(["git-agent", "-c", "--", "--no-verify", "-s"]).unwrap();
assert!(cli.generate.commit);
assert_eq!(cli.generate.git_args, ["--no-verify", "-s"]);
}
#[test]
fn push_combines_with_commit_but_not_raw() {
let cli = Cli::try_parse_from(["git-agent", "-cp"]).unwrap();
assert!(cli.generate.commit && cli.generate.push);
assert!(Cli::try_parse_from(["git-agent", "--push", "--raw"]).is_err());
}
#[test]
fn setting_flags_work_with_subcommands() {
let cli = Cli::try_parse_from(["git-agent", "models", "--model", "qwen3"]).unwrap();
assert!(matches!(cli.command, Some(Command::Models)));
assert_eq!(cli.settings.model.as_deref(), Some("qwen3"));
let cli = Cli::try_parse_from(["git-agent", "--context-length", "8192"]).unwrap();
assert_eq!(cli.settings.context_length, Some(8192));
let cli = Cli::try_parse_from(["git-agent", "pr", "--on-secrets", "warn"]).unwrap();
assert_eq!(cli.settings.on_secrets, Some(OnSecrets::Warn));
assert!(Cli::try_parse_from(["git-agent", "--on-secrets", "ignore"]).is_err());
}
#[test]
fn pr_takes_its_own_options_and_settings() {
let cli = Cli::try_parse_from([
"git-agent",
"pr",
"--base",
"develop",
"-d",
"-H",
"fixes #3",
"-m",
"qwen3",
"--",
"--label",
"bug",
])
.unwrap();
let Some(Command::Pr(args)) = cli.command else {
panic!("expected the pr command");
};
assert_eq!(args.base.as_deref(), Some("develop"));
assert!(args.draft && !args.create);
assert_eq!(args.hint.as_deref(), Some("fixes #3"));
assert_eq!(args.gh_args, ["--label", "bug"]);
assert_eq!(cli.settings.model.as_deref(), Some("qwen3"));
assert!(Cli::try_parse_from(["git-agent", "pr", "--raw", "--create"]).is_err());
assert!(Cli::try_parse_from(["git-agent", "--all", "pr"]).is_err());
}
#[test]
fn split_takes_its_own_options() {
let cli =
Cli::try_parse_from(["git-agent", "split", "-a", "-c", "--", "--signoff"]).unwrap();
let Some(Command::Split(args)) = cli.command else {
panic!("expected the split command");
};
assert!(args.all && args.commit && !args.push);
assert_eq!(args.git_args, ["--signoff"]);
}
#[test]
fn branch_takes_a_description_without_quotes() {
let cli = Cli::try_parse_from([
"git-agent",
"branch",
"fix",
"login",
"timeout",
"--from",
"origin/main",
])
.unwrap();
let Some(Command::Branch(args)) = cli.command else {
panic!("expected the branch command");
};
assert_eq!(args.description, ["fix", "login", "timeout"]);
assert_eq!(args.from.as_deref(), Some("origin/main"));
let cli = Cli::try_parse_from(["git-agent", "branch", "-c"]).unwrap();
assert!(
matches!(cli.command, Some(Command::Branch(args)) if args.create && args.description.is_empty())
);
assert!(Cli::try_parse_from(["git-agent", "branch", "-c", "-r", "42"]).is_err());
}
#[test]
fn tidy_takes_a_base_and_applies_without_review() {
let cli =
Cli::try_parse_from(["git-agent", "tidy", "--base", "HEAD~3", "--apply"]).unwrap();
let Some(Command::Tidy(args)) = cli.command else {
panic!("expected the tidy command");
};
assert_eq!(args.base.as_deref(), Some("HEAD~3"));
assert!(args.apply && args.hint.is_none());
}
#[test]
fn why_takes_lines_and_a_question() {
let cli = Cli::try_parse_from(["git-agent", "why", "src/fit.rs:120", "why 400?"]).unwrap();
let Some(Command::Why(args)) = cli.command else {
panic!("expected the why command");
};
assert_eq!(args.target, "src/fit.rs:120");
assert_eq!(args.question.as_deref(), Some("why 400?"));
assert!(Cli::try_parse_from(["git-agent", "why"]).is_err());
}
#[test]
fn explain_takes_a_commit_a_branch_or_the_last_pull() {
let parse = |args: &[&str]| {
let cli = Cli::try_parse_from([&["git-agent", "explain"], args].concat())?;
match cli.command {
Some(Command::Explain(args)) => Ok::<_, clap::Error>(args),
_ => panic!("expected the explain command"),
}
};
let args = parse(&[]).unwrap();
assert!(args.target.is_none() && !args.branch && !args.pull);
let args = parse(&["v1.0..v1.1", "-q", "breaking?"]).unwrap();
assert_eq!(args.target.as_deref(), Some("v1.0..v1.1"));
assert_eq!(args.question.as_deref(), Some("breaking?"));
let args = parse(&["--branch", "feature", "--base", "develop"]).unwrap();
assert!(args.branch);
assert_eq!(args.base.as_deref(), Some("develop"));
assert!(parse(&["--pull"]).unwrap().pull);
assert!(parse(&["--pull", "HEAD~2"]).is_err());
assert!(parse(&["--pull", "--branch"]).is_err());
assert!(parse(&["--base", "main"]).is_err());
}
#[test]
fn config_opens_the_menu_without_a_subcommand() {
let cli = Cli::try_parse_from(["git-agent", "config"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Config { command: None })
));
let cli = Cli::try_parse_from(["git-agent", "config", "show"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Config {
command: Some(ConfigCommand::Show)
})
));
}
}