Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Specifications

git-paw uses OpenSpec for formal, testable specifications. Each capability has a dedicated spec file using RFC 2119 keywords (SHALL, MUST, SHOULD) and GIVEN/WHEN/THEN scenarios.

Specification Index

CapabilityDescription
CLI ParsingCommand-line argument parsing and subcommands
CLI DetectionAuto-detect AI CLIs on PATH, load custom CLIs
Git OperationsValidate repos, list branches, manage worktrees
Tmux OrchestrationCreate sessions, manage panes, apply layout
Session StatePersist and recover session state
ConfigurationParse and merge TOML config files
Interactive SelectionUser prompts for mode, branch, and CLI selection
Error HandlingUnified error types with exit codes

CLI Parsing

Purpose

Define the command-line interface for git-paw using clap v4. Declares all subcommands (start, stop, purge, status, list-clis, add-cli, remove-cli), their flags, and argument validation. When no subcommand is given, defaults to start.

Requirements

Requirement: Default to start when no subcommand is given

The system SHALL treat no arguments as equivalent to start with no flags.

Scenario: No arguments yields None command

  • GIVEN no arguments are passed
  • WHEN the CLI is parsed
  • THEN command SHALL be None (handled as Start in main)

Test: cli::tests::no_args_defaults_to_none_command

Requirement: Start subcommand with optional flags

The start subcommand SHALL accept --cli, --branches (comma-separated), --dry-run, and --preset flags, all optional.

Scenario: Start with no flags

  • GIVEN start is passed with no flags
  • WHEN the CLI is parsed
  • THEN all optional fields SHALL be None / false

Test: cli::tests::start_with_no_flags

Scenario: Start with –cli flag

  • GIVEN start --cli claude
  • WHEN the CLI is parsed
  • THEN cli SHALL be Some("claude")

Test: cli::tests::start_with_cli_flag

Scenario: Start with comma-separated –branches flag

  • GIVEN start --branches feat/a,feat/b,fix/c
  • WHEN the CLI is parsed
  • THEN branches SHALL be ["feat/a", "feat/b", "fix/c"]

Test: cli::tests::start_with_branches_flag_comma_separated

Scenario: Start with –dry-run flag

  • GIVEN start --dry-run
  • WHEN the CLI is parsed
  • THEN dry_run SHALL be true

Test: cli::tests::start_with_dry_run

Scenario: Start with –preset flag

  • GIVEN start --preset backend
  • WHEN the CLI is parsed
  • THEN preset SHALL be Some("backend")

Test: cli::tests::start_with_preset

Scenario: Start with all flags combined

  • GIVEN start --cli gemini --branches a,b --dry-run --preset dev
  • WHEN the CLI is parsed
  • THEN all fields SHALL be populated correctly

Test: cli::tests::start_with_all_flags

Requirement: Stop subcommand

The stop subcommand SHALL parse with no additional arguments.

Scenario: Stop parses

  • GIVEN stop is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Stop

Test: cli::tests::stop_parses

Requirement: Purge subcommand with optional –force flag

The purge subcommand SHALL accept an optional --force flag (defaults to false).

Scenario: Purge without –force

  • GIVEN purge is passed without flags
  • WHEN the CLI is parsed
  • THEN force SHALL be false

Test: cli::tests::purge_without_force

Scenario: Purge with –force

  • GIVEN purge --force is passed
  • WHEN the CLI is parsed
  • THEN force SHALL be true

Test: cli::tests::purge_with_force

Requirement: Status subcommand

The status subcommand SHALL parse with no additional arguments.

Scenario: Status parses

  • GIVEN status is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Status

Test: cli::tests::status_parses

Requirement: List-CLIs subcommand

The list-clis subcommand SHALL parse with no additional arguments.

Scenario: List-CLIs parses

  • GIVEN list-clis is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::ListClis

Test: cli::tests::list_clis_parses

Requirement: Add-CLI subcommand with required and optional arguments

The add-cli subcommand SHALL require name and command positional arguments and accept an optional --display-name flag.

Scenario: Add-CLI with required arguments only

  • GIVEN add-cli my-agent /usr/local/bin/my-agent
  • WHEN the CLI is parsed
  • THEN name SHALL be "my-agent", command SHALL be the path, and display_name SHALL be None

Test: cli::tests::add_cli_with_required_args

Scenario: Add-CLI with –display-name

  • GIVEN add-cli my-agent my-agent --display-name "My Agent"
  • WHEN the CLI is parsed
  • THEN display_name SHALL be Some("My Agent")

Test: cli::tests::add_cli_with_display_name

Scenario: Add-CLI missing required arguments is rejected

  • GIVEN add-cli with no positional arguments
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail

Test: cli::tests::add_cli_missing_required_args_is_rejected

Requirement: Remove-CLI subcommand with required argument

The remove-cli subcommand SHALL require a name positional argument.

Scenario: Remove-CLI parses

  • GIVEN remove-cli my-agent
  • WHEN the CLI is parsed
  • THEN name SHALL be "my-agent"

Test: cli::tests::remove_cli_parses

Requirement: Standard flags –version and –help

The CLI SHALL accept --version and --help flags.

Scenario: –version flag is accepted

  • GIVEN --version is passed
  • WHEN the CLI is parsed
  • THEN clap SHALL emit a DisplayVersion response

Test: cli::tests::version_flag_is_accepted

Scenario: –help flag is accepted

  • GIVEN --help is passed
  • WHEN the CLI is parsed
  • THEN clap SHALL emit a DisplayHelp response

Test: cli::tests::help_flag_is_accepted

Requirement: Unknown subcommands are rejected

The CLI SHALL reject unrecognized subcommands with a parse error.

Scenario: Unknown subcommand fails

  • GIVEN an unrecognized subcommand is passed
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail

Test: cli::tests::unknown_subcommand_is_rejected

Requirement: Help output contains all subcommands and quick start

The --help output SHALL list all subcommands and include a Quick Start section.

Scenario: Help lists all subcommands

  • GIVEN --help is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain start, stop, purge, status, list-clis, add-cli, and remove-cli

Test: cli_tests::help_shows_all_subcommands

Scenario: Help contains Quick Start

  • GIVEN --help is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain “Quick Start”

Test: cli_tests::help_contains_quick_start

Scenario: Start help shows all flags

  • GIVEN start --help is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain –cli, –branches, –dry-run, and –preset

Test: cli_tests::start_help_shows_flags

Scenario: Purge help shows –force flag

  • GIVEN purge --help is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain –force

Test: cli_tests::purge_help_shows_force_flag

Scenario: Add-CLI help shows arguments

  • GIVEN add-cli --help is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain –display-name, name, and command arguments

Test: cli_tests::add_cli_help_shows_arguments

Requirement: Version output includes binary name

The --version output SHALL include the binary name.

Scenario: Version output

  • GIVEN --version is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain “git-paw”

Test: cli_tests::version_output

Requirement: No arguments behaves like start

When no subcommand is provided, the binary SHALL behave identically to start.

