use std::path::PathBuf;
use clap::{
Args, ColorChoice, Parser, Subcommand,
builder::{
Styles,
styling::{AnsiColor, Effects, Styles as ClapStyles},
},
};
#[derive(Debug, Parser)]
#[command(
name = "bra",
version,
about = "Manage Git worktrees by project and branch",
long_about = "Create, initialize, inspect, and navigate project worktrees with project-aware scripts and path helpers.",
color = ColorChoice::Auto,
styles = cli_styles(),
after_help = "Unknown subcommands are treated as script names and forwarded to 'script run' with additional arguments"
)]
pub struct Cli {
#[arg(
long,
global = true,
value_name = "ALIAS_OR_PATH",
help = "Use a project alias or a path to a cloned repository"
)]
pub project: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(about = "Fetch, reset, and run scripts in the current repo or worktree")]
Init,
#[command(about = "Create or reuse a worktree for a branch and print its path")]
Open {
#[arg(long, value_name = "BRANCH", help = "Base branch to create from (short name, no origin/ prefix)")]
from: Option<String>,
#[arg(help = "Branch name to open; defaults to a generated name")]
branch: Option<String>,
},
#[command(about = "Remove a worktree, optionally deleting its local branch")]
Close {
#[arg(help = "Branch name to close; defaults to the current branch")]
branch: Option<String>,
#[arg(long, help = "Delete the local branch after removing the worktree")]
delete_branch: bool,
},
#[command(about = "Prune stale worktree metadata and optionally stale branches")]
Prune {
#[arg(long, help = "Delete local branches already merged into the current HEAD")]
merged: bool,
},
#[command(about = "Print the path for a branch without creating a worktree")]
Go {
#[arg(help = "Branch name to resolve")]
branch: String,
},
#[command(about = "List worktrees for the current repository")]
List,
#[command(about = "Manage project scripts")]
Script {
#[command(subcommand)]
command: ScriptCommand,
},
#[command(about = "Manage bra configuration")]
Config {
#[command(subcommand)]
command: ConfigCommand,
},
#[command(external_subcommand)]
External(Vec<String>),
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
#[command(about = "Create the config file with defaults if needed")]
Init,
#[command(about = "Show the config file path")]
Path,
#[command(about = "Show the current config file contents")]
Show,
}
#[derive(Debug, Subcommand)]
pub enum ScriptCommand {
#[command(about = "Add a named script for a project")]
Add(ScriptAddArgs),
#[command(about = "List scripts for the current project")]
List,
#[command(about = "List scripts for all configured projects")]
All,
#[command(about = "Run a named script for the current project")]
Run {
#[arg(help = "Script name to run")]
name: String,
#[arg(last = true, help = "Additional arguments to pass to the script")]
args: Vec<String>,
},
#[command(about = "Remove a named script from the current project")]
Remove {
#[arg(help = "Script name to remove")]
name: String,
},
}
#[derive(Debug, Args)]
pub struct ScriptAddArgs {
#[arg(help = "Unique script name within the project")]
pub name: String,
#[arg(help = "Path to the script file")]
pub path: Option<PathBuf>,
#[arg(long, num_args(0..=1), help = "Inline script text (reads from stdin if no value given)")]
pub text: Option<Option<String>>,
}
fn cli_styles() -> Styles {
ClapStyles::styled()
.header(AnsiColor::Green.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Cyan.on_default())
.placeholder(AnsiColor::Blue.on_default())
.valid(AnsiColor::Green.on_default())
.invalid(AnsiColor::Yellow.on_default())
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[test]
fn parses_global_project_before_subcommand() {
let cli = Cli::parse_from(["bra", "--project", "my-project", "list"]);
assert_eq!(cli.project.as_deref(), Some("my-project"));
assert!(matches!(cli.command, Command::List));
}
#[test]
fn parses_open_subcommand() {
let cli = Cli::parse_from(["bra", "--project", "my-project", "open", "feature/test"]);
assert_eq!(cli.project.as_deref(), Some("my-project"));
assert!(
matches!(cli.command, Command::Open { branch, from } if branch.as_deref() == Some("feature/test") && from.is_none())
);
}
#[test]
fn parses_open_from_subcommand() {
let cli = Cli::parse_from(["bra", "open", "--from", "develop", "feature/test"]);
assert!(
matches!(cli.command, Command::Open { branch, from } if branch.as_deref() == Some("feature/test") && from.as_deref() == Some("develop"))
);
}
#[test]
fn parses_open_subcommand_without_branch() {
let cli = Cli::parse_from(["bra", "open"]);
assert!(matches!(cli.command, Command::Open { branch, from } if branch.is_none() && from.is_none()));
}
#[test]
fn parses_close_subcommand() {
let cli = Cli::parse_from(["bra", "close", "feature/test", "--delete-branch"]);
assert!(matches!(
cli.command,
Command::Close { ref branch, delete_branch }
if branch.as_deref() == Some("feature/test") && delete_branch
));
}
#[test]
fn parses_prune_subcommand() {
let cli = Cli::parse_from(["bra", "prune", "--merged"]);
assert!(matches!(cli.command, Command::Prune { merged: true }));
}
#[test]
fn parses_external_subcommand() {
let cli = Cli::parse_from(["bra", "build"]);
assert!(matches!(
cli.command,
Command::External(ref args) if args == &["build".to_string()]
));
}
#[test]
fn parses_external_subcommand_with_args() {
let cli = Cli::parse_from(["bra", "build", "--release", "--target", "x86_64"]);
assert!(matches!(
cli.command,
Command::External(ref args) if args == &["build".to_string(), "--release".to_string(), "--target".to_string(), "x86_64".to_string()]
));
}
#[test]
fn parses_script_run_with_args() {
let cli = Cli::parse_from(["bra", "script", "run", "build", "--", "--release"]);
assert!(matches!(
cli.command,
Command::Script { command: ScriptCommand::Run { ref name, ref args } }
if name == "build" && args == &["--release".to_string()]
));
}
}