use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum SpecsFormat {
Openspec,
Markdown,
Speckit,
}
impl SpecsFormat {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Openspec => "openspec",
Self::Markdown => "markdown",
Self::Speckit => "speckit",
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "git-paw",
version,
about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
across git worktrees from a single terminal using tmux. Each branch gets its own \
worktree and AI session, running in parallel.",
after_help = "\x1b[1mQuick Start:\x1b[0m\n\n \
# Launch interactive session (picks CLI and branches)\n \
git paw\n\n \
# Use Claude on specific branches\n \
git paw start --cli claude --branches feat/auth,feat/api\n\n \
# Check session status\n \
git paw status\n\n \
# Pause session (detaches client, stops broker, keeps CLIs alive)\n \
git paw pause\n\n \
# Stop session (kills CLIs, preserves worktrees for later)\n \
git paw stop\n\n \
# Remove everything\n \
git paw purge"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(
about = "Launch a new session or reattach to an existing one",
long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
or launches a new interactive session.\n\n\
By default, every existing agent branch is rebased onto the repository's \
default branch (whatever `origin/HEAD` tracks — typically `main`) before \
its worktree is opened, so agents always start from current main. Pass \
`--no-rebase` to skip this step and reproduce the pre-v0.6 behaviour \
(useful when you have local pinned SHAs or are deliberately working off a \
stale baseline). If the rebase hits a conflict, the affected branch is \
left at its pre-rebase HEAD and `git paw start` exits with an error \
listing the conflicting files.\n\n\
Examples:\n \
git paw start\n \
git paw start --cli claude\n \
git paw start --cli claude --branches feat/auth,feat/api\n \
git paw start --from-all-specs\n \
git paw start --from-all-specs --cli claude\n \
git paw start --specs add-auth,fix-session\n \
git paw start --specs # opens spec picker (TTY required)\n \
git paw start --dry-run\n \
git paw start --preset backend\n \
git paw start --supervisor # auto-approve safe prompts via [supervisor.auto_approve]\n \
git paw start --no-supervisor # disable supervisor for this session (overrides config)\n \
git paw start --no-rebase # skip rebasing agent branches onto the default branch"
)]
Start {
#[arg(long, help = "AI CLI to use (skips CLI picker)")]
cli: Option<String>,
#[arg(
long,
value_delimiter = ',',
help = "Comma-separated branches (skips branch picker)"
)]
branches: Option<Vec<String>>,
#[arg(
long,
alias = "from-specs",
help = "Launch from every discovered spec across all configured formats"
)]
from_all_specs: bool,
#[arg(
long,
value_delimiter = ',',
num_args = 0..,
conflicts_with = "from_all_specs",
help = "Comma-separated spec names; bare flag opens picker (TTY required)"
)]
specs: Option<Vec<String>>,
#[arg(
long,
value_enum,
help = "Override spec format (openspec, markdown, speckit)"
)]
specs_format: Option<SpecsFormat>,
#[arg(long, help = "Preview the session plan without executing")]
dry_run: bool,
#[arg(long, help = "Use a named preset from config")]
preset: Option<String>,
#[arg(
long,
default_value_t = false,
help = "Enable supervisor mode for this session"
)]
supervisor: bool,
#[arg(
long,
conflicts_with = "supervisor",
default_value_t = false,
help = "Disable supervisor for this session, overriding any [supervisor] enabled = true in config"
)]
no_supervisor: bool,
#[arg(long, help = "Bypass uncommitted-spec validation warning")]
force: bool,
#[arg(
long,
default_value_t = false,
help = "Skip rebasing existing agent branches onto the default branch before opening worktrees"
)]
no_rebase: bool,
},
#[command(
about = "Pause the session (detaches client, stops broker, leaves CLIs running)",
long_about = "Detaches the tmux client and stops the broker, but leaves all CLI \
processes running in the background. This preserves agent conversation \
state for instant resume via `git paw start`. RAM stays allocated \
(~300 MB per Claude pane).\n\n\
Use pause for short breaks (lunch, meetings, end-of-day). For longer \
breaks, use `git paw stop` to kill the CLIs and release RAM (worktrees \
preserved). A future `git paw hibernate` (v1.0.0) will snapshot state \
to disk.\n\n\
Example:\n git paw pause"
)]
Pause,
#[command(
about = "Stop the session (kills tmux, keeps worktrees and state)",
long_about = "Kills the tmux session and every CLI pane process, but preserves \
worktrees and session state on disk. CLI conversation context is lost. \
Run `git paw start` later to recover the session with fresh CLI \
processes.\n\n\
Three teardown verbs:\n \
pause — soft stop (detach + broker stop; CLIs keep running, RAM held)\n \
stop — kills CLI processes; preserves worktrees on disk (this command)\n \
purge — full reset; removes worktrees, branches, and state\n\n\
`stop` prompts for confirmation in interactive terminals. Use \
`--force` to skip the prompt (scripts) or pipe stdin from \
`/dev/null` for non-interactive contexts.\n\n\
Examples:\n git paw stop\n git paw stop --force"
)]
Stop {
#[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
force: bool,
},
#[command(
about = "Remove everything (tmux session, worktrees, and state)",
long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
session state. Requires confirmation unless --force is used.\n\n\
Examples:\n git paw purge\n git paw purge --force"
)]
Purge {
#[arg(long, help = "Skip confirmation prompt")]
force: bool,
},
#[command(
about = "Show session state for the current repo",
long_about = "Displays the current session status, branches, CLIs, and worktree paths \
for the repository in the current directory.\n\n\
Example:\n git paw status"
)]
Status,
#[command(
about = "List detected and custom AI CLIs",
long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
registered in your config.\n\n\
Example:\n git paw list-clis"
)]
ListClis,
#[command(
about = "Register a custom AI CLI",
long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
The command can be an absolute path or a binary name on PATH.\n\n\
Examples:\n \
git paw add-cli my-agent /usr/local/bin/my-agent\n \
git paw add-cli my-agent my-agent --display-name \"My Agent\""
)]
AddCli {
#[arg(help = "Name to register the CLI as")]
name: String,
#[arg(help = "Command or path to the CLI binary")]
command: String,
#[arg(long, help = "Display name shown in prompts")]
display_name: Option<String>,
},
#[command(
about = "Unregister a custom AI CLI",
long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
removed — auto-detected CLIs cannot.\n\n\
Example:\n git paw remove-cli my-agent"
)]
RemoveCli {
#[arg(help = "Name of the custom CLI to remove")]
name: String,
},
#[command(
about = "Initialize .git-paw/ directory and configuration",
long_about = "Creates the .git-paw/ directory with a default config and sets up \
.gitignore for logs.\n\n\
Examples:\n git paw init"
)]
Init,
#[command(
hide = true,
name = "__dashboard",
about = "Internal: run the broker and dashboard in pane 0",
long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
in pane 0 of a tmux session. Not intended for direct invocation."
)]
Dashboard,
#[command(
about = "View captured session logs",
long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
for clean output. Use --color to view with colors via less -R.\n\n\
Examples:\n \
git paw replay --list\n \
git paw replay feat/add-auth\n \
git paw replay feat/add-auth --color\n \
git paw replay feat/add-auth --session paw-myproject"
)]
Replay {
#[arg(required_unless_present = "list", help = "Branch to replay")]
branch: Option<String>,
#[arg(long, help = "List available log sessions and branches")]
list: bool,
#[arg(long, help = "Display with colors via less -R")]
color: bool,
#[arg(long, help = "Session to replay from (defaults to most recent)")]
session: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> Cli {
let mut full = vec!["git-paw"];
full.extend(args);
Cli::try_parse_from(full).expect("failed to parse")
}
#[test]
fn no_args_defaults_to_none_command() {
let cli = parse(&[]);
assert!(
cli.command.is_none(),
"no args should yield None (handled as Start in main)"
);
}
#[test]
fn start_with_no_flags() {
let cli = parse(&["start"]);
match cli.command.unwrap() {
Command::Start {
cli,
branches,
from_all_specs,
specs,
specs_format,
dry_run,
preset,
supervisor,
no_supervisor,
force,
no_rebase,
} => {
assert!(cli.is_none());
assert!(branches.is_none());
assert!(!from_all_specs);
assert!(specs.is_none());
assert!(specs_format.is_none());
assert!(!dry_run);
assert!(preset.is_none());
assert!(!supervisor);
assert!(!no_supervisor);
assert!(!force);
assert!(!no_rebase);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_cli_flag() {
let cli = parse(&["start", "--cli", "claude"]);
match cli.command.unwrap() {
Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_from_all_specs_sets_flag_and_leaves_specs_unset() {
let cli = parse(&["start", "--from-all-specs"]);
match cli.command.unwrap() {
Command::Start {
from_all_specs,
specs,
..
} => {
assert!(from_all_specs);
assert!(specs.is_none());
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_from_specs_alias_parses_identically_to_from_all_specs() {
let alias_args = parse(&["start", "--from-specs"]);
let canonical_args = parse(&["start", "--from-all-specs"]);
match (alias_args.command.unwrap(), canonical_args.command.unwrap()) {
(
Command::Start {
from_all_specs: a_all,
specs: a_specs,
supervisor: a_sup,
..
},
Command::Start {
from_all_specs: c_all,
specs: c_specs,
supervisor: c_sup,
..
},
) => {
assert_eq!(a_all, c_all);
assert_eq!(a_specs, c_specs);
assert_eq!(a_sup, c_sup);
assert!(a_all);
}
other => panic!("expected two Start variants, got {other:?}"),
}
}
#[test]
fn start_with_bare_specs_yields_empty_vec_picker_mode() {
let cli = parse(&["start", "--specs"]);
match cli.command.unwrap() {
Command::Start {
from_all_specs,
specs,
..
} => {
assert!(!from_all_specs);
assert_eq!(specs, Some(Vec::<String>::new()));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_specs_single_name() {
let cli = parse(&["start", "--specs", "add-auth"]);
match cli.command.unwrap() {
Command::Start { specs, .. } => {
assert_eq!(specs, Some(vec!["add-auth".to_string()]));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_specs_two_comma_separated_names() {
let cli = parse(&["start", "--specs", "add-auth,fix-session"]);
match cli.command.unwrap() {
Command::Start { specs, .. } => {
assert_eq!(
specs,
Some(vec!["add-auth".to_string(), "fix-session".to_string()])
);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_specs_three_comma_separated_names() {
let cli = parse(&["start", "--specs", "add-auth,fix-session,add-logging"]);
match cli.command.unwrap() {
Command::Start { specs, .. } => {
assert_eq!(
specs,
Some(vec![
"add-auth".to_string(),
"fix-session".to_string(),
"add-logging".to_string(),
])
);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_from_all_specs_and_specs_is_rejected() {
let result = Cli::try_parse_from([
"git-paw",
"start",
"--from-all-specs",
"--specs",
"add-auth",
]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("--from-all-specs"), "got: {err}");
assert!(err.contains("--specs"), "got: {err}");
}
#[test]
fn start_with_from_specs_alias_and_specs_is_rejected() {
let result =
Cli::try_parse_from(["git-paw", "start", "--from-specs", "--specs", "add-auth"]);
assert!(result.is_err());
}
#[test]
fn start_with_from_all_specs_and_supervisor_sets_both_flags() {
let cli = parse(&["start", "--from-all-specs", "--supervisor"]);
match cli.command.unwrap() {
Command::Start {
from_all_specs,
specs,
supervisor,
..
} => {
assert!(from_all_specs);
assert!(supervisor);
assert!(specs.is_none());
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_supervisor_only_leaves_spec_mode_unset() {
let cli = parse(&["start", "--supervisor"]);
match cli.command.unwrap() {
Command::Start {
from_all_specs,
specs,
supervisor,
..
} => {
assert!(!from_all_specs);
assert!(specs.is_none());
assert!(supervisor);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_help_contains_from_all_specs_and_specs_but_not_alias() {
let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("--from-all-specs"),
"start --help should contain --from-all-specs; got: {help}"
);
assert!(
help.contains("--specs"),
"start --help should contain --specs; got: {help}"
);
assert!(
!help.contains("--from-specs"),
"start --help should NOT contain hidden alias --from-specs; got: {help}"
);
}
#[test]
fn start_with_branches_flag_comma_separated() {
let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
match cli.command.unwrap() {
Command::Start { branches, .. } => {
let b = branches.expect("branches should be set");
assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_dry_run() {
let cli = parse(&["start", "--dry-run"]);
match cli.command.unwrap() {
Command::Start { dry_run, .. } => assert!(dry_run),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_preset() {
let cli = parse(&["start", "--preset", "backend"]);
match cli.command.unwrap() {
Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_supervisor_flag() {
let cli = parse(&["start", "--supervisor"]);
match cli.command.unwrap() {
Command::Start { supervisor, .. } => assert!(supervisor),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_without_supervisor_defaults_false() {
let cli = parse(&["start", "--cli", "claude"]);
match cli.command.unwrap() {
Command::Start { supervisor, .. } => assert!(!supervisor),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_supervisor_and_other_flags() {
let cli = parse(&[
"start",
"--supervisor",
"--cli",
"claude",
"--branches",
"feat/a,feat/b",
]);
match cli.command.unwrap() {
Command::Start {
supervisor,
cli,
branches,
..
} => {
assert!(supervisor);
assert_eq!(cli.as_deref(), Some("claude"));
assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_specs_format_speckit() {
let cli = parse(&["start", "--from-specs", "--specs-format", "speckit"]);
match cli.command.unwrap() {
Command::Start { specs_format, .. } => {
assert_eq!(specs_format, Some(SpecsFormat::Speckit));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_specs_format_openspec() {
let cli = parse(&["start", "--from-specs", "--specs-format", "openspec"]);
match cli.command.unwrap() {
Command::Start { specs_format, .. } => {
assert_eq!(specs_format, Some(SpecsFormat::Openspec));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_specs_format_markdown() {
let cli = parse(&["start", "--from-specs", "--specs-format", "markdown"]);
match cli.command.unwrap() {
Command::Start { specs_format, .. } => {
assert_eq!(specs_format, Some(SpecsFormat::Markdown));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_rejects_unknown_specs_format() {
let result = Cli::try_parse_from([
"git-paw",
"start",
"--from-specs",
"--specs-format",
"unknown-value",
]);
assert!(result.is_err(), "unknown value should be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("openspec") && err.contains("markdown") && err.contains("speckit"),
"error should list all three valid values, got: {err}"
);
}
#[test]
fn specs_format_as_str_matches_backend_names() {
assert_eq!(SpecsFormat::Openspec.as_str(), "openspec");
assert_eq!(SpecsFormat::Markdown.as_str(), "markdown");
assert_eq!(SpecsFormat::Speckit.as_str(), "speckit");
}
#[test]
fn start_help_shows_specs_format_flag() {
let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("--specs-format"),
"start --help should contain --specs-format"
);
}
#[test]
fn start_help_shows_supervisor_flag() {
let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("--supervisor"),
"start --help should contain --supervisor"
);
}
#[test]
fn start_with_no_supervisor_flag() {
let cli = parse(&["start", "--no-supervisor"]);
match cli.command.unwrap() {
Command::Start {
supervisor,
no_supervisor,
..
} => {
assert!(no_supervisor);
assert!(!supervisor);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_without_flags_leaves_no_supervisor_false() {
let cli = parse(&["start"]);
match cli.command.unwrap() {
Command::Start {
supervisor,
no_supervisor,
..
} => {
assert!(!no_supervisor);
assert!(!supervisor);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_supervisor_and_no_supervisor_is_rejected() {
let result = Cli::try_parse_from(["git-paw", "start", "--supervisor", "--no-supervisor"]);
assert!(
result.is_err(),
"--supervisor + --no-supervisor must be rejected by clap"
);
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("--supervisor") && msg.contains("--no-supervisor"),
"error should mention both flags, got: {msg}"
);
}
#[test]
fn start_with_no_supervisor_and_supervisor_reversed_is_also_rejected() {
let result = Cli::try_parse_from(["git-paw", "start", "--no-supervisor", "--supervisor"]);
assert!(result.is_err());
}
#[test]
fn start_no_supervisor_combines_with_other_flags() {
let cli = parse(&[
"start",
"--no-supervisor",
"--cli",
"claude",
"--branches",
"feat/a,feat/b",
]);
match cli.command.unwrap() {
Command::Start {
no_supervisor,
cli,
branches,
..
} => {
assert!(no_supervisor);
assert_eq!(cli.as_deref(), Some("claude"));
assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_help_shows_no_supervisor_flag() {
let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("--no-supervisor"),
"start --help should contain --no-supervisor, got: {help}"
);
}
#[test]
fn start_with_all_flags() {
let cli = parse(&[
"start",
"--cli",
"gemini",
"--branches",
"a,b",
"--dry-run",
"--preset",
"dev",
]);
match cli.command.unwrap() {
Command::Start {
cli,
branches,
dry_run,
preset,
..
} => {
assert_eq!(cli.as_deref(), Some("gemini"));
assert_eq!(branches.unwrap(), vec!["a", "b"]);
assert!(dry_run);
assert_eq!(preset.as_deref(), Some("dev"));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn pause_parses() {
let cli = parse(&["pause"]);
assert!(matches!(cli.command.unwrap(), Command::Pause));
}
#[test]
fn pause_help_mentions_ram_tradeoff() {
let result = Cli::try_parse_from(["git-paw", "pause", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.to_lowercase().contains("ram"),
"pause --help should mention RAM tradeoff, got: {help}"
);
assert!(
help.contains("stop"),
"pause --help should cross-reference stop, got: {help}"
);
}
#[test]
fn pause_rejects_unknown_flags() {
let result = Cli::try_parse_from(["git-paw", "pause", "--anything"]);
assert!(result.is_err(), "pause should reject unknown flags");
}
#[test]
fn root_help_lists_pause() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("pause"),
"root --help should list pause subcommand, got: {help}"
);
assert!(
help.contains("git paw pause"),
"after_help quick-start should mention `git paw pause`"
);
}
#[test]
fn stop_parses() {
let cli = parse(&["stop"]);
assert!(matches!(
cli.command.unwrap(),
Command::Stop { force: false }
));
}
#[test]
fn stop_without_force() {
let cli = parse(&["stop"]);
match cli.command.unwrap() {
Command::Stop { force } => assert!(!force),
other => panic!("expected Stop, got {other:?}"),
}
}
#[test]
fn stop_with_force() {
let cli = parse(&["stop", "--force"]);
match cli.command.unwrap() {
Command::Stop { force } => assert!(force),
other => panic!("expected Stop, got {other:?}"),
}
}
#[test]
fn stop_help_mentions_pause_and_purge() {
let result = Cli::try_parse_from(["git-paw", "stop", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("pause"),
"stop --help should reference pause, got: {help}"
);
assert!(
help.contains("purge"),
"stop --help should reference purge, got: {help}"
);
assert!(
help.contains("--force"),
"stop --help should list --force, got: {help}"
);
}
#[test]
fn purge_without_force() {
let cli = parse(&["purge"]);
match cli.command.unwrap() {
Command::Purge { force } => assert!(!force),
other => panic!("expected Purge, got {other:?}"),
}
}
#[test]
fn purge_with_force() {
let cli = parse(&["purge", "--force"]);
match cli.command.unwrap() {
Command::Purge { force } => assert!(force),
other => panic!("expected Purge, got {other:?}"),
}
}
#[test]
fn status_parses() {
let cli = parse(&["status"]);
assert!(matches!(cli.command.unwrap(), Command::Status));
}
#[test]
fn list_clis_parses() {
let cli = parse(&["list-clis"]);
assert!(matches!(cli.command.unwrap(), Command::ListClis));
}
#[test]
fn add_cli_with_required_args() {
let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
match cli.command.unwrap() {
Command::AddCli {
name,
command,
display_name,
} => {
assert_eq!(name, "my-agent");
assert_eq!(command, "/usr/local/bin/my-agent");
assert!(display_name.is_none());
}
other => panic!("expected AddCli, got {other:?}"),
}
}
#[test]
fn add_cli_with_display_name() {
let cli = parse(&[
"add-cli",
"my-agent",
"my-agent",
"--display-name",
"My Agent",
]);
match cli.command.unwrap() {
Command::AddCli {
name,
command,
display_name,
} => {
assert_eq!(name, "my-agent");
assert_eq!(command, "my-agent");
assert_eq!(display_name.as_deref(), Some("My Agent"));
}
other => panic!("expected AddCli, got {other:?}"),
}
}
#[test]
fn remove_cli_parses() {
let cli = parse(&["remove-cli", "my-agent"]);
match cli.command.unwrap() {
Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
other => panic!("expected RemoveCli, got {other:?}"),
}
}
#[test]
fn version_flag_is_accepted() {
let result = Cli::try_parse_from(["git-paw", "--version"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn help_flag_is_accepted() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
}
#[test]
fn init_parses() {
let cli = parse(&["init"]);
assert!(matches!(cli.command.unwrap(), Command::Init));
}
#[test]
fn init_help_text() {
let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
}
#[test]
fn unknown_subcommand_is_rejected() {
let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
assert!(result.is_err());
}
#[test]
fn add_cli_missing_required_args_is_rejected() {
let result = Cli::try_parse_from(["git-paw", "add-cli"]);
assert!(result.is_err());
}
#[test]
fn replay_with_branch() {
let cli = parse(&["replay", "feat/add-auth"]);
match cli.command.unwrap() {
Command::Replay {
branch,
list,
color,
session,
} => {
assert_eq!(branch.as_deref(), Some("feat/add-auth"));
assert!(!list);
assert!(!color);
assert!(session.is_none());
}
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_with_list() {
let cli = parse(&["replay", "--list"]);
match cli.command.unwrap() {
Command::Replay { branch, list, .. } => {
assert!(list);
assert!(branch.is_none());
}
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_with_color() {
let cli = parse(&["replay", "feat/add-auth", "--color"]);
match cli.command.unwrap() {
Command::Replay { color, .. } => assert!(color),
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_with_session() {
let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
match cli.command.unwrap() {
Command::Replay { session, .. } => {
assert_eq!(session.as_deref(), Some("paw-myproject"));
}
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_no_args_fails() {
let result = Cli::try_parse_from(["git-paw", "replay"]);
assert!(result.is_err());
}
#[test]
fn replay_help_text() {
let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(help.contains("--list"));
assert!(help.contains("--color"));
assert!(help.contains("--session"));
}
#[test]
fn help_shows_replay_subcommand() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
let err = result.unwrap_err();
let help = err.to_string();
assert!(
help.contains("replay"),
"help should list the replay subcommand"
);
}
#[test]
fn dashboard_parses() {
let cli = parse(&["__dashboard"]);
assert!(matches!(cli.command.unwrap(), Command::Dashboard));
}
#[test]
fn start_with_no_rebase_flag_sets_no_rebase_true() {
let cli = parse(&["start", "--no-rebase"]);
match cli.command.unwrap() {
Command::Start { no_rebase, .. } => assert!(no_rebase),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_without_no_rebase_defaults_to_false() {
let cli = parse(&["start"]);
match cli.command.unwrap() {
Command::Start { no_rebase, .. } => assert!(!no_rebase),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_no_rebase_combines_with_supervisor() {
let cli = parse(&["start", "--no-rebase", "--supervisor"]);
match cli.command.unwrap() {
Command::Start {
no_rebase,
supervisor,
..
} => {
assert!(no_rebase);
assert!(supervisor);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_help_shows_no_rebase_flag() {
let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("--no-rebase"),
"start --help should contain --no-rebase, got: {help}"
);
}
#[test]
fn dashboard_does_not_appear_in_help() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
let err = result.unwrap_err();
let help = err.to_string();
assert!(
!help.contains("__dashboard"),
"hidden __dashboard subcommand should not appear in help output"
);
}
}