Scenario: No args produces same error as start

  • GIVEN the binary is run with no arguments outside a git repo
  • WHEN both git-paw and git-paw start are run
  • THEN they SHALL produce identical stderr output

Test: cli_tests::no_args_behaves_like_start

Requirement: Subcommands run without error when applicable

Subcommands that don’t require a session SHALL succeed in a valid git repo.

Scenario: Stop runs without error

  • GIVEN the binary is run in a git repo
  • WHEN stop is passed
  • THEN it SHALL succeed

Test: cli_tests::stop_runs_without_error

Scenario: Status runs without error

  • GIVEN the binary is run in a git repo
  • WHEN status is passed
  • THEN it SHALL succeed

Test: cli_tests::status_runs_without_error

Scenario: List-CLIs runs without error

  • GIVEN the binary is run in a git repo
  • WHEN list-clis is passed
  • THEN it SHALL succeed

Test: cli_tests::list_clis_runs_without_error

Requirement: Binary rejects missing required arguments

Subcommands with required arguments SHALL fail when they are missing.

Scenario: Add-CLI requires arguments

  • GIVEN add-cli is passed with no arguments
  • WHEN the binary runs
  • THEN it SHALL fail with stderr mentioning “required”

Test: cli_tests::add_cli_requires_arguments

Scenario: Remove-CLI requires argument

  • GIVEN remove-cli is passed with no arguments
  • WHEN the binary runs
  • THEN it SHALL fail with stderr mentioning “required”

Test: cli_tests::remove_cli_requires_argument

Requirement: Not-a-repo error from binary

Commands requiring a git repo SHALL fail with an actionable error when run outside one.

Scenario: Start from non-git directory

  • GIVEN the binary is run outside a git repository
  • WHEN start is passed
  • THEN it SHALL fail with stderr containing “Not a git repository”

Test: cli_tests::start_from_non_git_dir

Scenario: Unknown subcommand from binary

  • GIVEN the binary is passed an unrecognized subcommand
  • WHEN it runs
  • THEN it SHALL fail with stderr containing “error”

Test: cli_tests::unknown_subcommand_fails


CLI Detection

Purpose

Detect available AI coding CLI binaries by scanning PATH for known names and merging with user-defined custom CLIs from configuration. Provides a unified, deduplicated, sorted list for interactive selection or direct use.

Requirements

Requirement: Auto-detect known AI CLIs on PATH

The system SHALL scan PATH for the known CLI binaries: claude, codex, gemini, aider, vibe, qwen, and amp.

Scenario: All known CLIs are present on PATH

  • GIVEN all 8 known CLI binaries exist on PATH
  • WHEN detect_known_clis() is called
  • THEN it SHALL return a CliInfo for each binary with source = Detected, a non-empty display_name, and a valid path

Test: detect::tests::all_known_clis_detected_when_present

Scenario: No known CLIs are present on PATH

  • GIVEN PATH contains no known CLI binaries
  • WHEN detect_known_clis() is called
  • THEN it SHALL return an empty list

Test: detect::tests::returns_empty_when_no_known_clis_on_path

Scenario: Partial set of CLIs on PATH

  • GIVEN only a subset of known CLIs exist on PATH
  • WHEN detect_known_clis() is called
  • THEN it SHALL return only the CLIs that are found

Test: detect::tests::detects_subset_of_known_clis

Requirement: Resolve and merge custom CLIs from configuration

The system SHALL resolve custom CLI definitions by looking up commands as absolute paths or via PATH, and merge them with auto-detected CLIs.

Scenario: Custom CLIs merged with detected CLIs

  • GIVEN auto-detected CLIs exist and custom CLI definitions are provided
  • WHEN detect_clis() is called
  • THEN the result SHALL contain both detected and custom CLIs

Test: detect::tests::custom_clis_merged_with_detected

Scenario: Custom CLI binary not found

  • GIVEN a custom CLI definition references a non-existent binary
  • WHEN detect_clis() is called
  • THEN the missing CLI SHALL be excluded and a warning printed to stderr

Test: detect::tests::custom_cli_excluded_when_binary_missing

Scenario: Custom CLI resolved by absolute path

  • GIVEN a custom CLI definition uses an absolute path to an existing binary
  • WHEN resolve_custom_clis() is called
  • THEN the resolved path SHALL match the absolute path provided

Test: detect::tests::custom_cli_resolved_by_absolute_path

Requirement: Custom CLIs override detected CLIs with the same name

When a custom CLI has the same binary_name as a detected CLI, the custom definition SHALL take precedence.

Scenario: Custom CLI overrides auto-detected CLI

  • GIVEN a custom CLI shares a binary_name with an auto-detected CLI
  • WHEN detect_clis() is called
  • THEN the result SHALL contain only the custom version with source = Custom

Test: detect::tests::custom_cli_overrides_detected_with_same_binary_name

Requirement: Each CLI result includes all required fields

Every CliInfo SHALL have a non-empty display_name, binary_name, a valid path, and a source indicator.

Scenario: Detected CLI has all fields populated

  • GIVEN a known CLI binary exists on PATH
  • WHEN it is detected
  • THEN all fields (display_name, binary_name, path, source) SHALL be populated

Test: detect::tests::detected_cli_has_all_fields

Scenario: Custom CLI has all fields populated

  • GIVEN a custom CLI definition is resolved
  • WHEN it is included in results
  • THEN all fields SHALL be populated

Test: detect::tests::custom_cli_has_all_fields

Requirement: Display name derivation

When no explicit display name is provided, the system SHALL derive one by capitalizing the first letter of the binary name.

Scenario: Custom CLI defaults to capitalized name

  • GIVEN a custom CLI definition has no display_name
  • WHEN it is resolved
  • THEN the display_name SHALL be the binary name with the first letter capitalized

Test: detect::tests::custom_cli_display_name_defaults_to_capitalised_name

Requirement: Results sorted by display name

The combined CLI list SHALL be sorted alphabetically by display_name (case-insensitive).

Scenario: Results are sorted

  • GIVEN multiple CLIs are detected and/or custom
  • WHEN detect_clis() is called
  • THEN the results SHALL be sorted by display name

Test: detect::tests::results_sorted_by_display_name

Requirement: CliSource display format

The CliSource enum SHALL display as "detected" or "custom".

Scenario: CliSource display strings

  • GIVEN CliSource::Detected and CliSource::Custom
  • WHEN formatted with Display
  • THEN they SHALL render as "detected" and "custom" respectively

Test: detect::tests::cli_source_display_format


Git Operations

Purpose

Validate git repositories, list branches, create and remove worktrees, and derive worktree directory names. Provides the git plumbing that underpins parallel branch sessions.

Requirements

Requirement: Validate that a path is inside a git repository

The system SHALL confirm a path is inside a git repository and return the repository root.

Scenario: Path is inside a git repository

  • GIVEN a path inside an initialized git repository
  • WHEN validate_repo() is called
  • THEN it SHALL return Ok with the absolute path to the repository root

Test: git::tests::validate_repo_returns_root_inside_repo

