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
| Capability | Description |
|---|---|
| CLI Parsing | Command-line argument parsing and subcommands |
| CLI Detection | Auto-detect AI CLIs on PATH, load custom CLIs |
| Git Operations | Validate repos, list branches, manage worktrees |
| Tmux Orchestration | Create sessions, manage panes, apply layout |
| Session State | Persist and recover session state |
| Configuration | Parse and merge TOML config files |
| Interactive Selection | User prompts for mode, branch, and CLI selection |
| Error Handling | Unified 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.
The system SHALL also accept a hidden __dashboard subcommand that does not appear in --help output. This subcommand is used internally by pane 0 to run the broker and dashboard.
Scenario: No arguments yields None command
- GIVEN no arguments are passed
- WHEN the CLI is parsed
- THEN
commandSHALL beNone(handled asStartin main)
Scenario: __dashboard subcommand parses
- GIVEN
__dashboardis passed - WHEN the CLI is parsed
- THEN the command SHALL be
Command::Dashboard
Scenario: __dashboard does not appear in help
- GIVEN
--helpis passed - WHEN the help text is rendered
- THEN the output SHALL NOT contain
__dashboard
Requirement: Start subcommand with optional flags
The start subcommand SHALL be extended to accept a --supervisor flag (boolean, defaults to false). The flag MAY be combined with any other start flags.
When --supervisor is passed, the parsed StartArgs struct SHALL have supervisor: bool set to true.
The start subcommand SHALL also accept a --no-rebase flag (boolean, defaults to false). When --no-rebase is passed, the parsed StartArgs struct SHALL have no_rebase: bool set to true. The dispatch SHALL invoke create_worktree with rebase_onto_main = !args.no_rebase for every worktree creation in the launch. When --no-rebase is omitted (i.e. no_rebase == false), agent branches SHALL be rebased onto the repository’s default branch before their worktrees are opened. When --no-rebase is present, agent branches SHALL NOT be rebased, matching the post-worktree-resume-fix v0.5.0 behaviour.
The --no-rebase flag MAY be combined with any other start flags including --supervisor, --from-specs, --cli, and --branches.
Scenario: Start with –supervisor flag
- GIVEN
start --supervisor - WHEN the CLI is parsed
- THEN
supervisorSHALL betrue
Scenario: Start with –supervisor combined with other flags
- GIVEN
start --supervisor --cli claude --branches feat/a,feat/b - WHEN the CLI is parsed
- THEN
supervisorSHALL betrue - AND
cliSHALL beSome("claude") - AND
branchesSHALL be["feat/a", "feat/b"]
Scenario: Start without –supervisor defaults to false
- GIVEN
start --cli claude - WHEN the CLI is parsed
- THEN
supervisorSHALL befalse
Scenario: Start with –no-rebase flag
- GIVEN
start --no-rebase - WHEN the CLI is parsed
- THEN
no_rebaseSHALL betrue
Scenario: Start without –no-rebase defaults to false
- GIVEN
start --cli claude - WHEN the CLI is parsed
- THEN
no_rebaseSHALL befalse
Scenario: Start with –no-rebase combined with other flags
- GIVEN
start --no-rebase --supervisor --from-specs - WHEN the CLI is parsed
- THEN
no_rebaseSHALL betrue - AND
supervisorSHALL betrue - AND
from_specsSHALL betrue
Scenario: –no-rebase propagates to create_worktree as rebase_onto_main = false
- GIVEN
start --branches feat/a --no-rebaseis invoked - WHEN the dispatch reaches the worktree-creation loop
- THEN
create_worktree(repo_root, "feat/a", rebase_onto_main)SHALL be called withrebase_onto_main = false
Scenario: Default start propagates rebase_onto_main = true
- GIVEN
start --branches feat/ais invoked without--no-rebase - WHEN the dispatch reaches the worktree-creation loop
- THEN
create_worktree(repo_root, "feat/a", rebase_onto_main)SHALL be called withrebase_onto_main = true
Requirement: Stop subcommand
The stop subcommand SHALL accept an optional --force flag (boolean, defaults to false). When --force is omitted AND stdin is a TTY, cmd_stop SHALL render an interactive confirmation prompt describing the destructive nature of stop and pointing at git paw pause (soft alternative) and git paw purge (full reset). When --force is set OR stdin is not a TTY, the prompt SHALL be skipped and the stop SHALL proceed immediately.
The long_about help text for stop SHALL name all three teardown verbs (pause, stop, purge) with a one-line summary of each, so users can choose the right verb at --help time.
Scenario: Stop parses without flags
- GIVEN
stopis passed - WHEN the CLI is parsed
- THEN the command SHALL be
Command::Stop { force: false }
Scenario: Stop parses with –force
- GIVEN
stop --forceis passed - WHEN the CLI is parsed
- THEN the command SHALL be
Command::Stop { force: true }
Scenario: Stop help names all three teardown verbs
- WHEN
git paw stop --helpis run - THEN the output SHALL mention
pauseas the soft alternative - AND the output SHALL mention
purgeas the full reset - AND the output SHALL describe what
stopitself does (kills CLI processes, preserves worktrees)
Scenario: Stop with –force from a TTY skips the prompt
- GIVEN an active session and
--forceis passed - WHEN
git paw stop --forceis run with stdin attached to a TTY - THEN no interactive prompt SHALL be rendered
- AND the session SHALL be killed immediately
Requirement: Purge subcommand with optional –force flag
The purge subcommand SHALL accept an optional --force flag (defaults to false).
Scenario: Purge without –force
- GIVEN
purgeis passed without flags - WHEN the CLI is parsed
- THEN
forceSHALL befalse
Test: cli::tests::purge_without_force
Scenario: Purge with –force
- GIVEN
purge --forceis passed - WHEN the CLI is parsed
- THEN
forceSHALL betrue
Test: cli::tests::purge_with_force
Requirement: Status subcommand
The status subcommand SHALL parse with no additional arguments.
Scenario: Status parses
- GIVEN
statusis 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-clisis 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
nameSHALL be"my-agent",commandSHALL be the path, anddisplay_nameSHALL beNone
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_nameSHALL beSome("My Agent")
Test: cli::tests::add_cli_with_display_name
Scenario: Add-CLI missing required arguments is rejected
- GIVEN
add-cliwith 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
nameSHALL 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
--versionis passed - WHEN the CLI is parsed
- THEN clap SHALL emit a
DisplayVersionresponse
Test: cli::tests::version_flag_is_accepted
Scenario: –help flag is accepted
- GIVEN
--helpis passed - WHEN the CLI is parsed
- THEN clap SHALL emit a
DisplayHelpresponse
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 start --help output SHALL list the --supervisor flag with a description.
Scenario: Start help shows –supervisor flag
- GIVEN
start --helpis passed - WHEN the binary runs
- THEN stdout SHALL contain
--supervisor
Requirement: Version output includes binary name
The --version output SHALL include the binary name.
Scenario: Version output
- GIVEN
--versionis 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-pawandgit-paw startare 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
stopis 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
statusis 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-clisis 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-cliis 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-cliis 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
startis 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
Requirement: Replay subcommand
The replay subcommand SHALL accept an optional <branch> positional argument, a --list flag, a --color flag, and an optional --session flag.
Scenario: Replay with branch
- WHEN
replay feat/add-authis passed - THEN
branchSHALL beSome("feat/add-auth"),listSHALL befalse,colorSHALL befalse
Scenario: Replay with –list
- WHEN
replay --listis passed - THEN
listSHALL betrueandbranchSHALL beNone
Scenario: Replay with –color
- WHEN
replay feat/add-auth --coloris passed - THEN
colorSHALL betrue
Scenario: Replay with –session
- WHEN
replay feat/add-auth --session paw-myprojectis passed - THEN
sessionSHALL beSome("paw-myproject")
Scenario: Replay with no arguments and no –list
- WHEN
replayis passed with no arguments and no--list - THEN parsing SHALL fail with an error indicating either a branch or
--listis required
Scenario: Replay help text
- WHEN
replay --helpis passed - THEN stdout SHALL contain descriptions of
--list,--color, and--sessionflags with examples
Requirement: Init subcommand
The init subcommand SHALL parse with no required arguments.
Scenario: Init parses
- WHEN
initis passed - THEN the command SHALL be
Command::Init
Scenario: Init help text
- WHEN
init --helpis passed - THEN stdout SHALL contain a description of project initialization and examples
Requirement: Spec mode + supervisor mode dispatch
The start subcommand dispatch SHALL evaluate the supervisor-mode resolution chain (per supervisor-cli) BEFORE branching on --from-specs. Specifically:
- Resolve supervisor-mode-enabled state from the
--supervisorflag,--no-supervisorflag,[supervisor]config, and the prompt fallback. - If supervisor mode is enabled, route to
cmd_supervisor. When--from-specsis also set, passbranches_flag = Nonesocmd_supervisor’s existingscan_specs(...)fallback runs to determine branches from configured specs. - Otherwise, if
--from-specsis set, route tocmd_start_from_specs. - Otherwise, route to bare
cmd_start.
This ordering ensures --from-specs --supervisor (or --from-specs with [supervisor] enabled = true in config) actually engages supervisor mode end-to-end, rather than silently degrading to spec-mode-without-supervisor.
--from-specs combined with --branches continues to follow v0.4’s existing behaviour (the spec-mode flow ignores explicit branches when from-specs is set); this change does not introduce a new mutual-exclusion error for that combination.
Scenario: –from-specs –supervisor engages supervisor mode
- GIVEN
git paw start --from-specs --supervisoris invoked - WHEN the dispatch resolves
- THEN the supervisor-mode resolution chain SHALL evaluate
supervisor = true - AND the dispatch SHALL route to
cmd_supervisor - AND
cmd_supervisorSHALL receivebranches_flag = None, triggering itsscan_specs(...)fallback
Scenario: –from-specs without supervisor uses spec mode
- GIVEN
git paw start --from-specsis invoked, no--supervisorflag, and[supervisor]config indicates supervisor mode is not enabled (either explicitly false or absent + non-interactive) - WHEN the dispatch resolves
- THEN the dispatch SHALL route to
cmd_start_from_specs
Scenario: –from-specs with [supervisor] enabled = true config engages supervisor mode
- GIVEN
git paw start --from-specsis invoked with no--supervisorflag - AND
.git-paw/config.tomlcontains[supervisor] enabled = true - WHEN the dispatch resolves
- THEN supervisor mode SHALL be active per the resolution chain
- AND the dispatch SHALL route to
cmd_supervisor(notcmd_start_from_specs)
Scenario: –no-supervisor –from-specs uses spec mode
- GIVEN
git paw start --from-specs --no-supervisoris invoked - AND
[supervisor] enabled = trueis set in config - WHEN the dispatch resolves
- THEN supervisor mode SHALL be disabled per the resolution chain
- AND the dispatch SHALL route to
cmd_start_from_specs
Scenario: Bare start (no –from-specs, no supervisor) uses cmd_start
- GIVEN
git paw startis invoked with no--from-specs, no--supervisor, and supervisor mode is not enabled in config - WHEN the dispatch resolves
- THEN the dispatch SHALL route to
cmd_start
Requirement: Non-TTY launch handling
When a git paw start invocation reaches its session-launch step (after worktrees are created, panes added, and tmux_session.execute() succeeds), the system SHALL detect whether stdin is connected to a terminal via std::io::IsTerminal::is_terminal(&std::io::stdin()).
When stdin is not a terminal:
- The system SHALL skip the
tmux::attach(...)call. - The system SHALL print an informational message to stdout naming the launched session and the manual-attach command (
tmux attach -t <session>). - The system SHALL exit with status
0. - For supervisor mode specifically, the system SHALL also skip the foreground supervisor-CLI launch (
Command::new(supervisor_cli).status()) with an additional hint that supervisor mode requires an interactive terminal.
When stdin is a terminal, the launch flow proceeds as before (call tmux::attach, run the supervisor CLI in foreground for supervisor mode).
This SHALL apply to all three start paths: cmd_start, cmd_start_from_specs, and cmd_supervisor.
Scenario: Non-TTY bare start exits cleanly with attach hint
- GIVEN
git paw start --branches feat/x,feat/yis invoked with stdin redirected from/dev/null(or otherwise non-TTY) - WHEN the launch flow completes its session-build steps
- THEN the command SHALL exit with status
0 - AND stdout SHALL contain “Session ‘
’ started in detached mode.” - AND stdout SHALL contain “Attach with: tmux attach -t
” - AND the tmux session SHALL exist and be alive after exit
Scenario: Non-TTY –from-specs exits cleanly
- GIVEN
git paw start --from-specsis invoked from a non-TTY context - WHEN the launch flow completes
- THEN the command SHALL exit with status
0 - AND the attach-hint message SHALL be printed
- AND the tmux session SHALL exist and be alive
Scenario: Non-TTY –supervisor skips supervisor CLI launch
- GIVEN
git paw start --supervisor --from-specsis invoked from a non-TTY context - WHEN the launch flow completes
- THEN the command SHALL exit with status
0 - AND the foreground supervisor-CLI launch SHALL be skipped
- AND stdout SHALL contain a hint indicating supervisor mode requires an interactive terminal
- AND stdout SHALL contain the manual-attach command for the launched session
Scenario: TTY launch attaches as before
- GIVEN
git paw start --branches feat/x,feat/yis invoked from a real TTY - WHEN the launch flow completes its session-build steps
- THEN the system SHALL call
tmux::attach(...)for the launched session - AND SHALL NOT print the “started in detached mode” hint
Requirement: –from-all-specs flag
The start subcommand SHALL accept a --from-all-specs flag (boolean, default false). When passed, the resulting StartArgs SHALL indicate the “launch every discovered spec” mode — the v0.4 behaviour previously gated by --from-specs.
The flag SHALL appear in git paw start --help output with a description naming it as the canonical name for this behaviour.
Scenario: –from-all-specs sets the launch-all mode
- GIVEN the user invokes
git paw start --from-all-specs - WHEN the CLI is parsed
- THEN the parsed
StartArgsSHALL indicate the launch-all-discovered-specs mode
Scenario: –from-all-specs combined with –supervisor
- GIVEN
start --from-all-specs --supervisor - WHEN the CLI is parsed
- THEN both
from_all_specsandsupervisorSHALL betrue
Scenario: –from-all-specs appears in help output
- WHEN
git paw start --helpis run - THEN the output contains
--from-all-specs - AND the output describes the flag as launching every discovered spec
Requirement: –from-specs is a hidden alias of –from-all-specs
The start subcommand SHALL accept --from-specs as a hidden alias of --from-all-specs. When the user passes --from-specs, the parsed StartArgs SHALL be byte-for-byte identical to the parse result for --from-all-specs. No stderr warning SHALL be emitted at runtime; the alias is silent.
The alias SHALL NOT appear in git paw start --help output. The alias SHALL be removed in v1.0.0; v0.5.0 keeps it for backward compatibility with v0.4 scripts.
Scenario: –from-specs parses identically to –from-all-specs
- GIVEN two CLI invocations:
start --from-specsandstart --from-all-specs - WHEN both are parsed
- THEN the resulting
StartArgsvalues SHALL be equal
Scenario: –from-specs does not appear in help
- WHEN
git paw start --helpis run - THEN the output SHALL NOT contain the substring
--from-specs
Scenario: –from-specs emits no stderr warning
- GIVEN the user runs a command containing
--from-specs - WHEN the CLI parses
- THEN no stderr warning SHALL be emitted regarding the flag’s deprecation
- AND the command proceeds exactly as if
--from-all-specshad been passed
Requirement: –specs flag with comma-separated values
The start subcommand SHALL accept a --specs flag whose value is a comma-separated list of spec names (mirroring the existing --branches feat/a,feat/b syntax). The flag SHALL accept zero or more values:
--specs(no values) — indicates the picker mode.--specs NAME— narrows to a single named spec.--specs NAME1,NAME2,NAME3— narrows to the listed specs.--specs NAME1,NAME2 --specs NAME3— equivalent to--specs NAME1,NAME2,NAME3if clap’s value-accumulation across repetitions is enabled (implementation choice; tests assert behaviour for the comma-separated form).
The parsed value distinguishes three states:
- Flag absent → no spec mode requested.
- Flag present with zero values → picker mode.
- Flag present with one or more values → narrow mode with the listed names.
The flag SHALL appear in git paw start --help output.
Scenario: –specs with single value parses as narrow
- GIVEN
start --specs add-auth - WHEN the CLI is parsed
- THEN
StartArgsSHALL indicate narrow mode with["add-auth"]
Scenario: –specs with comma-separated values parses as narrow with multiple names
- GIVEN
start --specs add-auth,fix-session,add-logging - WHEN the CLI is parsed
- THEN
StartArgsSHALL indicate narrow mode with["add-auth", "fix-session", "add-logging"]
Scenario: –specs with no values parses as picker
- GIVEN
start --specs - WHEN the CLI is parsed
- THEN
StartArgsSHALL indicate picker mode
Scenario: –specs absent leaves spec mode unset
- GIVEN
start --supervisor(no--specs, no--from-all-specs) - WHEN the CLI is parsed
- THEN
StartArgsSHALL indicate no spec mode (falls through to standard branch selection)
Requirement: –from-all-specs and –specs are mutually exclusive
The system SHALL reject any invocation that combines --from-all-specs (or its alias --from-specs) with --specs. clap’s parse step SHALL produce an error before the command runs. The error message SHALL clearly state that the two flags express opposing intents and SHALL list both flags.
Scenario: –from-all-specs and –specs together are rejected
- GIVEN
start --from-all-specs --specs add-auth - WHEN the CLI is parsed
- THEN parsing SHALL fail with an error mentioning both
--from-all-specsand--specs
Scenario: –from-specs alias and –specs together are also rejected
- GIVEN
start --from-specs --specs add-auth - WHEN the CLI is parsed
- THEN parsing SHALL fail with an error mentioning both flags
- AND the alias SHALL enforce the same mutual-exclusion rule as the canonical name
Requirement: –no-supervisor flag
The start subcommand SHALL accept a --no-supervisor flag (boolean, default false). When passed, the parsed StartArgs SHALL have no_supervisor: bool set to true. The flag SHALL appear in git paw start --help output with a description that names the use case (overriding [supervisor] enabled = true for a single session).
Scenario: –no-supervisor sets the flag
- GIVEN the user invokes
git paw start --no-supervisor - WHEN the CLI is parsed
- THEN the parsed
StartArgs.no_supervisorSHALL betrue - AND
StartArgs.supervisorSHALL befalse
Scenario: –no-supervisor absent leaves flag false
- GIVEN the user invokes
git paw startwith neither--supervisornor--no-supervisor - WHEN the CLI is parsed
- THEN
StartArgs.no_supervisorSHALL befalse - AND
StartArgs.supervisorSHALL befalse
Scenario: –no-supervisor appears in help output
- WHEN
git paw start --helpis run - THEN the output contains
--no-supervisor - AND the output describes the flag as disabling supervisor for the session and overriding any
[supervisor] enabled = trueconfig setting
Requirement: –supervisor and –no-supervisor are mutually exclusive
The system SHALL reject any invocation that combines --supervisor and --no-supervisor on the same start command. clap’s parse step SHALL produce an error before the command runs. The error message SHALL clearly state that the two flags express opposing intents and SHALL list both.
Scenario: Both flags together are rejected
- GIVEN
start --supervisor --no-supervisor - WHEN the CLI is parsed
- THEN parsing SHALL fail with an error mentioning both
--supervisorand--no-supervisor
Scenario: –no-supervisor combines with other flags
- GIVEN
start --no-supervisor --cli claude --branches feat/a,feat/b - WHEN the CLI is parsed
- THEN
no_supervisorSHALL betrue - AND
cliSHALL beSome("claude") - AND
branchesSHALL containfeat/aandfeat/b - AND parsing SHALL succeed
Requirement: Pause subcommand
The pause subcommand SHALL parse with no additional arguments and SHALL be visible in git paw --help output. The subcommand SHALL include an about string (“Pause the session (detaches client, stops broker, leaves CLIs running)”) and a long_about string that names the RAM trade-off and points the reader at stop and (forthcoming v1.0.0) hibernate for the destructive and RAM-free alternatives respectively.
The pause subcommand SHALL appear in the root after_help quick-start guide alongside start, stop, and purge.
Scenario: Pause parses
- GIVEN
pauseis passed - WHEN the CLI is parsed
- THEN the command SHALL be
Command::Pause
Scenario: Pause accepts no flags
- GIVEN
pause --anythingis passed (any flag) - WHEN the CLI is parsed
- THEN parsing SHALL fail with an unknown-argument error
Scenario: Pause appears in help
- WHEN
git paw --helpis run - THEN the output SHALL list a
pausesubcommand - AND the output SHALL include the
pauseline in the quick-startafter_helpblock
Scenario: Pause help text names the RAM trade-off
- WHEN
git paw pause --helpis run - THEN the output SHALL mention that CLI processes remain running
- AND the output SHALL mention the RAM-allocation trade-off (or words conveying “RAM stays held”)
- AND the output SHALL suggest
git paw stopfor the RAM-releasing alternative
Requirement: git paw purge interactive confirmation SHALL honour y+Enter under all conditions
The cmd_purge interactive confirmation prompt SHALL be reliably readable by the dialoguer Confirm widget regardless of preceding stderr output. When the unmerged-commits warning has been written to stderr immediately before the prompt, the warning writer SHALL flush stderr before the prompt’s interact() call begins, so the user’s y+Enter input is not racing the warning’s buffered bytes.
Scenario: Purge with unmerged commits and y+Enter proceeds
- GIVEN a session with at least one branch carrying commits not in
main - AND
git paw purgeis invoked from a TTY - WHEN the prompt “Purge is irreversible. Continue?” appears and the user types
yfollowed by Enter - THEN the purge SHALL proceed (kill tmux session + remove worktrees + delete session JSON)
- AND the exit code SHALL be 0
- AND stdout SHALL contain
Purged session 'paw-...'
Scenario: Purge with unmerged commits and n+Enter cancels
- GIVEN same setup as above
- WHEN the user types
nfollowed by Enter - THEN the purge SHALL NOT proceed
- AND the exit code SHALL be 0
- AND stdout SHALL contain
Purge cancelled. - AND the session worktrees SHALL still be on disk
Scenario: Purge with bare Enter (no y/n) defaults to no
- GIVEN same setup
- WHEN the user types Enter without first typing
yorn - THEN the prompt SHALL default to false (No)
- AND the purge SHALL NOT proceed
- AND stdout SHALL contain
Purge cancelled.
Requirement: git paw purge --force SHALL propagate --force to git worktree remove and emit per-worktree progress
When git paw purge is invoked with --force, the underlying git worktree remove invocations SHALL pass --force so the removal succeeds on worktrees with uncommitted changes. The command SHALL also emit per-worktree progress messages to stderr (e.g. Removing worktree <path>... before each removal and done (<elapsed>s) after) so the user can distinguish a slow-but-progressing removal from an actual hang.
Scenario: purge --force removes dirty worktrees
- GIVEN a session with one worktree containing uncommitted edits
- WHEN
git paw purge --forceis invoked - THEN the dirty worktree SHALL be removed successfully
- AND the exit code SHALL be 0
- AND the underlying
git worktree removeinvocation SHALL include the--forceflag
Scenario: purge --force emits per-worktree progress to stderr
- GIVEN a session with two or more worktrees
- WHEN
git paw purge --forceis invoked - THEN stderr SHALL contain a
Removing worktree <path>...line for each worktree being removed - AND stderr SHALL contain a
doneor completion marker after each removal - AND the order SHALL match the worktree iteration order
Scenario: purge without --force does NOT pass --force to git worktree remove
- GIVEN a session with one worktree containing uncommitted edits
- WHEN
git paw purge(no--force) is invoked and the user confirms withy - THEN the underlying
git worktree removeSHALL NOT include the--forceflag - AND if
git worktree removefails because of the dirty state, the failure SHALL be reported to stderr aswarning: failed to remove worktree '<path>': <git error>per the existing error-handling path - AND purge SHALL continue with the remaining worktrees
Requirement: git paw stop and git paw purge SHALL strip the supervisor boot-block injection from AGENTS.md
cmd_stop and cmd_purge (src/main.rs) SHALL invoke a helper that removes the supervisor-pane boot-block injection block from <repo>/AGENTS.md. The block is bounded by HTML comment markers <!-- git-paw:start --> … <!-- git-paw:end --> (or similar — the actual marker strings are owned by the injection code path and SHALL match exactly). The helper SHALL be idempotent and SHALL preserve all surrounding content byte-for-byte.
Scenario: Stop strips the boot-block injection
- GIVEN a session in which
cmd_supervisororcmd_startinjected a<!-- git-paw:start -->…<!-- git-paw:end -->block intoAGENTS.md - WHEN
git paw stop(with or without--force) is invoked - AND the teardown completes successfully
- THEN the resulting
AGENTS.mdSHALL contain no<!-- git-paw:start -->marker - AND no
<!-- git-paw:end -->marker
Scenario: Purge strips the boot-block injection
- GIVEN the same setup
- WHEN
git paw purge(with or without--force) is invoked - THEN
AGENTS.mdSHALL contain neither marker after the purge completes
Scenario: Stop/purge on AGENTS.md without markers is a no-op
- GIVEN an
AGENTS.mdwith no<!-- git-paw:start -->marker - WHEN
git paw stoporgit paw purgeruns the cleanup helper - THEN
AGENTS.mdSHALL be byte-identical to its pre-cleanup state - AND the helper SHALL return success
Requirement: git paw init SHALL be idempotent and additive on existing config files
src/init.rs::run_init SHALL parse the existing .git-paw/config.toml (if any) and compare its top-level keys/tables against the bundled-default schema. The init flow SHALL append commented stanzas ONLY for keys/tables missing from the user’s config. It SHALL NEVER:
- Modify the value of an existing key.
- Add a second occurrence of any top-level table (e.g. a second
[supervisor]) when the user already has that section commented OR uncommented. - Re-order or reformat existing keys/sections.
- Strip existing user comments or blank lines.
When every bundled-default key is already present in the user’s config, init SHALL print config.toml already has all default keys; no changes and return Ok without writing.
Init invocations SHALL be idempotent: running git paw init a second time on a config that the first run produced SHALL leave the file byte-identical.
Scenario: First init writes a complete commented default config
- GIVEN a fresh repo with no
.git-paw/config.toml - WHEN
git paw initis invoked - THEN the file SHALL be created
- AND SHALL parse as valid TOML
- AND SHALL contain commented stanzas for every bundled-default top-level key/section
Scenario: Second init on the just-written file is a no-op
- GIVEN the same repo after the first init
- WHEN
git paw initis invoked again - THEN the file SHALL be byte-identical to the first-run output
- AND the exit SHALL be 0
Scenario: Init preserves a user-authored [supervisor] block
- GIVEN a
.git-paw/config.tomlcontaining only:[supervisor] enabled = true cli = "claude-oss" - WHEN
git paw initis invoked - THEN the resulting file SHALL contain
enabled = trueandcli = "claude-oss"byte-identical to the input - AND SHALL NOT contain a second
[supervisor]section header (commented or uncommented) - AND SHALL parse as valid TOML (no
duplicate keyerrors)
Scenario: Init appends missing top-level sections
- GIVEN a
.git-paw/config.tomlcontaining onlybranch_prefix = "feat/" - WHEN
git paw initis invoked - THEN the resulting file SHALL preserve
branch_prefix = "feat/"byte-identical - AND SHALL gain commented stanzas for every bundled-default section the user is missing (
[broker],[dashboard],[supervisor], etc.) - AND SHALL parse as valid TOML
Scenario: Init never modifies existing user values
- GIVEN a
.git-paw/config.tomlwith[broker] port = 9200(non-default port) - WHEN
git paw initis invoked - THEN the resulting file SHALL still have
port = 9200 - AND SHALL NOT introduce a second
portkey or a commented# port = 9119stanza inside[broker]
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
CliInfofor each binary withsource = Detected, a non-emptydisplay_name, and a validpath
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_namewith 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_nameSHALL 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::DetectedandCliSource::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
Okwith 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.
The create_worktree function SHALL accept a rebase_onto_main: bool parameter. When rebase_onto_main is true AND the target branch already exists in the local repository, the function SHALL rebase the target branch onto the repository’s default branch (as returned by default_branch()) BEFORE performing the existence check for the worktree directory. The rebase SHALL be performed by invoking git rebase <default-branch> from the repository root. When the branch is already at or ahead of the default branch, git rebase exits zero with no rewrite; the function SHALL treat that as success.
If git rebase exits non-zero (rebase conflict or any other failure), the function SHALL invoke git rebase --abort in the repository root and return Err(PawError::WorktreeError("rebase onto main failed: <stderr>")). The branch SHALL be left at its pre-rebase HEAD after the abort; the function SHALL NOT proceed to the existence check or git worktree add when the rebase failed.
If rebase_onto_main is false, the function SHALL skip the rebase block entirely and behave identically to the post-worktree-resume-fix v0.5.0 contract (idempotent existence check followed by git worktree add).
If the target branch does NOT exist in the local repository at the time create_worktree is invoked, the function SHALL skip the rebase block regardless of the rebase_onto_main value and proceed to the existing git worktree add -b <branch> fallback, which creates the branch from current HEAD (already at the default branch tip by construction).
The system SHALL be idempotent in the resume case: when create_worktree() is invoked for a branch whose worktree already exists at the expected path AND is registered with git for that branch, the function SHALL return Ok(WorktreeCreation { path, branch_created: false }) without re-running git worktree add. Idempotency is verified by parsing git worktree list --porcelain output and matching both the worktree path and the refs/heads/<branch> line. When rebase_onto_main is true, the rebase block runs BEFORE this idempotency check, so a surviving worktree’s branch ref SHALL be updated to the rebased SHA before the function returns.
If the expected path exists on disk but is NOT a git worktree registered for the specified branch (e.g. an unrelated directory, or a worktree for a different branch), the function SHALL fall through to the existing git worktree add call so the user sees the actionable fatal: '<path>' already exists error from git directly.
Scenario: Worktree created at correct path
- GIVEN a repository with a branch
feature/test - WHEN
create_worktree()is called withrebase_onto_main = false - THEN a worktree SHALL be created at
../<project>-feature-testcontaining the repository files
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)
Scenario: Resume of an existing worktree returns success without re-running git worktree add
- GIVEN a worktree already exists at
../<project>-feature-testfor branchfeature/testfrom a prior session - AND
rebase_onto_main = falseis passed - WHEN
create_worktree()is called forfeature/test - THEN the function SHALL return
Ok(WorktreeCreation { path: <expected>, branch_created: false }) - AND the existing worktree SHALL remain unchanged (HEAD SHA, working tree files, and uncommitted changes preserved)
- AND no second
git worktree addSHALL be executed
Scenario: Path exists but is not a git worktree
- GIVEN the expected worktree path
../<project>-feature-testexists as a regular directory (not registered with git) - WHEN
create_worktree()is called for branchfeature/test - THEN the function SHALL return
Err(PawError::WorktreeError)whose message contains the substringalready exists
Scenario: Path exists as a worktree but for a different branch
- GIVEN a worktree already exists at
../<project>-feature-testbut registered for branchfeature/other - WHEN
create_worktree()is called for branchfeature/test - THEN the function SHALL fall through to
git worktree addand returnErr(PawError::WorktreeError)(preserving the v0.4 contract for unrelated path collisions)
Scenario: Rebase-on-resume happy path advances branch onto current main
- GIVEN the default branch
mainhas advanced by N commits since branchfeat/examplewas created - AND
feat/exampleexists locally and is behindmainby exactly N commits with no diverging commits of its own - WHEN
create_worktree()is called forfeat/examplewithrebase_onto_main = true - THEN the function SHALL invoke
git rebase <main>againstfeat/examplein the repository root - AND the rebase SHALL succeed
- AND
feat/example’s HEAD SHA after the call SHALL be reachable frommain(i.e. include the N new commits) - AND the function SHALL return
Ok(WorktreeCreation { path: <expected>, branch_created: false })
Scenario: Rebase skipped when branch is already up-to-date
- GIVEN branch
feat/exampleexists locally and is at the same SHA asmain(no divergence) - WHEN
create_worktree()is called forfeat/examplewithrebase_onto_main = true - THEN
git rebase <main>SHALL be invoked and SHALL exit zero with no rewrite - AND
feat/example’s HEAD SHA SHALL be unchanged - AND the function SHALL return
Ok(WorktreeCreation { path: <expected>, branch_created: false }) - AND no error SHALL be returned
Scenario: Rebase conflict aborts cleanly and surfaces error
- GIVEN branch
feat/exampleandmainboth modify the same line of the same file with different content - WHEN
create_worktree()is called forfeat/examplewithrebase_onto_main = true - THEN
git rebase <main>SHALL be invoked and SHALL exit non-zero with conflict markers - AND the function SHALL invoke
git rebase --abort - AND
feat/example’s HEAD SHA after the call SHALL equal its pre-call HEAD SHA - AND no
.git/rebase-mergeor.git/rebase-applydirectory SHALL remain in the repository - AND the function SHALL return
Err(PawError::WorktreeError(msg))wheremsgcontains the substringrebase onto main failed - AND the worktree directory at
../<project>-feat-exampleSHALL NOT have been created (or, if it existed from a prior session, SHALL be unchanged)
Scenario: rebase_onto_main = false preserves v0.5 no-rebase behaviour
- GIVEN branch
feat/exampleexists locally and is behindmainby 3 commits - WHEN
create_worktree()is called forfeat/examplewithrebase_onto_main = false - THEN no
git rebaseinvocation SHALL occur - AND
feat/example’s HEAD SHA after the call SHALL equal its pre-call HEAD SHA - AND the function SHALL proceed to the existence check and (if applicable)
git worktree add, matching the post-worktree-resume-fixv0.5.0 contract exactly
Scenario: New branch creation skips rebase regardless of flag
- GIVEN branch
feat/newdoes NOT exist in the local repository - AND
rebase_onto_main = trueis passed - WHEN
create_worktree()is called forfeat/new - THEN no
git rebaseinvocation SHALL occur (there is nothing to rebase) - AND the function SHALL invoke
git worktree add -b feat/new <path>to create the branch from current HEAD - AND the function SHALL return
Ok(WorktreeCreation { path, branch_created: true })
Requirement: Remove worktrees and prune stale entries
The system SHALL force-remove a worktree and prune stale git worktree metadata. remove_worktree SHALL pass --force to git worktree remove so a worktree containing uncommitted modifications, untracked files, or both is still deleted; the function is only called from the destructive purge path, where leaving worktree directories on disk after the user already opted into a destructive operation is the wrong behaviour.
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
Scenario: Dirty worktree is force-removed
- GIVEN an existing worktree containing both a modified tracked file and an untracked file
- WHEN
remove_worktree()is called - THEN the call SHALL succeed
- AND the worktree directory SHALL be deleted from disk
- AND git SHALL no longer track the worktree
Test: git_integration::remove_worktree_force_removes_dirty_worktree
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/authandfix/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()thenremove_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
Requirement: Worktree creation produces a usable worktree path
The create_worktree function SHALL create a git worktree for the given branch and return its path. Callers MAY perform post-creation setup (such as AGENTS.md generation) using the returned path.
Scenario: Worktree created at correct path
- GIVEN a git repo and a branch name
- WHEN
create_worktree()is called - THEN it SHALL return the path to the new worktree as a sibling of the repo directory
Test: git::tests::create_worktree_at_correct_path
Scenario: Worktree creation fails for checked-out branch
- GIVEN a branch that is currently checked out
- WHEN
create_worktree()is called - THEN it SHALL return a
PawError::WorktreeError
Test: git::tests::create_worktree_errors_on_checked_out_branch
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-sessionwithpaw-app
Test: tmux::tests::session_creation_command_uses_session_name
Requirement: Session creation passes explicit dimensions for headless environments
Both session builders (the basic TmuxSessionBuilder and build_supervisor_session) SHALL emit tmux new-session with -x 200 -y 50 so the session has explicit window dimensions when created without an attached client. The user’s real terminal resizes the session on tmux attach.
Without explicit dimensions, tmux on Linux (apt-shipped tmux 3.4+) errors with size missing on subsequent split-window operations because the layout engine can’t resolve percentages without a known window size.
Additionally, immediately after the new-session command, both builders SHALL emit tmux set-option -g default-size 200x50. This pins the global default-size so subsequent split-window / resize-pane operations have a fallback size context even when no client is attached. macOS tmux honours per-session -x/-y for splits; Linux tmux 3.4+ requires the server-level fallback.
Scenario: Basic session passes -x/-y to new-session
- GIVEN any
TmuxSessionBuilderwith one or more panes - WHEN
command_strings()is invoked - THEN the first command in the output SHALL be a
new-sessioncontaining the substrings-x 200and-y 50
Scenario: Basic session sets global default-size after new-session
- GIVEN any
TmuxSessionBuilderwith one or more panes - WHEN
command_strings()is invoked - THEN the second command in the output SHALL be
set-option -g default-size 200x50
Scenario: Supervisor session passes -x/-y to new-session
- GIVEN a
build_supervisor_sessioninvocation with supervisor + dashboard + N agent panes - WHEN the command list is built
- THEN the first emitted command SHALL be a
new-sessioncontaining-x 200and-y 50
Scenario: Supervisor session sets global default-size after new-session
- GIVEN a
build_supervisor_sessioninvocation - WHEN the command list is built
- THEN the second emitted command SHALL be
set-option -g default-size 200x50
Scenario: Headless supervisor launch succeeds under socket isolation
- GIVEN a cold tmux server (no pre-existing client, isolated socket via
TMUX_TMPDIR) - WHEN
git paw start --supervisor --branches a,bis invoked - THEN the supervisor session SHALL launch successfully
- AND stderr SHALL NOT contain
Tmux error: size missing - AND the exit code SHALL be 0
Test: tmux::tests::built_session_can_be_executed_and_killed, tmux::tests::supervisor_top_row_split_50_50, and the cli_supervisor_no_config::supervisor_without_section_uses_default_when_default_cli_present integration test.
Scenario: Supervisor splits use -l <N>% syntax (tmux 3.1+) not deprecated -p <N>
- GIVEN a
build_supervisor_sessioninvocation with any agent count - WHEN the command list is built
- THEN every emitted
split-windowcommand SHALL use the-l <N>%length flag, NOT the deprecated-p <N>percentage flag
Rationale: Linux apt-tmux 3.4 (Ubuntu 24.04) emits cmd-split-window.c: "size missing" when -p cannot resolve the percentage against the parent pane’s laid-out size — on a detached server with no attached client the pane geometry is unresolved. -l <N>% resolves against the window’s -y dimension (set by new-session -x 200 -y 50) instead, which is well-defined in headless mode. macOS tmux 3.6a tolerates either form.
Test: tmux::tests::supervisor_splits_use_l_percent_not_p.
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. When broker is enabled, the builder SHALL receive an additional PaneSpec for the dashboard in pane 0, increasing the total pane count by one.
Scenario: Two agent panes plus dashboard created
- GIVEN broker is enabled and 2 agent pane specs are added
- WHEN the session is built
- THEN exactly 3 panes SHALL exist: pane 0 (dashboard) + panes 1-2 (agents)
Scenario: Two panes without broker
- GIVEN broker is disabled and 2 pane specs are added
- WHEN the session is built
- THEN exactly 2 panes SHALL exist (same as v0.2.0)
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-keyscommand SHALL containcd <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-keyscommand SHALL includeEnter
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-keysSHALL target:0.0,:0.1, and:0.2respectively
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 -Tcommands 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-statusSHALL be set totopandpane-border-formatSHALL 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 oncommand 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 oncommand 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, thenkill_session(), thenis_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(), andkill_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-optionis 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 nonexistentis 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
stopis 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 --forceis 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
statusis 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
stopis 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
statusis run - THEN it SHALL fail with “Not a git repository”
Test: e2e_tests::status_from_non_git_dir_fails
Requirement: TmuxSession supports pipe-pane command
The TmuxSession builder SHALL support queuing a pipe-pane command to attach logging to a specific pane.
Scenario: pipe-pane queued in builder
- WHEN
pipe_pane()is called on aTmuxSessionwith a pane target and log path - THEN the command queue SHALL contain a
pipe-pane -o -t <pane> "cat >> <path>"entry
Scenario: pipe-pane in dry-run output
- WHEN a session with
pipe_pane()is rendered as dry-run - THEN the output SHALL include the
tmux pipe-panecommand string
Scenario: pipe-pane executed after pane creation
- WHEN the session commands are executed
- THEN the
pipe-panecommand SHALL execute after the correspondingsplit-windowandsend-keyscommands for that pane
Requirement: TmuxSession supports session-level environment variables
The TmuxSessionBuilder SHALL support setting session-level environment variables via a set_environment(key, value) method. The resulting set-environment -t <session> <key> <value> command SHALL be emitted before any send-keys commands to ensure all panes inherit the variable.
Scenario: set_environment emits correct tmux command
- GIVEN
set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")is called on the builder - WHEN the session is built
- THEN the command queue SHALL contain
set-environment -t <session> GIT_PAW_BROKER_URL http://127.0.0.1:9119
Scenario: set_environment appears before send-keys
- GIVEN a builder with environment variables and pane specs
- WHEN the session is built
- THEN all
set-environmentcommands SHALL appear before anysend-keyscommands in the command queue
Scenario: set_environment in dry-run output
- GIVEN a builder with
set_environmentcalled - WHEN the session is rendered as dry-run
- THEN the output SHALL include the
tmux set-environmentcommand string
Scenario: Multiple environment variables
- GIVEN
set_environment("A", "1")andset_environment("B", "2")are both called - WHEN the session is built
- THEN both
set-environmentcommands SHALL appear in the command queue
Requirement: Supervisor-mode pane layout
When the tmux session is built for supervisor mode (per the supervisor-launch capability), the system SHALL produce a layout with these structural properties:
-
Top row: split horizontally 50/50 between pane 0 (supervisor agent) and pane 1 (dashboard).
-
Agent grid below: dynamically sized by agent count, with up to 5 columns per row in v0.5.0. The agent grid is a sequence of horizontal rows; each row holds up to 5 agent panes side-by-side.
-
Pane indices: pane 0 = supervisor; pane 1 = dashboard; panes 2..N+1 = coding agents in row-major order (left-to-right, top-to-bottom).
-
Vertical proportions by total-row count (top row + agent rows):
Total rows Top row height Each agent row height 2 (1-5 agents) 60% 40% 3 (6-10 agents) 40% 30% each 4 (11-15 agents) 28% 24% each 5 (16-20 agents) 28% 18% each 6 (21-25 agents) 28% 14.4% each -
Hard cap: 25 agents per session. Above 25, the system SHALL reject the launch with a clear “split into multiple sessions” error before any tmux command runs.
The layout SHALL be built using tmux split-window -h and -v with explicit percentages, then enforced via tmux resize-pane -y <pct> for the height proportions. select-layout tiled (or other auto-layouts) SHALL NOT be used for the supervisor-mode layout because they don’t preserve the predictable pane-index ordering this layout relies on.
Scenario: 5-agent supervisor layout has 1 agent row
- GIVEN a supervisor session with 5 agent branches
- WHEN the tmux layout is built
- THEN pane 0 SHALL be the supervisor at 50% of the top row’s width
- AND pane 1 SHALL be the dashboard at 50% of the top row’s width
- AND panes 2-6 SHALL be agents arranged in a single row below the top row
- AND the top row’s height SHALL be 60% and the agent row’s height SHALL be 40%
Scenario: 10-agent supervisor layout has 2 agent rows
- GIVEN a supervisor session with 10 agent branches
- WHEN the tmux layout is built
- THEN total row count SHALL be 3 (1 top + 2 agent rows)
- AND the top row’s height SHALL be 40%
- AND each agent row’s height SHALL be 30%
- AND the first agent row SHALL contain panes 2-6, the second agent row SHALL contain panes 7-11
Scenario: 20-agent supervisor layout has 4 agent rows
- GIVEN a supervisor session with 20 agent branches
- WHEN the tmux layout is built
- THEN total row count SHALL be 5 (1 top + 4 agent rows)
- AND the top row’s height SHALL be 28%
- AND each of the 4 agent rows’ height SHALL be 18%
Scenario: 26-agent supervisor session is rejected
- GIVEN 26 agent branches resolved (via specs, –branches, or a combination)
- WHEN the supervisor launch flow runs
- THEN the launch SHALL be rejected with a
PawError - AND the error message SHALL state the requested count (26), the maximum (25), and a hint suggesting
--branches <subset>for splitting into multiple sessions - AND no tmux session SHALL be created
Scenario: Pane indices match row-major order
- GIVEN a supervisor session with 7 agents
- WHEN the tmux layout is built
- THEN pane 2 SHALL be the first agent (top-left of the agent grid)
- AND pane 6 SHALL be the fifth agent (top-right of the first agent row, since
agents_per_row = 5) - AND pane 7 SHALL be the sixth agent (start of the second agent row)
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.
The session data SHALL include optional broker fields: broker_port (Option<u16>), broker_bind (Option<String>), and broker_log_path (Option<PathBuf>). These fields SHALL be omitted from the JSON when None and SHALL default to None when absent during deserialization.
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
Scenario: Saved session with broker fields round-trips
- GIVEN an active session with
broker_port = Some(9119),broker_bind = Some("127.0.0.1"),broker_log_path = Some("/path/to/broker.log") - WHEN
save_session()is called and the session is loaded back - THEN all broker fields SHALL match the original
Scenario: Session without broker fields loads successfully
- GIVEN a session JSON file saved by v0.2.0 (no broker fields)
- WHEN the session is loaded
- THEN
broker_port,broker_bind, andbroker_log_pathSHALL all beNone - AND all existing fields SHALL load correctly
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
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 returnNone
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
Session::effective_status(is_tmux_alive) SHALL combine the persisted status field with the result of is_tmux_alive to produce the runtime-effective status:
| Recorded status | tmux alive? | Effective status |
|---|---|---|
Active | yes | Active |
Active | no | Stopped |
Paused | yes | Paused |
Paused | no | Stopped |
Stopped | any | Stopped |
The rule for Paused: tmux must still be alive for the Paused state to be valid — pause’s whole purpose is to keep tmux + CLI panes running while the client is detached. If tmux died despite a recorded Paused state (e.g. tmux server crash), effective_status SHALL downgrade to Stopped, and cmd_start SHALL run the cold-recovery path (fresh CLI spawn) rather than the restart-from-pause path.
Scenario: Active + alive remains Active
- GIVEN a session with
status = Active - WHEN
effective_status(|_| true)is called - THEN it SHALL return
Active
Scenario: Active + dead downgrades to Stopped
- GIVEN a session with
status = Active - WHEN
effective_status(|_| false)is called - THEN it SHALL return
Stopped
Scenario: Paused + alive remains Paused
- GIVEN a session with
status = Paused - WHEN
effective_status(|_| true)is called - THEN it SHALL return
Paused
Scenario: Paused + dead downgrades to Stopped
- GIVEN a session with
status = Paused - WHEN
effective_status(|_| false)is called - THEN it SHALL return
Stopped
Scenario: Stopped remains Stopped regardless of tmux liveness
- GIVEN a session with
status = Stopped - WHEN
effective_statusis called with either liveness result - THEN it SHALL return
Stopped
Requirement: SessionStatus display format
The SessionStatus enum SHALL display as lowercase strings.
Scenario: SessionStatus display strings
- GIVEN
SessionStatus::ActiveandSessionStatus::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 including broker fields
- GIVEN a saved session with worktrees and broker enabled
- WHEN tmux crashes and the session is loaded from disk
- THEN it SHALL have the session name, repo path, all worktree details, AND broker_port, broker_bind, broker_log_path
Scenario: Session recovery recreates dashboard pane when broker was enabled
- GIVEN a saved session with
broker_port = Some(9119)andbroker_bind = Some("127.0.0.1") - WHEN
recover_session()is called - THEN the rebuilt tmux session SHALL have:
- Dashboard pane in pane 0 running
git-paw __dashboard GIT_PAW_BROKER_URLenvironment variable set tohttp://127.0.0.1:9119- All original worktree panes in subsequent indices
- Dashboard pane in pane 0 running
Scenario: Session recovery uses original broker config, not current config
- GIVEN a saved session with
broker_port = Some(9119) - AND current repo config has
broker.enabled = false - WHEN
recover_session()is called - THEN the dashboard pane SHALL still be created with the original broker URL
Scenario: Session recovery without original broker creates no dashboard
- GIVEN a saved session with
broker_port = None - WHEN
recover_session()is called - THEN no dashboard pane SHALL be created
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()andload_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 returnNone
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
Activestatus 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
Activestatus 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
Stoppedstatus - WHEN
effective_status()is called - THEN it SHALL return
Stoppedregardless 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
Requirement: Paused session status variant
The SessionStatus enum SHALL include a third variant Paused (alongside Active and Stopped). The serde representation SHALL serialize as the lowercase string "paused" and SHALL deserialize from the same string. The Display implementation SHALL render Paused as "paused".
The Paused state means: the tmux session is intended to remain alive, all coding-agent CLI panes are intended to remain running, the user’s tmux client is detached, and the broker is stopped. Session state files saved by v0.4.0 binaries (which only know Active and Stopped) SHALL continue to load successfully under v0.5+ binaries — the new variant only appears in files saved by v0.5+.
Scenario: Paused status serializes lowercase
- GIVEN a
Sessionwithstatus = SessionStatus::Paused - WHEN
save_session()is called and the JSON file is inspected - THEN the
"status"field SHALL be"paused"
Scenario: Paused status round-trips
- GIVEN a
Sessionwithstatus = SessionStatus::Pausedsaved to disk - WHEN the session is loaded back via
load_session() - THEN
statusSHALL beSessionStatus::Paused
Scenario: v0.4-saved sessions load under v0.5
- GIVEN a session JSON file saved by v0.4.0 (only
"active"or"stopped"in the status field) - WHEN the file is loaded by a v0.5+ binary
- THEN the load SHALL succeed
- AND the
statusfield SHALL match the original (ActiveorStopped)
Scenario: Paused Display renders lowercase
- WHEN
format!("{}", SessionStatus::Paused)is evaluated - THEN the result SHALL be
"paused"
Requirement: Dashboard pane index persisted in session state
The Session struct SHALL include an optional field dashboard_pane: Option<u32> that records the pane index of the dashboard pane within the tmux session. The field SHALL use #[serde(default, skip_serializing_if = "Option::is_none")] so v0.4-saved sessions load with None. The field SHALL be populated by the start flow when broker is enabled (typically 0 for bare-start mode and 1 for supervisor mode).
The restart-from-pause flow (specced in the broker-lifecycle delta) SHALL read this field to determine where to re-spawn the dashboard pane. When the field is None (v0.4-saved session), the restart flow SHALL default to 0.
Scenario: Dashboard pane index round-trips
- GIVEN a
Sessionwithdashboard_pane = Some(1)saved to disk - WHEN the session is loaded back
- THEN
dashboard_paneSHALL beSome(1)
Scenario: Session without dashboard_pane defaults to None on load
- GIVEN a session JSON file with no
dashboard_panefield - WHEN the session is loaded
- THEN
dashboard_paneSHALL beNone
Scenario: Dashboard pane field is omitted when None
- GIVEN a
Sessionwithdashboard_pane = None - WHEN
save_session()is called and the JSON file is inspected - THEN the JSON SHALL NOT contain a
dashboard_panefield
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, presets, and optional sections [specs], [logging], [broker], and [supervisor].
Scenario: Config with all fields populated
- GIVEN a TOML file with
default_cli,mouse, custom CLIs, presets,[broker], and[supervisor]sections - WHEN the file is loaded
- THEN all fields SHALL be correctly parsed including supervisor 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
Noneor empty collections - AND
supervisorSHALL beNone
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"andmouse = true, and repo hasdefault_cli = "gemini" - WHEN configs are merged
- THEN
default_cliSHALL be"gemini"andmouseSHALL betrue(preserved from global)
Test: config::tests::repo_config_overrides_global_scalars
Scenario: CLI maps are merged
- GIVEN global config has CLI
agent-aand repo config has CLIagent-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-meandremove-me - WHEN
remove_custom_cli("remove-me")is called - THEN only
keep-meSHALL 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
The system SHALL provide a load_config(repo_root, user_config_path) function that loads the merged PawConfig from the per-repo .git-paw/config.toml and a user-level (global) config.toml. The second parameter user_config_path: Option<&Path> SHALL control which file is read as the user-level config:
- When
user_config_pathisNone, the loader SHALL resolve the user-level config path via the platform-default helper (global_config_path()→crate::dirs::config_dir().join("git-paw/config.toml")), preserving the v0.4 production behaviour. - When
user_config_pathisSome(p), the loader SHALL readpas the user-level config and SHALL NOT consult the platform-default helper. Ifpdoes not exist on disk, the user-level side of the merge SHALL be the defaultPawConfig, exactly as if no file existed at the platform-default path.
The merge semantics on top of the user-level config (per-repo config overrides user-level for scalar fields and map entries) are unchanged from prior requirements in this capability.
Scenario: Defaults when no files exist
- GIVEN a temp directory with no config files
- AND
load_configis called withuser_config_path = None - 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.tomlwith default_cli and mouse - AND
load_configis called withuser_config_path = Some(&unused_temp_path) - 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.tomlwith two custom CLIs - AND
load_configis called withuser_config_path = Some(&unused_temp_path) - 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.tomlwith two presets - AND
load_configis called withuser_config_path = Some(&unused_temp_path) - 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.tomlwith specific values - AND
load_configis called withuser_config_path = Some(&unused_temp_path) - 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.tomlwith invalid TOML - AND
load_configis called withuser_config_path = Some(&unused_temp_path) - 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 - AND
load_configis called withuser_config_path = Some(&unused_temp_path) - WHEN
load_config()is called - THEN it SHALL return a default config
Test: config_integration::empty_config_file_is_valid
Scenario: None preserves platform-default user-config resolution
- GIVEN a repo
TempDirwith no.git-paw/config.toml - AND the platform-default user config path (
crate::dirs::config_dir().join("git-paw/config.toml")) is a readable file containing a custom CLI namedglobally-registered - WHEN
load_config(&repo, None)is called - THEN the returned
PawConfig.clisSHALL containglobally-registered - AND the loader SHALL have resolved the user-level path via
global_config_path(), exactly matching v0.4 behaviour
Test: config::tests::load_config_with_none_reads_platform_default_global
Scenario: Some(path) pins the user-level read to that path
- GIVEN a
TempDircontaining two distinct files:tmp/global-A.tomldefining custom CLIcli-Atmp/global-B.tomldefining custom CLIcli-B
- AND an unrelated CLI
cli-Cis registered at the platform-default user-config path - WHEN
load_config(&repo, Some(&tmp.join("global-A.toml")))is called - THEN the returned
PawConfig.clisSHALL containcli-A - AND it SHALL NOT contain
cli-Borcli-C
Test: config::tests::load_config_with_some_pins_global_to_override_path
Scenario: Some(nonexistent path) returns defaults for the user-level side
- GIVEN a
TempDirand a pathtmp/does-not-exist.tomlthat has never been written - AND an unrelated CLI
cli-leakis registered at the platform-default user-config path - WHEN
load_config(&repo, Some(&tmp.join("does-not-exist.toml")))is called - THEN the user-level side of the merge SHALL be the default
PawConfig - AND the returned
PawConfig.clisSHALL NOT containcli-leak - AND no error SHALL be returned (a missing user-config file is not an error)
Test: config::tests::load_config_with_some_nonexistent_returns_defaults
Scenario: Override path does not affect repo-config resolution
- GIVEN a
TempDirwith.git-paw/config.tomldefiningdefault_cli = "claude" - AND a separate path
tmp/global.tomldefiningdefault_cli = "gemini" - WHEN
load_config(&tmp, Some(&tmp.join("global.toml")))is called - THEN the repo-level
default_cli = "claude"SHALL override the user-leveldefault_cli = "gemini"per the existing repo-overrides-user merge semantics - AND the override parameter SHALL only control which user-level file is read, never the repo-level resolution
Test: config::tests::load_config_override_does_not_affect_repo_resolution
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-agentalready 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
Requirement: The system SHALL support a default_spec_cli config field
The system SHALL support a default_spec_cli field in PawConfig that specifies the CLI to use for --from-specs branches that don’t have a paw_cli override, bypassing the interactive picker.
Scenario: default_spec_cli set
- WHEN a config has
default_spec_cli = "claude" - THEN
PawConfig.default_spec_cliSHALL beSome("claude")
Scenario: default_spec_cli absent
- WHEN a config has no
default_spec_clifield - THEN
PawConfig.default_spec_cliSHALL beNone
Scenario: Merge preserves repo override
- WHEN global config has
default_spec_cli = "claude"and repo config hasdefault_spec_cli = "gemini" - THEN the merged config SHALL have
default_spec_cli = Some("gemini")
Requirement: Repo SHALL override new v0.2.0 scalar fields
Scenario: Repo overrides new v0.2.0 scalar fields
- GIVEN global config has
default_spec_cli = "claude"and repo hasdefault_spec_cli = "gemini" - WHEN configs are merged
- THEN
default_spec_cliSHALL be"gemini"
Requirement: Specs configuration section
The system SHALL support an optional [specs] section with a dir field and a type field. Field names SHALL match the spec-scanning capability and the implementation in src/config.rs::SpecsConfig.
dir: String— path (relative to the repo root) to the directory containing spec filestype: String— backend identifier (e.g."openspec","markdown"); the field is exposed asspec_typein Rust to avoid clashing with thetypekeyword and is serialised astypein TOML/JSON via#[serde(rename = "type")]
When the [specs] section is absent, the optional specs field on PawConfig SHALL be None.
Scenario: Specs section with all fields
- GIVEN a TOML file with
[specs]containingdir = "openspec/specs"andtype = "openspec" - WHEN the file is loaded
- THEN
specs.dirSHALL be"openspec/specs" - AND
specs.spec_typeSHALL be"openspec"
Scenario: Specs section defaults
- GIVEN a TOML file without a
[specs]section - WHEN the file is loaded
- THEN
specsSHALL beNone
Scenario: Round-trip preserves rename
- GIVEN a
SpecsConfig { dir: "openspec/specs".into(), spec_type: "openspec".into() } - WHEN the value is serialised to TOML and parsed back
- THEN the resulting TOML SHALL contain
type = "openspec"(notspec_type) - AND parsing SHALL succeed and reproduce the original struct
Requirement: Logging configuration section
The system SHALL support an optional [logging] section with enabled and log_dir fields.
Scenario: Logging section with all fields
- GIVEN a TOML file with
[logging]containingenabled = trueandlog_dir = ".git-paw/logs" - WHEN the file is loaded
- THEN
logging.enabledSHALL betrueandlogging.log_dirSHALL be".git-paw/logs"
Scenario: Logging section defaults
- GIVEN a TOML file without a
[logging]section - WHEN the file is loaded
- THEN
loggingSHALL beNone
Requirement: Default config generation
The system SHALL provide a function to generate a default config.toml string with active defaults and commented-out fields including the [supervisor] section.
Scenario: Generated config contains commented supervisor examples
- WHEN the default config string is generated
- THEN it SHALL contain commented-out examples for
[supervisor]withenabled,cli,test_command, andagent_approvalfields
Scenario: Generated config contains commented examples
- WHEN the default config string is generated
- THEN it SHALL contain commented-out examples for
default_spec_cli,branch_prefix,[specs],[logging],[broker], and[supervisor]
Requirement: Config round-trip with new fields
A PawConfig with v0.2.0 fields populated SHALL be identical after save and reload.
Scenario: Config with specs and logging round-trips
- GIVEN a config with
default_spec_cli,branch_prefix,specs, andloggingpopulated - WHEN saved and loaded back
- THEN it SHALL be equal to the original
Requirement: Broker configuration section
The system SHALL support an optional [broker] section with the following fields:
enabled: bool— defaults tofalsewhen the field or section is absentport: u16— defaults to9119when absentbind: String— defaults to"127.0.0.1"when absent
The BrokerConfig struct SHALL provide a url(&self) -> String method returning http://<bind>:<port>.
Scenario: Broker section with all fields
- GIVEN a TOML file with
[broker]containingenabled = true,port = 9200,bind = "127.0.0.1" - WHEN the file is loaded
- THEN
broker.enabledSHALL betrue,broker.portSHALL be9200,broker.bindSHALL be"127.0.0.1"
Scenario: Broker section defaults
- GIVEN a TOML file without a
[broker]section - WHEN the file is loaded
- THEN
brokerSHALL haveenabled = false,port = 9119,bind = "127.0.0.1"
Scenario: Partial broker section
- GIVEN a TOML file with
[broker]containing onlyenabled = true - WHEN the file is loaded
- THEN
broker.enabledSHALL betrue,broker.portSHALL be9119,broker.bindSHALL be"127.0.0.1"
Scenario: BrokerConfig url method
- GIVEN
BrokerConfig { enabled: true, port: 9200, bind: "127.0.0.1" } - WHEN
url()is called - THEN the result SHALL be
"http://127.0.0.1:9200"
Scenario: Broker config round-trips through save and load
- GIVEN a config with
[broker]fully populated - WHEN saved and loaded back
- THEN all broker fields SHALL match the original
Requirement: Internal callers SHALL preserve v0.4 behaviour by passing None
All production call sites of load_config inside the git-paw binary SHALL pass None as the user_config_path argument, so production behaviour is byte-identical to the v0.4 single-argument load_config(repo_root) API.
The Option<&Path> argument SHALL exist only to give test code a discoverable way to isolate the user-level config read from whatever exists at the dev machine’s platform-default path. No production code path SHALL pass Some(_).
Scenario: All production call sites pass None
- GIVEN the v0.5.0 source tree
- WHEN every call site of
config::load_configinsidesrc/is inspected - THEN every call SHALL be of the form
config::load_config(&repo_root, None)(modulo whitespace and the exact name of therepo_rootbinding) - AND no production call site SHALL pass
Some(_)
Test: covered by cargo build (compile-time) plus a focused grep-style assertion in src/main.rs::tests or equivalent — see tasks.md task 2.
Scenario: Production behaviour is byte-identical to v0.4
- GIVEN a v0.5.0 binary built from this change
- AND the same
.git-paw/config.tomland platform-default user config that a v0.4 binary would read - WHEN any production command that calls
load_configruns (e.g.git paw start,git paw add-cli,git paw dashboard) - THEN the merged
PawConfigthe command operates on SHALL be equal to the mergedPawConfigv0.4 would have produced
Test: behaviour preserved by construction (every production call passes None); verified by the v0.4 test suite continuing to pass unchanged plus the new load_config_with_none_reads_platform_default_global unit test.
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 alphaand--branches feature/auth,fix/apiflags - 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 alphaflag 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
--branchesflag 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::UniformandCliMode::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
CliInfowheredisplay_nameequalsbinary_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
CliInfowheredisplay_namediffers frombinary_name - WHEN formatted with
Display - THEN it SHALL render as
"DisplayName (binary_name)"
Test: interactive::tests::cli_info_display_different_names
Requirement: CLI picker with optional pre-selection
The select_cli method on the Prompter trait SHALL accept an optional default CLI name for pre-selection in the interactive picker.
Scenario: Picker with default pre-selected
- WHEN
select_cli()is called withdefault = Some("claude")and"claude"is in the CLI list - THEN the picker SHALL display with
"claude"highlighted as the default selection
Scenario: Picker without default
- WHEN
select_cli()is called withdefault = None - THEN the picker SHALL display with the first item selected (no pre-selection)
Scenario: Default CLI not in available list
- WHEN
select_cli()is called withdefault = Some("nonexistent")and that CLI is not available - THEN the picker SHALL display with no pre-selection (graceful fallback)
Requirement: Spec multi-select picker
The Prompter trait SHALL include a select_specs(&self, specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError> method that presents a multi-select picker for spec entries and returns the user’s chosen subset.
The default TerminalPrompter implementation SHALL display one row per logical spec unit (a feature in Spec Kit terms; a change in OpenSpec; a file in plain Markdown), grouping multiple SpecEntry values that decompose from the same Spec Kit feature into a single row. Selecting a row SHALL cause every SpecEntry belonging to that row’s logical unit to be returned.
Each row’s display label SHALL include the unit identifier and, for Spec Kit features that decompose into multiple worktrees, a worktree-count hint summarising the breakdown (e.g. "003-user-list — 3 worktrees: 2 [P] + 1 phase/"). For OpenSpec changes and Markdown specs, the label SHALL be the unit identifier alone (one entry → one worktree, no hint needed).
The picker SHALL behave the same way as select_branches for cancellation:
- User pressing Ctrl+C →
PawError::UserCancelled. - User confirming with zero rows selected →
PawError::UserCancelled.
Scenario: select_specs returns the chosen subset
- GIVEN 3 OpenSpec entries
add-auth,fix-session,add-logging - WHEN the user toggles
add-authandadd-loggingand presses enter - THEN
select_specsreturns aVeccontaining those two entries
Scenario: select_specs groups Spec Kit entries by feature
- GIVEN 4 SpecEntry values from a Spec Kit project: two
[P]entries (003-user-list-T009,003-user-list-T010), one consolidated entry (003-user-list-phase-2), and one entry from a different feature (004-error-handling-phase-1) - WHEN the picker renders
- THEN it displays exactly 2 rows — one per logical feature
- AND the row for feature
003-user-listshows a worktree-count hint summarising the 3 underlying entries (2 [P] + 1 phase/)
Scenario: Selecting a Spec Kit feature row pulls in all its entries
- GIVEN a picker rendering one row for feature
003-user-list(3 underlying SpecEntry values) - WHEN the user selects only that row and confirms
- THEN
select_specsreturns all 3 underlying SpecEntry values
Scenario: User cancels spec picker via Ctrl+C
- GIVEN the spec picker is open
- WHEN the user presses Ctrl+C
- THEN
select_specsreturnsErr(PawError::UserCancelled)
Scenario: User confirms with zero rows selected
- GIVEN the spec picker is open with N rows displayed
- WHEN the user confirms without toggling any row
- THEN
select_specsreturnsErr(PawError::UserCancelled)
Requirement: Spec picker requires an interactive terminal
When the start command would invoke select_specs (i.e. the user passed --specs with no values), the system SHALL detect whether stdin is connected to a terminal before invoking the picker. If stdin is NOT a terminal (CI, scripted invocation, redirected input), the system SHALL exit with an actionable error pointing at the explicit forms (--specs NAME[,NAME...] to narrow, --from-all-specs to launch every discovered spec).
The system SHALL NOT block waiting for picker input on a non-interactive stdin.
Scenario: Bare –specs in non-TTY environment exits with guidance
- GIVEN the user runs
git paw start --specswith stdin redirected (or no controlling terminal) - WHEN the start command attempts to open the picker
- THEN the command SHALL exit with a non-zero status before any picker UI is drawn
- AND the error message SHALL point the user at
--specs NAME[,NAME...]and--from-all-specs
Scenario: Bare –specs on TTY proceeds to picker
- GIVEN the user runs
git paw start --specsfrom an interactive terminal - WHEN the start command runs
- THEN the picker SHALL open
- AND no TTY-required error SHALL be emitted
Requirement: Spec name resolution for narrow mode
When --specs is passed with one or more values (narrow mode), the system SHALL resolve each value against the discovered SpecEntry set returned by scan_specs(). Resolution SHALL apply the following matching strategies in order, taking the first that succeeds:
- Exact match on
SpecEntry.id(case-sensitive). For Spec Kit, this matches a specific decomposed entry like003-user-list-T009. For OpenSpec / Markdown, it matches the change name or filename stem. - Spec Kit feature match on the feature directory prefix of the
SpecEntry.id(e.g.003-user-listmatches all entries belonging to that feature). When the value matches a Spec Kit feature unambiguously, ALL entries belonging to that feature SHALL be selected. - Spec Kit numeric prefix match (e.g.
003) matching a Spec Kit feature directory name’s leading numeric portion. The match SHALL succeed only when exactly one feature directory begins with the given prefix followed by a non-digit boundary; ambiguous prefixes SHALL be rejected (see below).
Resolution SHALL fail (and the start command SHALL exit before any worktrees are created) when:
- A value matches no
SpecEntryand no feature. - A Spec Kit numeric prefix matches more than one feature directory (ambiguous).
The resulting error SHALL list the unresolved or ambiguous names AND the discovered candidate names so the user can correct quickly.
Scenario: Exact match resolves to a single SpecEntry
- GIVEN a discovered set including OpenSpec change
add-auth - WHEN the user passes
--specs add-auth - THEN the resolved set SHALL contain exactly that one
SpecEntry
Scenario: Spec Kit feature name resolves to all decomposed entries
- GIVEN a Spec Kit feature
003-user-listdecomposing into 3 SpecEntry values (2[P]+ 1 consolidated) - WHEN the user passes
--specs 003-user-list - THEN the resolved set SHALL contain all 3 entries belonging to that feature
Scenario: Spec Kit numeric prefix resolves unambiguously
- GIVEN a Spec Kit project with a single feature directory beginning with
003-(e.g.003-user-list) - WHEN the user passes
--specs 003 - THEN the resolved set SHALL contain all entries belonging to that feature
Scenario: Ambiguous numeric prefix is rejected
- GIVEN a Spec Kit project containing both
003-user-listand003a-experiment - WHEN the user passes
--specs 003 - THEN the start command SHALL exit with an error
- AND the error message SHALL list both candidate feature names
Scenario: Unknown spec name is rejected with candidate list
- GIVEN a discovered set containing
add-auth,fix-session - WHEN the user passes
--specs no-such-spec - THEN the start command SHALL exit with an error
- AND the error message SHALL include
no-such-spec - AND the error message SHALL list
add-authandfix-sessionas candidates
Scenario: Multiple values are resolved independently
- GIVEN a discovered set including
add-auth,fix-session,add-logging - WHEN the user passes
--specs add-auth,add-logging - THEN the resolved set SHALL contain entries for
add-authandadd-logging - AND the resolved set SHALL NOT contain the entry for
fix-session
Scenario: Partial-failure batches do not partially start
- GIVEN a user passes
--specs add-auth,no-such-spec - WHEN resolution runs
- THEN the start command SHALL exit with the unknown-name error
- AND no worktrees SHALL be created
- AND the error message SHALL include
no-such-spec(the unresolved name)
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 installandapt installhints
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-clicommand
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
Requirement: SkillError variants with actionable messages
The system SHALL define a SkillError type with variants for skill loading failures. Each variant SHALL produce a user-facing message that explains the problem and suggests a remedy. SkillError SHALL be wrappable inside PawError as a variant.
The following variants SHALL exist:
UnknownSkill { name: String }— no embedded or user override found for the requested skill nameUserOverrideRead { path: PathBuf, source: std::io::Error }— a user override file exists but cannot be read
Scenario: UnknownSkill is actionable
- GIVEN
SkillError::UnknownSkill { name: "nonexistent" } - WHEN formatted with
Display - THEN the message SHALL mention the skill name
"nonexistent"and indicate no embedded default exists
Scenario: UserOverrideRead is actionable
- GIVEN
SkillError::UserOverrideRead { path: "/home/user/.config/git-paw/agent-skills/coordination.md", .. } - WHEN formatted with
Display - THEN the message SHALL include the file path and suggest checking permissions
Scenario: SkillError exit code
- GIVEN any
SkillErrorvariant wrapped inPawError - WHEN
exit_code()is called - THEN it SHALL return
1
Requirement: BrokerError variants with actionable messages
The system SHALL define a BrokerError type with variants for broker-specific failures. Each variant SHALL produce a user-facing message that explains the problem and suggests a remedy. BrokerError SHALL be wrappable inside PawError as a variant.
The following variants SHALL exist:
PortInUse { port: u16, source: std::io::Error }— the configured port is already occupied;sourcecarries the underlying bind/probeio::Errorso callers can chain or log the original causeProbeTimeout { port: u16 }— the stale-broker probe timed outBindFailed(std::io::Error)— socket bind failed for a reason other than port-in-useRuntimeFailed(std::io::Error)— tokio runtime construction failed
PortInUse.source SHALL be marked #[source] (or equivalent thiserror attribute) so it participates in std::error::Error::source() chains. The Display output SHALL NOT include the source by default — it is reserved for explicit chaining via {:?} or programmatic .source() access — to avoid duplicated diagnostics in user-facing CLI output.
Scenario: PortInUse is actionable
- GIVEN
BrokerError::PortInUse { port: 9119, source: io::Error::from(io::ErrorKind::AddrInUse) } - WHEN formatted with
Display - THEN the message SHALL mention port
9119and suggest changing[broker] portin config - AND the message SHALL NOT contain the underlying
io::ErrorDisplay text
Scenario: PortInUse exposes underlying cause
- GIVEN a
PortInUsevalue with anAddrInUsesource - WHEN
std::error::Error::source()is called on it - THEN the result SHALL be
Some(&dyn Error)referencing the wrappedio::Error
Scenario: ProbeTimeout is actionable
- GIVEN
BrokerError::ProbeTimeout { port: 9119 } - WHEN formatted with
Display - THEN the message SHALL mention the port and suggest checking for stuck processes
Scenario: BrokerError exit code
- GIVEN any
BrokerErrorvariant wrapped inPawError - WHEN
exit_code()is called - THEN it SHALL return
1