git-bra 0.4.0

A Git worktree manager with project-aware configuration.
Documentation
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()]
        ));
    }
}