Scenario: Path is not inside a git repository

  • GIVEN a path that is not inside any git repository
  • WHEN validate_repo() is called
  • THEN it SHALL return Err(PawError::NotAGitRepo)

Test: git::tests::validate_repo_returns_not_a_git_repo_outside

Requirement: List branches sorted and deduplicated

The system SHALL list all local and remote branches, deduplicated, sorted, with remote prefixes stripped and HEAD pointers excluded.

Scenario: Branches are returned sorted

  • GIVEN a repository with multiple branches
  • WHEN list_branches() is called
  • THEN it SHALL return branches sorted alphabetically

Test: git::tests::list_branches_returns_sorted_branches

Scenario: Local and remote branches are deduplicated with prefix stripping

  • GIVEN a repository cloned from a remote, with branches existing both locally and as remote-tracking refs
  • WHEN list_branches() is called
  • THEN each branch SHALL appear exactly once, with origin/ prefixes stripped

Test: git_integration::list_branches_strips_remote_prefix_and_deduplicates

Requirement: Derive project name from repository path

The system SHALL extract the project name from the final component of the repository root path, falling back to "project" for root paths.

Scenario: Normal repository path

  • GIVEN a repository at /Users/jie/code/git-paw
  • WHEN project_name() is called
  • THEN it SHALL return "git-paw"

Test: git::tests::project_name_from_path

Scenario: Root path fallback

  • GIVEN a repository at /
  • WHEN project_name() is called
  • THEN it SHALL return "project"

Test: git::tests::project_name_fallback_for_root

Requirement: Build worktree directory names

The system SHALL generate worktree directory names as <project>-<sanitized-branch>, replacing / with - and stripping unsafe characters.

Scenario: Branch with single slash

  • GIVEN project "git-paw" and branch "feature/auth-flow"
  • WHEN worktree_dir_name() is called
  • THEN it SHALL return "git-paw-feature-auth-flow"

Test: git::tests::worktree_dir_name_replaces_slash_with_dash

Scenario: Branch with multiple slashes

  • GIVEN project "git-paw" and branch "feat/auth/v2"
  • WHEN worktree_dir_name() is called
  • THEN it SHALL return "git-paw-feat-auth-v2"

Test: git::tests::worktree_dir_name_handles_multiple_slashes

Scenario: Branch with special characters

  • GIVEN project "my-proj" and branch "fix/issue#42"
  • WHEN worktree_dir_name() is called
  • THEN unsafe characters SHALL be stripped, returning "my-proj-fix-issue42"

Test: git::tests::worktree_dir_name_strips_special_chars

Scenario: Simple branch name

  • GIVEN project "git-paw" and branch "main"
  • WHEN worktree_dir_name() is called
  • THEN it SHALL return "git-paw-main"

Test: git::tests::worktree_dir_name_simple_branch

Requirement: Create worktrees as siblings of the repository

The system SHALL create git worktrees in the parent directory of the repository root using the derived directory name convention.

Scenario: Worktree created at correct path

  • GIVEN a repository with a branch feature/test
  • WHEN create_worktree() is called
  • THEN a worktree SHALL be created at ../<project>-feature-test containing the repository files

Test: git::tests::create_worktree_at_correct_path

Scenario: Creating worktree for currently checked-out branch fails

  • GIVEN the current branch is checked out in the main repo
  • WHEN create_worktree() is called for that branch
  • THEN it SHALL return Err(PawError::WorktreeError)

Test: git::tests::create_worktree_errors_on_checked_out_branch

Requirement: Remove worktrees and prune stale entries

The system SHALL force-remove a worktree and prune stale git worktree metadata.

Scenario: Worktree fully cleaned up after removal

  • GIVEN an existing worktree
  • WHEN remove_worktree() is called
  • THEN the directory SHALL be deleted and git SHALL no longer track it

Test: git::tests::remove_worktree_cleans_up_fully

Requirement: Repository validation SHALL work against real git repos

Scenario: Succeeds inside a real git repo

  • GIVEN a temporary git repository with an initial commit
  • WHEN validate_repo() is called
  • THEN it SHALL return the canonicalized repo root

Test: git_integration::validate_repo_succeeds_inside_git_repo

Scenario: Fails outside a git repo

  • GIVEN a temporary directory that is not a git repo
  • WHEN validate_repo() is called
  • THEN it SHALL return an error

Test: git_integration::validate_repo_fails_outside_git_repo

Requirement: Branch listing SHALL work against real git repos

Scenario: Lists created branches

  • GIVEN a repo with branches feature/auth and fix/db
  • WHEN list_branches() is called
  • THEN both branches SHALL appear in the result

Test: git_integration::list_branches_includes_created_branches

Scenario: Branches are sorted

  • GIVEN branches created in non-alphabetical order
  • WHEN list_branches() is called
  • THEN results SHALL be alphabetically sorted

Test: git_integration::list_branches_returns_sorted

Scenario: Deduplicates local and remote

  • GIVEN a repository with a default branch
  • WHEN list_branches() is called
  • THEN each branch SHALL appear exactly once

Test: git_integration::list_branches_deduplicates_local_and_remote

Requirement: Worktree lifecycle SHALL work against real git repos

Scenario: Create and remove worktree

  • GIVEN a branch in a temporary repo
  • WHEN create_worktree() then remove_worktree() are called
  • THEN the worktree SHALL exist after creation and be gone after removal

Test: git_integration::create_and_remove_worktree

Scenario: Worktree placed as sibling of repo

  • GIVEN a repo at <sandbox>/test-repo/
  • WHEN create_worktree() is called
  • THEN the worktree SHALL be in the same parent directory

Test: git_integration::worktree_placed_as_sibling_of_repo

Scenario: Fails for checked-out branch

  • GIVEN the currently checked-out branch
  • WHEN create_worktree() is called for it
  • THEN it SHALL fail

Test: git_integration::create_worktree_fails_for_checked_out_branch

Requirement: Directory naming SHALL be correct in integration tests

Scenario: Project name from real repo path

  • GIVEN a repo at .../test-repo/
  • WHEN project_name() is called
  • THEN it SHALL return "test-repo"

Test: git_integration::project_name_from_repo_path

Scenario: Worktree dir name replaces slashes

  • WHEN worktree_dir_name("my-project", "feature/auth-flow") is called
  • THEN it SHALL return "my-project-feature-auth-flow"

Test: git_integration::worktree_dir_name_replaces_slashes

Scenario: Worktree dir name strips unsafe chars

  • WHEN worktree_dir_name("proj", "feat/special@chars!") is called
  • THEN @ and ! SHALL be stripped

Test: git_integration::worktree_dir_name_strips_unsafe_chars

Scenario: Worktree dir name handles nested slashes

  • WHEN worktree_dir_name("proj", "feature/deep/nested/branch") is called
  • THEN it SHALL return "proj-feature-deep-nested-branch"

Test: git_integration::worktree_dir_name_handles_nested_slashes


Tmux Orchestration

Purpose

Orchestrate tmux sessions with multiple panes, each running an AI CLI in a git worktree. Uses a builder pattern for testability and dry-run support, with configurable mouse mode and automatic tiled layout.

Requirements

Requirement: Check tmux availability with actionable error

The system SHALL verify tmux is installed on PATH and provide install instructions if missing.

Scenario: tmux is present on PATH

  • GIVEN tmux is installed
  • WHEN ensure_tmux_installed() is called
  • THEN it SHALL return Ok(())

Test: tmux::tests::ensure_tmux_installed_succeeds_when_present

Requirement: Create named sessions derived from project name

The system SHALL name tmux sessions as paw-<project_name>.

Scenario: Session named after project

  • GIVEN project name "my-project"
  • WHEN a session is built
  • THEN the session name SHALL be "paw-my-project"

Test: tmux::tests::session_is_named_after_project

Scenario: Session creation command uses correct name

  • GIVEN project name "app"
  • WHEN a session is built
  • THEN the commands SHALL include new-session with paw-app

Test: tmux::tests::session_creation_command_uses_session_name

Requirement: Session name override via builder

The builder SHALL support overriding the default paw-<project> session name with a custom name.

Scenario: Override replaces default name

  • GIVEN session_name("custom-session-name") is set on the builder
  • WHEN the session is built
  • THEN the session name SHALL be "custom-session-name" and commands SHALL target it

Test: tmux::tests::session_name_override_replaces_default

Requirement: Dynamic pane count matches input

The number of panes in the session SHALL match the number of PaneSpec entries added via the builder.

Scenario: Two panes created

  • GIVEN 2 pane specs added
  • WHEN the session is built
  • THEN exactly 2 send-keys commands SHALL be emitted

Test: tmux::tests::pane_count_matches_input_for_two_panes

Scenario: Five panes created

  • GIVEN 5 pane specs added
  • WHEN the session is built
  • THEN exactly 5 send-keys commands SHALL be emitted

Test: tmux::tests::pane_count_matches_input_for_five_panes

Scenario: Building with no panes is an error

  • GIVEN no pane specs added
  • WHEN build() is called
  • THEN it SHALL return an error

Test: tmux::tests::building_with_no_panes_is_an_error

Requirement: Correct commands sent to each pane

Each pane SHALL receive a cd <worktree> && <cli_command> command targeting the correct pane index.

Scenario: Each pane receives cd and CLI command

  • GIVEN two panes with different worktrees and CLIs
  • WHEN the session is built
  • THEN each send-keys command SHALL contain cd <worktree> && <cli>

Test: tmux::tests::each_pane_receives_cd_and_cli_command

Scenario: Commands are submitted with Enter

  • GIVEN a pane spec
  • WHEN the session is built
  • THEN the send-keys command SHALL include Enter

Test: tmux::tests::pane_commands_are_submitted_with_enter

Scenario: Each pane targets a distinct index

  • GIVEN 3 panes
  • WHEN the session is built
  • THEN send-keys SHALL target :0.0, :0.1, and :0.2 respectively

Test: tmux::tests::each_pane_targets_a_distinct_pane_index

Requirement: Pane titles show branch and CLI

Each pane SHALL be titled with <branch> → <cli_command> and border status configured.

Scenario: Pane titles contain branch and CLI

  • GIVEN panes with branches and CLIs
  • WHEN the session is built
  • THEN select-pane -T commands SHALL set titles like "feat/auth → claude"

Test: tmux::tests::each_pane_is_titled_with_branch_and_cli

Scenario: Pane border status configured

  • GIVEN any session
  • WHEN the session is built
  • THEN pane-border-status SHALL be set to top and pane-border-format SHALL use #{pane_title}

Test: tmux::tests::pane_border_status_is_configured

Requirement: Configurable mouse mode per session

Mouse mode SHALL be enabled by default and be disableable via the builder.

Scenario: Mouse mode enabled by default

  • GIVEN no explicit mouse mode setting
  • WHEN the session is built
  • THEN a mouse on command SHALL be emitted

Test: tmux::tests::mouse_mode_enabled_by_default

Scenario: Mouse mode can be disabled

  • GIVEN mouse_mode(false) is set on the builder
  • WHEN the session is built
  • THEN no mouse on command SHALL be emitted

Test: tmux::tests::mouse_mode_can_be_disabled

Requirement: Attach to a tmux session

The system SHALL attach the current terminal to a named tmux session, returning an error if the session does not exist.

Scenario: Attaching to a nonexistent session fails

  • GIVEN no tmux session with the given name exists
  • WHEN attach() is called
  • THEN it SHALL return an error

Test: e2e_tests::attach_fails_for_nonexistent_session

Requirement: Session liveness check

The system SHALL check whether a tmux session is alive by name.

Scenario: Nonexistent session reports not alive

  • GIVEN no tmux session with the queried name exists
  • WHEN is_session_alive() is called
  • THEN it SHALL return false

Test: tmux::tests::is_session_alive_returns_false_for_nonexistent

Requirement: Session lifecycle management

The system SHALL support creating, checking, and killing tmux sessions.

Scenario: Full create-check-kill lifecycle

  • GIVEN a tmux session is created
  • WHEN is_session_alive() is called, then kill_session(), then is_session_alive() again
  • THEN it SHALL be alive after creation and not alive after killing

Test: tmux::tests::session_lifecycle_create_check_kill

Scenario: Built session can be executed and killed

  • GIVEN a session built via TmuxSessionBuilder
  • WHEN execute() is called
  • THEN the tmux session SHALL be alive, and after kill_session() it SHALL be gone

Test: tmux::tests::built_session_can_be_executed_and_killed

Requirement: Session name collision resolution

The system SHALL resolve name collisions by appending -2, -3, etc. to the base session name.

Scenario: No collision returns base name

  • GIVEN no existing session with the base name
  • WHEN resolve_session_name() is called
  • THEN it SHALL return paw-<project_name>

Test: tmux::tests::resolve_session_name_returns_base_when_no_collision

Scenario: Collision appends numeric suffix

  • GIVEN a session with the base name already exists
  • WHEN resolve_session_name() is called
  • THEN it SHALL return paw-<project_name>-2

Test: tmux::tests::resolve_session_name_appends_suffix_on_collision

Requirement: Tmux session lifecycle SHALL work against a real tmux server

Scenario: Create and kill session lifecycle

  • GIVEN a tmux session is created via the builder
  • WHEN execute(), is_session_alive(), and kill_session() are called
  • THEN the session SHALL be alive after creation and gone after killing

Test: e2e_tests::tmux_session_create_and_kill_lifecycle

Scenario: Five panes with different CLIs

  • GIVEN 5 pane specs with different branch/CLI pairs
  • WHEN the session is executed
  • THEN tmux SHALL have 5 panes with correct titles

Test: e2e_tests::tmux_session_with_five_panes_and_different_clis

Scenario: Mouse mode enabled by default against live tmux

  • GIVEN a session built with default settings
  • WHEN tmux show-option is queried
  • THEN mouse SHALL be “on”

Test: e2e_tests::tmux_mouse_mode_enabled_by_default

Scenario: is_session_alive returns false for nonexistent (e2e)

  • GIVEN no session with the queried name
  • WHEN is_session_alive() is called
  • THEN it SHALL return false

Test: e2e_tests::tmux_is_session_alive_returns_false_for_nonexistent

Scenario: Attach succeeds for live session

  • GIVEN a live tmux session
  • WHEN attach() is called and the client is detached programmatically
  • THEN the function SHALL execute without panic

Test: e2e_tests::attach_succeeds_for_live_session

Requirement: E2E commands SHALL behave correctly against real repos

Scenario: Dry run shows session plan

  • GIVEN a git repo with branches and --dry-run --cli echo --branches feat/a,feat/b
  • WHEN the binary runs
  • THEN stdout SHALL contain “Dry run”, branch names, and the CLI name

Test: e2e_tests::dry_run_with_flags_shows_plan

Scenario: Preset not found returns error

  • GIVEN a git repo with no presets configured
  • WHEN start --preset nonexistent is run
  • THEN it SHALL fail with stderr mentioning “not found”

Test: e2e_tests::preset_not_found_returns_error

Scenario: Stop with no session

  • GIVEN a git repo with no active session
  • WHEN stop is run
  • THEN it SHALL succeed with stdout mentioning “No active session”

Test: e2e_tests::stop_with_no_session

Scenario: Purge with no session

  • GIVEN a git repo with no active session
  • WHEN purge --force is run
  • THEN it SHALL succeed with stdout mentioning “No session to purge”

Test: e2e_tests::purge_with_no_session

Scenario: Status with no session

  • GIVEN a git repo with no active session
  • WHEN status is run
  • THEN it SHALL succeed with stdout mentioning “No session”

Test: e2e_tests::status_with_no_session

Scenario: Stop from non-git directory fails

  • GIVEN a directory that is not a git repository
  • WHEN stop is run
  • THEN it SHALL fail with “Not a git repository”

Test: e2e_tests::stop_from_non_git_dir_fails

Scenario: Status from non-git directory fails

  • GIVEN a directory that is not a git repository
  • WHEN status is run
  • THEN it SHALL fail with “Not a git repository”

Test: e2e_tests::status_from_non_git_dir_fails


Session State

Purpose

Persist session state to disk for recovery after crashes, reboots, or manual stops. Stores one JSON file per session under the XDG data directory, with atomic writes and tmux liveness checks.

Requirements

Requirement: Save session state atomically

The system SHALL serialize session data to JSON and write it atomically using a temp file and rename to prevent corruption.

Scenario: Saved session round-trips with all fields intact

  • GIVEN an active session with 3 worktrees
  • WHEN save_session() is called and the session is loaded back
  • THEN all fields (session_name, repo_path, project_name, created_at, status, worktrees) SHALL match the original

Test: session::tests::saved_session_can_be_loaded_with_all_fields_intact

Scenario: Saving again replaces previous state

  • GIVEN a previously saved session
  • WHEN save_session() is called with updated fields
  • THEN the new state SHALL overwrite the old state

Test: session::tests::saving_again_replaces_previous_state

Requirement: Load session by name

The system SHALL load a session from disk by name, returning None if the file does not exist.

Scenario: Loading a nonexistent session returns None

  • GIVEN no session file exists with the given name
  • WHEN load_session() is called
  • THEN it SHALL return Ok(None)

Test: session::tests::loading_nonexistent_session_returns_none

Requirement: Find session by repository path

The system SHALL scan all session files and return the session matching a given repository path.

Scenario: Finds correct session among multiple

  • GIVEN two sessions for different repositories
  • WHEN find_session_for_repo() is called with one repo path
  • THEN it SHALL return the matching session

Test: session::tests::finds_correct_session_among_multiple_by_repo_path

Scenario: No matching session

  • GIVEN saved sessions for other repositories
  • WHEN find_session_for_repo() is called with a different path
  • THEN it SHALL return None

Test: session::tests::find_returns_none_when_no_repo_matches

Scenario: No sessions directory

  • GIVEN no sessions directory exists
  • WHEN find_session_for_repo() is called
  • THEN it SHALL return None

Test: session::tests::find_returns_none_when_no_sessions_exist

Requirement: Delete session by name

The system SHALL delete a session file, succeeding even if the file does not exist (idempotent).

Scenario: Deleted session is no longer loadable

  • GIVEN a saved session
  • WHEN delete_session() is called
  • THEN load_session() SHALL return None

Test: session::tests::deleted_session_is_no_longer_loadable

Scenario: Deleting nonexistent session succeeds

  • GIVEN no session file with the given name
  • WHEN delete_session() is called
  • THEN it SHALL return Ok(())

Test: session::tests::deleting_nonexistent_session_succeeds

Requirement: Effective status combines file state with tmux liveness

The system SHALL report Stopped when the file says Active but the tmux session is dead.

Scenario: Active file and alive tmux means Active

  • GIVEN a session with status = Active and tmux is alive
  • WHEN effective_status() is called
  • THEN it SHALL return Active

Test: session::tests::file_says_active_and_tmux_alive_means_active

Scenario: Active file but dead tmux means Stopped

  • GIVEN a session with status = Active and tmux is dead
  • WHEN effective_status() is called
  • THEN it SHALL return Stopped

Test: session::tests::file_says_active_but_tmux_dead_means_stopped

Scenario: Stopped file stays Stopped

  • GIVEN a session with status = Stopped
  • WHEN effective_status() is called regardless of tmux state
  • THEN it SHALL return Stopped

Test: session::tests::file_says_stopped_stays_stopped_regardless_of_tmux

Requirement: SessionStatus display format

The SessionStatus enum SHALL display as lowercase strings.

Scenario: SessionStatus display strings

  • GIVEN SessionStatus::Active and SessionStatus::Stopped
  • WHEN formatted with Display
  • THEN they SHALL render as "active" and "stopped"

Test: session::tests::session_status_displays_as_lowercase_string

Requirement: Recovery data survives tmux crashes

After a tmux crash, the persisted session SHALL contain all data needed to reconstruct the session.

Scenario: Crashed session has all recovery data

  • GIVEN a saved session with worktrees
  • WHEN tmux crashes and the session is loaded from disk
  • THEN it SHALL have the session name, repo path, and all worktree details (branch, path, CLI)

Test: session::tests::recovery_after_tmux_crash_has_all_data_to_reconstruct

Requirement: Session persistence SHALL work through the public API

Scenario: Save and load round-trip

  • GIVEN a session with 2 worktrees
  • WHEN save_session_in() and load_session_from() are called
  • THEN all fields SHALL match

Test: session_integration::save_and_load_round_trip

Scenario: Find session by repo path

  • GIVEN a saved session
  • WHEN find_session_for_repo_in() is called with the matching repo path
  • THEN the correct session SHALL be returned

Test: session_integration::find_session_by_repo_path

Scenario: Find returns None for unknown repo

  • GIVEN no matching session
  • WHEN find_session_for_repo_in() is called
  • THEN it SHALL return None

Test: session_integration::find_session_returns_none_for_unknown_repo

Scenario: Find correct session among multiple

  • GIVEN two sessions for different repos
  • WHEN find_session_for_repo_in() is called for one
  • THEN the correct session SHALL be returned

Test: session_integration::find_correct_session_among_multiple

Scenario: Delete removes session

  • GIVEN a saved session
  • WHEN delete_session_in() is called
  • THEN load_session_from() SHALL return None

Test: session_integration::delete_removes_session

Scenario: Delete nonexistent is idempotent

  • GIVEN no session file
  • WHEN delete_session_in() is called
  • THEN it SHALL succeed

Test: session_integration::delete_nonexistent_is_idempotent

Scenario: Load nonexistent returns None

  • GIVEN no session file
  • WHEN load_session_from() is called
  • THEN it SHALL return None

Test: session_integration::load_nonexistent_returns_none

Scenario: Saving again replaces previous state

  • GIVEN a saved session
  • WHEN the status is changed and saved again
  • THEN the loaded session SHALL have the new status

Test: session_integration::saving_again_replaces_previous_state

Scenario: Effective status active when tmux alive

  • GIVEN a session with Active status and tmux alive
  • WHEN effective_status() is called
  • THEN it SHALL return Active

Test: session_integration::effective_status_active_when_tmux_alive

Scenario: Effective status stopped when tmux dead

  • GIVEN a session with Active status and tmux dead
  • WHEN effective_status() is called
  • THEN it SHALL return Stopped

Test: session_integration::effective_status_stopped_when_tmux_dead

Scenario: Effective status stopped stays stopped

  • GIVEN a session with Stopped status
  • WHEN effective_status() is called
  • THEN it SHALL return Stopped regardless of tmux

Test: session_integration::effective_status_stopped_stays_stopped

Scenario: Saved session has all recovery fields

  • GIVEN a saved and reloaded session
  • WHEN recovery fields are checked
  • THEN session_name, repo_path, project_name, and all worktree entries SHALL be non-empty

Test: session_integration::saved_session_has_all_recovery_fields


Configuration

Purpose

Parse TOML configuration from global (~/.config/git-paw/config.toml) and per-repo (.git-paw/config.toml) files. Supports custom CLI definitions, presets, and programmatic add/remove of custom CLIs with repo config overriding global config.

Requirements

Requirement: Parse TOML config with all fields

The system SHALL parse a TOML configuration file containing default_cli, mouse, clis, and presets fields.

Scenario: Config with all fields populated

  • GIVEN a TOML file with default_cli, mouse, custom CLIs, and presets
  • WHEN the file is loaded
  • THEN all fields SHALL be correctly parsed

Test: config::tests::parses_config_with_all_fields

Scenario: All fields are optional

  • GIVEN a TOML file with only default_cli
  • WHEN the file is loaded
  • THEN missing fields SHALL default to None or empty collections

Test: config::tests::all_fields_are_optional

Scenario: No config files exist

  • GIVEN neither global nor repo config files exist
  • WHEN load_config() is called
  • THEN it SHALL return a default config with all fields empty/None

Test: config::tests::returns_defaults_when_no_files_exist

Scenario: Invalid TOML reports error with file path

  • GIVEN a malformed TOML file
  • WHEN it is loaded
  • THEN the error message SHALL include the file path

Test: config::tests::reports_error_for_invalid_toml

Requirement: Merge repo config over global config

The system SHALL merge per-repo configuration on top of global configuration, with repo values taking precedence for scalar fields and map entries.

Scenario: Repo overrides global scalar fields

  • GIVEN global config has default_cli = "claude" and mouse = true, and repo has default_cli = "gemini"
  • WHEN configs are merged
  • THEN default_cli SHALL be "gemini" and mouse SHALL be true (preserved from global)

Test: config::tests::repo_config_overrides_global_scalars

Scenario: CLI maps are merged

  • GIVEN global config has CLI agent-a and repo config has CLI agent-b
  • WHEN configs are merged
  • THEN both CLIs SHALL be present

Test: config::tests::repo_config_merges_cli_maps

Scenario: Repo CLI overrides global CLI with same name

  • GIVEN both global and repo define a CLI named my-agent
  • WHEN configs are merged
  • THEN the repo definition SHALL win

Test: config::tests::repo_cli_overrides_global_cli_with_same_name

Scenario: Only global config exists

  • GIVEN a global config file but no repo config
  • WHEN load_config() is called
  • THEN global values SHALL be used

Test: config::tests::load_config_from_reads_global_file_when_no_repo

Scenario: Only repo config exists

  • GIVEN a repo config file but no global config
  • WHEN load_config() is called
  • THEN repo values SHALL be used

Test: config::tests::load_config_from_reads_repo_file_when_no_global

Requirement: Preset lookup by name

The system SHALL provide access to named presets that define branches and a CLI.

Scenario: Preset accessible by name

  • GIVEN a config with a preset named "backend"
  • WHEN get_preset("backend") is called
  • THEN it SHALL return the preset with its branches and CLI

Test: config::tests::preset_accessible_by_name

Scenario: Missing preset returns None

  • GIVEN a config without the requested preset
  • WHEN get_preset("nonexistent") is called
  • THEN it SHALL return None

Test: config::tests::preset_returns_none_when_not_in_config

Requirement: Add custom CLIs to global config

The system SHALL add custom CLI definitions to the global config, resolving non-absolute commands via PATH.

Scenario: Add CLI with absolute path

  • GIVEN an absolute path to a CLI binary
  • WHEN add_custom_cli() is called
  • THEN the CLI SHALL be written to the config file

Test: config::tests::add_cli_writes_to_config_file

Scenario: Adding preserves existing entries

  • GIVEN an existing CLI in the config
  • WHEN a second CLI is added
  • THEN both CLIs SHALL be present

Test: config::tests::add_cli_preserves_existing_entries

Scenario: Adding CLI with missing command fails

  • GIVEN a command that does not exist on PATH
  • WHEN add_custom_cli() is called
  • THEN it SHALL return an error mentioning “not found on PATH”

Test: config::tests::add_cli_errors_when_command_not_on_path

Requirement: Remove custom CLIs from global config

The system SHALL remove a custom CLI by name, returning an error if the CLI is not found.

Scenario: Remove existing CLI

  • GIVEN a config with CLIs keep-me and remove-me
  • WHEN remove_custom_cli("remove-me") is called
  • THEN only keep-me SHALL remain

Test: config::tests::remove_cli_deletes_entry_from_config_file

Scenario: Remove nonexistent CLI returns error

  • GIVEN a config without the named CLI
  • WHEN remove_custom_cli() is called
  • THEN it SHALL return PawError::CliNotFound

Test: config::tests::remove_nonexistent_cli_returns_cli_not_found_error

Scenario: Remove CLI from empty/missing config returns error

  • GIVEN no config file exists
  • WHEN remove_custom_cli() is called
  • THEN it SHALL return PawError::CliNotFound

Test: config::tests::remove_cli_from_empty_config_returns_error

Requirement: Config survives round-trip serialization

A PawConfig SHALL be identical after save and reload.

Scenario: Config round-trip

  • GIVEN a fully populated config
  • WHEN saved and loaded back
  • THEN it SHALL be equal to the original

Test: config::tests::config_survives_save_and_load

Requirement: Config loading SHALL work with real files

Scenario: Defaults when no files exist

  • GIVEN a temp directory with no config files
  • WHEN load_config() is called
  • THEN all fields SHALL be None/empty

Test: config_integration::load_config_returns_defaults_when_no_files_exist

Scenario: Reads repo .git-paw/config.toml

  • GIVEN a .git-paw/config.toml with default_cli and mouse
  • WHEN load_config() is called
  • THEN the values SHALL be read correctly

Test: config_integration::load_config_reads_repo_config

Scenario: Repo config with custom CLIs

  • GIVEN a .git-paw/config.toml with two custom CLIs
  • WHEN load_config() is called
  • THEN both CLIs SHALL be parsed with correct fields

Test: config_integration::repo_config_with_custom_clis

Scenario: Repo config with presets

  • GIVEN a .git-paw/config.toml with two presets
  • WHEN load_config() is called
  • THEN presets SHALL be accessible with correct branches and CLI

Test: config_integration::repo_config_with_presets

Scenario: Default PawConfig has no presets

  • GIVEN a default PawConfig
  • WHEN get_preset("nonexistent") is called
  • THEN it SHALL return None

Test: config_integration::get_preset_returns_none_for_unknown

Scenario: Repo config overrides default fields

  • GIVEN a .git-paw/config.toml with specific values
  • WHEN load_config() is called
  • THEN the repo values SHALL take precedence

Test: config_integration::repo_config_overrides_default_fields

Scenario: Repo config path is correct

  • GIVEN a temp directory
  • WHEN repo_config_path() is called
  • THEN it SHALL return <dir>/.git-paw/config.toml

Test: config_integration::repo_config_path_is_in_repo_root

Scenario: Malformed TOML returns error

  • GIVEN a .git-paw/config.toml with invalid TOML
  • WHEN load_config() is called
  • THEN it SHALL return an error

Test: config_integration::malformed_toml_returns_error

Scenario: Empty config file is valid

  • GIVEN an empty .git-paw/config.toml
  • WHEN load_config() is called
  • THEN it SHALL return a default config

Test: config_integration::empty_config_file_is_valid

Requirement: Custom CLI management SHALL persist through file I/O

Scenario: Add CLI with absolute path

  • GIVEN no config file
  • WHEN add_custom_cli_to() is called with an absolute path
  • THEN the CLI SHALL be persisted and reloadable

Test: config_integration::add_custom_cli_with_absolute_path

Scenario: Add CLI with display name

  • GIVEN no config file
  • WHEN add_custom_cli_to() is called with a display name
  • THEN the display name SHALL be persisted

Test: config_integration::add_custom_cli_with_display_name

Scenario: Multiple CLIs preserved across adds

  • GIVEN 4 CLIs added sequentially
  • WHEN the config is loaded
  • THEN all 4 SHALL be present with correct fields

Test: config_integration::add_multiple_custom_clis_preserves_all

Scenario: Adding overwrites existing entry

  • GIVEN a CLI with name my-agent already exists
  • WHEN add_custom_cli_to() is called with the same name but different values
  • THEN the new values SHALL replace the old

Test: config_integration::add_cli_overwrites_existing_entry

Scenario: Add CLI with nonexistent command fails

  • GIVEN a non-absolute command that is not on PATH
  • WHEN add_custom_cli_to() is called
  • THEN it SHALL return an error

Test: config_integration::add_cli_with_nonexistent_path_command_fails

Scenario: Remove custom CLI

  • GIVEN two CLIs in the config
  • WHEN one is removed
  • THEN only the other SHALL remain

Test: config_integration::remove_custom_cli

Scenario: Remove nonexistent CLI returns error

  • GIVEN no CLIs in the config
  • WHEN remove_custom_cli_from() is called
  • THEN it SHALL return an error

Test: config_integration::remove_nonexistent_cli_returns_error

Scenario: Remove all CLIs leaves empty config

  • GIVEN one CLI in the config
  • WHEN it is removed
  • THEN the CLI map SHALL be empty

Test: config_integration::remove_all_custom_clis_leaves_empty_config

Requirement: Global and repo config SHALL merge custom CLIs correctly

Scenario: Repo custom CLIs merge with global

  • GIVEN global config with 2 CLIs and repo config with 2 CLIs (one overlapping)
  • WHEN load_config_from() is called
  • THEN the result SHALL have 3 CLIs, with repo winning on collision

Test: config_integration::repo_custom_clis_merge_with_global_custom_clis

Requirement: Config SHALL handle many custom CLIs

Scenario: Config with 10 custom CLIs

  • GIVEN a config file with 10 custom CLI definitions
  • WHEN load_config() is called
  • THEN all 10 SHALL be parsed correctly

Test: config_integration::config_with_many_custom_clis


Interactive Selection

Purpose

Interactive selection prompts for choosing branches and AI CLIs. Supports uniform (same CLI for all branches) and per-branch assignment modes, with CLI flags that skip prompts. Logic is separated from UI via the Prompter trait for testability.

Requirements

Requirement: CLI flags skip all prompts when both provided

When both --cli and --branches flags are provided, the system SHALL skip all interactive prompts and map the CLI to all specified branches.

Scenario: Both flags skip all prompts

  • GIVEN --cli alpha and --branches feature/auth,fix/api flags
  • WHEN run_selection() is called
  • THEN it SHALL return mappings without invoking any prompts

Test: interactive::tests::both_flags_skips_all_prompts_and_maps_cli_to_all_branches

Requirement: CLI flag skips CLI prompt but prompts for branches

When only --cli is provided, the system SHALL prompt for branch selection but skip CLI selection.

Scenario: CLI flag provided, branches prompted

  • GIVEN --cli alpha flag and no branches flag
  • WHEN run_selection() is called
  • THEN branch selection SHALL be prompted and the flag CLI SHALL be used

Test: interactive::tests::cli_flag_skips_cli_prompt_but_prompts_for_branches

Requirement: Branches flag skips branch prompt but prompts for CLI

When only --branches is provided, the system SHALL skip branch selection but prompt for CLI assignment.

Scenario: Branches flag provided, CLI prompted in uniform mode

  • GIVEN --branches flag and no CLI flag
  • WHEN user selects uniform mode
  • THEN the selected CLI SHALL be mapped to all flagged branches

Test: interactive::tests::branches_flag_skips_branch_prompt_but_prompts_for_cli_uniform

Requirement: Uniform mode maps same CLI to all branches

In uniform mode, the system SHALL assign the selected CLI to every selected branch.

Scenario: Uniform mode selection

  • GIVEN user selects uniform mode, picks 2 branches and 1 CLI
  • WHEN run_selection() completes
  • THEN both branches SHALL be mapped to the same CLI

Test: interactive::tests::uniform_mode_maps_same_cli_to_all_selected_branches

Requirement: Per-branch mode maps different CLIs to each branch

In per-branch mode, the system SHALL prompt for a CLI for each selected branch individually.

Scenario: Per-branch mode selection

  • GIVEN user selects per-branch mode with 2 branches
  • WHEN different CLIs are chosen for each branch
  • THEN each branch SHALL be mapped to its respective CLI

Test: interactive::tests::per_branch_mode_maps_different_cli_to_each_branch

Scenario: Per-branch mode with branches flag

  • GIVEN branches provided via flag and per-branch mode selected
  • WHEN different CLIs are chosen
  • THEN each flagged branch SHALL be mapped to its selected CLI

Test: interactive::tests::per_branch_mode_with_branches_flag

Requirement: Error when no CLIs available

The system SHALL return PawError::NoCLIsFound when the CLI list is empty.

Scenario: Empty CLI list

  • GIVEN no CLIs available
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::NoCLIsFound)

Test: interactive::tests::no_clis_available_returns_error

Requirement: Error when no branches available

The system SHALL return PawError::BranchError when the branch list is empty.

Scenario: Empty branch list

  • GIVEN no branches available
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::BranchError)

Test: interactive::tests::no_branches_available_returns_error

Requirement: User cancellation propagates as PawError::UserCancelled

The system SHALL propagate cancellation (Ctrl+C or empty selection) as PawError::UserCancelled.

Scenario: User cancels branch selection

  • GIVEN user presses Ctrl+C during branch selection
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::UserCancelled)

Test: interactive::tests::user_cancels_branch_selection_returns_cancelled

Scenario: User selects no branches

  • GIVEN user confirms with zero branches selected
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::UserCancelled)

Test: interactive::tests::user_selects_no_branches_returns_cancelled

Scenario: User cancels CLI selection

  • GIVEN user presses Ctrl+C during CLI selection
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::UserCancelled)

Test: interactive::tests::user_cancels_cli_selection_returns_cancelled

Requirement: Subset branch selection

The system SHALL support selecting a subset of available branches.

Scenario: Selecting one of two branches

  • GIVEN 2 available branches
  • WHEN user selects only the second
  • THEN only that branch SHALL appear in the result

Test: interactive::tests::selecting_subset_of_branches_works

Requirement: CliMode display format

The CliMode enum SHALL display as human-readable descriptions.

Scenario: CliMode display strings

  • GIVEN CliMode::Uniform and CliMode::PerBranch
  • WHEN formatted with Display
  • THEN they SHALL render as "Same CLI for all branches" and "Different CLI per branch"

Test: interactive::tests::cli_mode_display

Requirement: CliInfo display format

CliInfo SHALL display as the binary name when it matches the display name, or as "DisplayName (binary)" when they differ.

Scenario: Same display and binary name

  • GIVEN a CliInfo where display_name equals binary_name
  • WHEN formatted with Display
  • THEN it SHALL render as just the binary name

Test: interactive::tests::cli_info_display_same_names

Scenario: Different display and binary name

  • GIVEN a CliInfo where display_name differs from binary_name
  • WHEN formatted with Display
  • THEN it SHALL render as "DisplayName (binary_name)"

Test: interactive::tests::cli_info_display_different_names


Error Handling

Purpose

Define the central error type PawError used across all git-paw modules. Every variant carries an actionable, user-facing message and maps to a process exit code.

Requirements

Requirement: Actionable error messages for each variant

Each PawError variant SHALL produce a user-facing message that explains the problem and suggests a remedy where appropriate.

Scenario: NotAGitRepo is actionable

  • GIVEN PawError::NotAGitRepo
  • WHEN formatted with Display
  • THEN the message SHALL mention “git repository” and name the tool

Test: error::tests::test_not_a_git_repo_is_actionable

Scenario: TmuxNotInstalled includes install instructions

  • GIVEN PawError::TmuxNotInstalled
  • WHEN formatted with Display
  • THEN the message SHALL include both brew install and apt install hints

Test: error::tests::test_tmux_not_installed_includes_install_instructions

Scenario: NoCLIsFound suggests add-cli

  • GIVEN PawError::NoCLIsFound
  • WHEN formatted with Display
  • THEN the message SHALL suggest the add-cli command

Test: error::tests::test_no_clis_found_suggests_add_cli

Scenario: WorktreeError includes detail

  • GIVEN PawError::WorktreeError("failed to create")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_worktree_error_includes_detail

Scenario: SessionError includes detail

  • GIVEN PawError::SessionError("file corrupt")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_session_error_includes_detail

Scenario: ConfigError includes detail

  • GIVEN PawError::ConfigError("invalid toml")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_config_error_includes_detail

Scenario: BranchError includes detail

  • GIVEN PawError::BranchError("not found")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_branch_error_includes_detail

Scenario: UserCancelled has a message

  • GIVEN PawError::UserCancelled
  • WHEN formatted with Display
  • THEN the message SHALL not be empty

Test: error::tests::test_user_cancelled_is_not_empty

Scenario: TmuxError includes detail

  • GIVEN PawError::TmuxError("session failed")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_tmux_error_includes_detail

Scenario: CliNotFound includes CLI name

  • GIVEN PawError::CliNotFound("my-agent")
  • WHEN formatted with Display
  • THEN the message SHALL include the missing CLI name

Test: error::tests::test_cli_not_found_includes_cli_name

Requirement: Exit codes distinguish cancellation from errors

UserCancelled SHALL exit with code 2; all other errors SHALL exit with code 1.

Scenario: UserCancelled exit code

  • GIVEN PawError::UserCancelled
  • WHEN exit_code() is called
  • THEN it SHALL return 2

Test: error::tests::test_user_cancelled_exit_code

Scenario: General errors exit code

  • GIVEN any non-cancellation error variant
  • WHEN exit_code() is called
  • THEN it SHALL return 1

Test: error::tests::test_general_errors_exit_code

Requirement: Exit method prints to stderr and exits with correct code

PawError::exit() SHALL print the error message to stderr and terminate with the appropriate exit code.

Scenario: NotAGitRepo exits with code 1

  • GIVEN the binary is run outside a git repository
  • WHEN the error propagates to exit()
  • THEN the process SHALL exit with code 1 and stderr SHALL contain the error message

Test: e2e_tests::error_exit_code_is_1_for_not_a_git_repo

Scenario: ConfigError exits with code 1

  • GIVEN a nonexistent preset is requested
  • WHEN the error propagates to exit()
  • THEN the process SHALL exit with code 1 and stderr SHALL mention “not found”

Test: e2e_tests::error_exit_code_is_1_for_preset_not_found

Requirement: Debug representation is derivable

All PawError variants SHALL support Debug formatting.

Scenario: Debug format includes variant name

  • GIVEN PawError::NotAGitRepo
  • WHEN formatted with Debug
  • THEN the output SHALL contain "NotAGitRepo"

Test: error::tests::test_debug_derived