1use clap::{Parser, Subcommand, ValueEnum};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
15#[clap(rename_all = "lowercase")]
16pub enum SpecsFormat {
17 Openspec,
19 Markdown,
21 Speckit,
23}
24
25impl SpecsFormat {
26 #[must_use]
28 pub fn as_str(self) -> &'static str {
29 match self {
30 Self::Openspec => "openspec",
31 Self::Markdown => "markdown",
32 Self::Speckit => "speckit",
33 }
34 }
35}
36
37#[derive(Debug, Parser)]
40#[command(
41 name = "git-paw",
42 version,
43 about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
44 long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
45 across git worktrees from a single terminal using tmux. Each branch gets its own \
46 worktree and AI session, running in parallel.",
47 after_help = "\x1b[1mQuick Start:\x1b[0m\n\n \
48 # Launch interactive session (picks CLI and branches)\n \
49 git paw\n\n \
50 # Use Claude on specific branches\n \
51 git paw start --cli claude --branches feat/auth,feat/api\n\n \
52 # Check session status\n \
53 git paw status\n\n \
54 # Pause session (detaches client, stops broker, keeps CLIs alive)\n \
55 git paw pause\n\n \
56 # Stop session (kills CLIs, preserves worktrees for later)\n \
57 git paw stop\n\n \
58 # Remove everything\n \
59 git paw purge"
60)]
61pub struct Cli {
62 #[command(subcommand)]
64 pub command: Option<Command>,
65}
66
67#[derive(Debug, Subcommand)]
69pub enum Command {
70 #[command(
72 about = "Launch a new session or reattach to an existing one",
73 long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
74 or launches a new interactive session.\n\n\
75 By default, every existing agent branch is rebased onto the repository's \
76 default branch (whatever `origin/HEAD` tracks — typically `main`) before \
77 its worktree is opened, so agents always start from current main. Pass \
78 `--no-rebase` to skip this step and reproduce the pre-v0.6 behaviour \
79 (useful when you have local pinned SHAs or are deliberately working off a \
80 stale baseline). If the rebase hits a conflict, the affected branch is \
81 left at its pre-rebase HEAD and `git paw start` exits with an error \
82 listing the conflicting files.\n\n\
83 Examples:\n \
84 git paw start\n \
85 git paw start --cli claude\n \
86 git paw start --cli claude --branches feat/auth,feat/api\n \
87 git paw start --from-all-specs\n \
88 git paw start --from-all-specs --cli claude\n \
89 git paw start --specs add-auth,fix-session\n \
90 git paw start --specs # opens spec picker (TTY required)\n \
91 git paw start --dry-run\n \
92 git paw start --preset backend\n \
93 git paw start --supervisor # auto-approve safe prompts via [supervisor.auto_approve]\n \
94 git paw start --no-supervisor # disable supervisor for this session (overrides config)\n \
95 git paw start --no-rebase # skip rebasing agent branches onto the default branch"
96 )]
97 Start {
98 #[arg(long, help = "AI CLI to use (skips CLI picker)")]
100 cli: Option<String>,
101
102 #[arg(
104 long,
105 value_delimiter = ',',
106 help = "Comma-separated branches (skips branch picker)"
107 )]
108 branches: Option<Vec<String>>,
109
110 #[arg(
112 long,
113 alias = "from-specs",
114 help = "Launch from every discovered spec across all configured formats"
115 )]
116 from_all_specs: bool,
117
118 #[arg(
128 long,
129 value_delimiter = ',',
130 num_args = 0..,
131 conflicts_with = "from_all_specs",
132 help = "Comma-separated spec names; bare flag opens picker (TTY required)"
133 )]
134 specs: Option<Vec<String>>,
135
136 #[arg(
142 long,
143 value_enum,
144 help = "Override spec format (openspec, markdown, speckit)"
145 )]
146 specs_format: Option<SpecsFormat>,
147
148 #[arg(long, help = "Preview the session plan without executing")]
150 dry_run: bool,
151
152 #[arg(long, help = "Use a named preset from config")]
154 preset: Option<String>,
155
156 #[arg(
158 long,
159 default_value_t = false,
160 help = "Enable supervisor mode for this session"
161 )]
162 supervisor: bool,
163
164 #[arg(
166 long,
167 conflicts_with = "supervisor",
168 default_value_t = false,
169 help = "Disable supervisor for this session, overriding any [supervisor] enabled = true in config"
170 )]
171 no_supervisor: bool,
172
173 #[arg(long, help = "Bypass uncommitted-spec validation warning")]
175 force: bool,
176
177 #[arg(
188 long,
189 default_value_t = false,
190 help = "Skip rebasing existing agent branches onto the default branch before opening worktrees"
191 )]
192 no_rebase: bool,
193 },
194
195 #[command(
197 about = "Attach a new worktree + agent pane to a running session",
198 long_about = "Hot-attaches a worktree and agent pane to an already-running session — \
199 no stop/purge/restart, the other agents keep working undisturbed. The \
200 agent grid re-tiles to the layout a start of that many agents would \
201 produce, the new branch is registered in the session, and the agent boots \
202 with the same broker boot block + initial prompt a start-time agent gets.\n\n\
203 Provide a branch name, or use --from-spec to derive the branch (and CLI) \
204 from a discovered spec. Adding past the 25-agent cap is rejected. When the \
205 session is paused, the new pane starts paused too and begins on the next \
206 `git paw resume`. The supervisor (if any) discovers the new agent on its \
207 next broker poll — no restart.\n\n\
208 Examples:\n \
209 git paw add feat/new-thing\n \
210 git paw add feat/api --cli codex\n \
211 git paw add --from-spec add-export"
212 )]
213 Add {
214 #[arg(
217 required_unless_present = "from_spec",
218 help = "Branch to attach (omit when using --from-spec)"
219 )]
220 branch: Option<String>,
221
222 #[arg(
224 long,
225 help = "AI CLI for the new pane (defaults to the session's default CLI)"
226 )]
227 cli: Option<String>,
228
229 #[arg(
232 long,
233 conflicts_with = "branch",
234 help = "Derive branch + CLI from a spec (OpenSpec change, Markdown spec, or Spec Kit feature)"
235 )]
236 from_spec: Option<String>,
237 },
238
239 #[command(
241 about = "Detach a single agent from a running session",
242 long_about = "Removes one agent from an active session: closes its tmux pane, re-tiles \
243 the grid for the smaller agent count, removes its worktree (reusing \
244 `git paw purge`'s per-worktree teardown), and drops it from the session. \
245 The other agents are left untouched.\n\n\
246 Safe by default: `remove` refuses to delete a worktree with uncommitted \
247 changes (it lists what would be lost) unless you pass --force. Pass \
248 --keep-worktree to detach the pane + session entry but leave the worktree \
249 and branch on disk (this skips the uncommitted-work check, since nothing \
250 is deleted). `remove supervisor` is refused — use `git paw stop` to end \
251 the whole session. The supervisor notices the departure on its next broker \
252 poll (the agent stops heartbeating) — no restart.\n\n\
253 Examples:\n \
254 git paw remove feat/done-thing\n \
255 git paw remove feat/wip --force\n \
256 git paw remove feat/keep --keep-worktree"
257 )]
258 Remove {
259 #[arg(help = "Branch of the agent to remove")]
261 branch: String,
262
263 #[arg(
266 long,
267 help = "Leave the worktree + branch on disk; only detach the pane and session entry"
268 )]
269 keep_worktree: bool,
270
271 #[arg(
273 long,
274 help = "Remove even with uncommitted changes (bypass the safety check)"
275 )]
276 force: bool,
277 },
278
279 #[command(
281 about = "Pause the session (detaches client, stops broker, leaves CLIs running)",
282 long_about = "Detaches the tmux client and stops the broker, but leaves all CLI \
283 processes running in the background. This preserves agent conversation \
284 state for instant resume via `git paw start`. RAM stays allocated \
285 (~300 MB per Claude pane).\n\n\
286 Use pause for short breaks (lunch, meetings, end-of-day). For longer \
287 breaks, use `git paw stop` to kill the CLIs and release RAM (worktrees \
288 preserved). A future `git paw hibernate` (v1.0.0) will snapshot state \
289 to disk.\n\n\
290 Example:\n git paw pause"
291 )]
292 Pause,
293
294 #[command(
296 about = "Stop the session (kills tmux, keeps worktrees and state)",
297 long_about = "Kills the tmux session and every CLI pane process, but preserves \
298 worktrees and session state on disk. CLI conversation context is lost. \
299 Run `git paw start` later to recover the session with fresh CLI \
300 processes.\n\n\
301 Three teardown verbs:\n \
302 pause — soft stop (detach + broker stop; CLIs keep running, RAM held)\n \
303 stop — kills CLI processes; preserves worktrees on disk (this command)\n \
304 purge — full reset; removes worktrees, branches, and state\n\n\
305 `stop` prompts for confirmation in interactive terminals. Use \
306 `--force` to skip the prompt (scripts) or pipe stdin from \
307 `/dev/null` for non-interactive contexts.\n\n\
308 Examples:\n git paw stop\n git paw stop --force"
309 )]
310 Stop {
311 #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
313 force: bool,
314 },
315
316 #[command(
318 about = "Remove everything (tmux session, worktrees, and state)",
319 long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
320 session state. Requires confirmation unless --force is used.\n\n\
321 Use --stale to purge only sessions whose tmux session is gone (a stale \
322 receipt). Live sessions are left untouched, so --stale is safe in cleanup \
323 scripts. Pairing --stale with --force is a no-op (--force is redundant on \
324 a stale entry).\n\n\
325 Examples:\n git paw purge\n git paw purge --force\n git paw purge --stale"
326 )]
327 Purge {
328 #[arg(long, help = "Skip confirmation prompt")]
330 force: bool,
331 #[arg(
333 long,
334 help = "Purge only stale sessions (receipt claims active but tmux is gone); \
335 live sessions untouched"
336 )]
337 stale: bool,
338 },
339
340 #[command(
342 about = "Show session state for the current repo",
343 long_about = "Displays the current session status, branches, CLIs, and worktree paths \
344 for the repository in the current directory.\n\n\
345 Status is one of 🟢 active (tmux running), 🔵 paused, 🟡 stopped, or \
346 🔴 stale (the receipt claims active but the tmux session no longer \
347 exists — a crash or release-boundary carry-over). Run `git paw start` to \
348 self-heal a stale receipt, or `git paw purge --stale` to clear it.\n\n\
349 Pass --json for machine-readable output (the `status` field is one of \
350 active/paused/stopped/stale).\n\n\
351 Examples:\n git paw status\n git paw status --json"
352 )]
353 Status {
354 #[arg(long, help = "Emit machine-readable JSON")]
356 json: bool,
357 },
358
359 #[command(
361 about = "List detected and custom AI CLIs",
362 long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
363 registered in your config.\n\n\
364 Example:\n git paw list-clis"
365 )]
366 ListClis,
367
368 #[command(
370 about = "Register a custom AI CLI",
371 long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
372 The command can be an absolute path or a binary name on PATH.\n\n\
373 Examples:\n \
374 git paw add-cli my-agent /usr/local/bin/my-agent\n \
375 git paw add-cli my-agent my-agent --display-name \"My Agent\""
376 )]
377 AddCli {
378 #[arg(help = "Name to register the CLI as")]
380 name: String,
381
382 #[arg(help = "Command or path to the CLI binary")]
384 command: String,
385
386 #[arg(long, help = "Display name shown in prompts")]
388 display_name: Option<String>,
389 },
390
391 #[command(
393 about = "Unregister a custom AI CLI",
394 long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
395 removed — auto-detected CLIs cannot.\n\n\
396 Example:\n git paw remove-cli my-agent"
397 )]
398 RemoveCli {
399 #[arg(help = "Name of the custom CLI to remove")]
401 name: String,
402 },
403
404 #[command(
406 about = "Initialize .git-paw/ directory and configuration",
407 long_about = "Creates the .git-paw/ directory with a default config and sets up \
408 .gitignore for logs.\n\n\
409 Examples:\n git paw init"
410 )]
411 Init,
412
413 #[command(
415 hide = true,
416 name = "__dashboard",
417 about = "Internal: run the broker and dashboard in pane 0",
418 long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
419 in pane 0 of a tmux session. Not intended for direct invocation."
420 )]
421 Dashboard,
422
423 #[command(
425 about = "View captured session logs",
426 long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
427 for clean output. Use --color to view with colors via less -R.\n\n\
428 Examples:\n \
429 git paw replay --list\n \
430 git paw replay feat/add-auth\n \
431 git paw replay feat/add-auth --color\n \
432 git paw replay feat/add-auth --session paw-myproject"
433 )]
434 Replay {
435 #[arg(required_unless_present = "list", help = "Branch to replay")]
437 branch: Option<String>,
438
439 #[arg(long, help = "List available log sessions and branches")]
441 list: bool,
442
443 #[arg(long, help = "Display with colors via less -R")]
445 color: bool,
446
447 #[arg(long, help = "Session to replay from (defaults to most recent)")]
449 session: Option<String>,
450 },
451
452 #[command(
454 about = "Report manually-approved command patterns for a session",
455 long_about = "Lists the command patterns you manually approved during a session — the \
456 prompts the auto-approve preset did NOT match — sorted by how often each \
457 was approved. Each row carries a SUGGEST hint for where the pattern might \
458 be promoted: the project-local allowlist (project-specific scripts/paths) \
459 or the bundled dev-allowlist preset (general commands like `make <target>`). \
460 The suggestion is a hint, not a rule.\n\n\
461 Reads `.git-paw/sessions/<session>.manual-approvals.jsonl`. Defaults to the \
462 active session; pass --session to target another. Recording is controlled by \
463 `[supervisor] manual_approvals_log` (default on).\n\n\
464 Examples:\n \
465 git paw approvals\n \
466 git paw approvals --json\n \
467 git paw approvals --session paw-myproject\n \
468 git paw approvals --limit 5"
469 )]
470 Approvals {
471 #[arg(long, help = "Session to read from (defaults to the active session)")]
473 session: Option<String>,
474
475 #[arg(long, help = "Show at most N patterns (top N by count)")]
477 limit: Option<usize>,
478
479 #[arg(long, help = "Emit machine-readable JSON")]
481 json: bool,
482 },
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use clap::Parser;
489
490 fn parse(args: &[&str]) -> Cli {
492 let mut full = vec!["git-paw"];
493 full.extend(args);
494 Cli::try_parse_from(full).expect("failed to parse")
495 }
496
497 #[test]
500 fn no_args_defaults_to_none_command() {
501 let cli = parse(&[]);
502 assert!(
503 cli.command.is_none(),
504 "no args should yield None (handled as Start in main)"
505 );
506 }
507
508 #[test]
511 fn start_with_no_flags() {
512 let cli = parse(&["start"]);
513 match cli.command.unwrap() {
514 Command::Start {
515 cli,
516 branches,
517 from_all_specs,
518 specs,
519 specs_format,
520 dry_run,
521 preset,
522 supervisor,
523 no_supervisor,
524 force,
525 no_rebase,
526 } => {
527 assert!(cli.is_none());
528 assert!(branches.is_none());
529 assert!(!from_all_specs);
530 assert!(specs.is_none());
531 assert!(specs_format.is_none());
532 assert!(!dry_run);
533 assert!(preset.is_none());
534 assert!(!supervisor);
535 assert!(!no_supervisor);
536 assert!(!force);
537 assert!(!no_rebase);
538 }
539 other => panic!("expected Start, got {other:?}"),
540 }
541 }
542
543 #[test]
544 fn start_with_cli_flag() {
545 let cli = parse(&["start", "--cli", "claude"]);
546 match cli.command.unwrap() {
547 Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
548 other => panic!("expected Start, got {other:?}"),
549 }
550 }
551
552 #[test]
553 fn start_with_from_all_specs_sets_flag_and_leaves_specs_unset() {
554 let cli = parse(&["start", "--from-all-specs"]);
555 match cli.command.unwrap() {
556 Command::Start {
557 from_all_specs,
558 specs,
559 ..
560 } => {
561 assert!(from_all_specs);
562 assert!(specs.is_none());
563 }
564 other => panic!("expected Start, got {other:?}"),
565 }
566 }
567
568 #[test]
569 fn start_with_from_specs_alias_parses_identically_to_from_all_specs() {
570 let alias_args = parse(&["start", "--from-specs"]);
571 let canonical_args = parse(&["start", "--from-all-specs"]);
572 match (alias_args.command.unwrap(), canonical_args.command.unwrap()) {
573 (
574 Command::Start {
575 from_all_specs: a_all,
576 specs: a_specs,
577 supervisor: a_sup,
578 ..
579 },
580 Command::Start {
581 from_all_specs: c_all,
582 specs: c_specs,
583 supervisor: c_sup,
584 ..
585 },
586 ) => {
587 assert_eq!(a_all, c_all);
588 assert_eq!(a_specs, c_specs);
589 assert_eq!(a_sup, c_sup);
590 assert!(a_all);
591 }
592 other => panic!("expected two Start variants, got {other:?}"),
593 }
594 }
595
596 #[test]
597 fn start_with_bare_specs_yields_empty_vec_picker_mode() {
598 let cli = parse(&["start", "--specs"]);
599 match cli.command.unwrap() {
600 Command::Start {
601 from_all_specs,
602 specs,
603 ..
604 } => {
605 assert!(!from_all_specs);
606 assert_eq!(specs, Some(Vec::<String>::new()));
607 }
608 other => panic!("expected Start, got {other:?}"),
609 }
610 }
611
612 #[test]
613 fn start_with_specs_single_name() {
614 let cli = parse(&["start", "--specs", "add-auth"]);
615 match cli.command.unwrap() {
616 Command::Start { specs, .. } => {
617 assert_eq!(specs, Some(vec!["add-auth".to_string()]));
618 }
619 other => panic!("expected Start, got {other:?}"),
620 }
621 }
622
623 #[test]
624 fn start_with_specs_two_comma_separated_names() {
625 let cli = parse(&["start", "--specs", "add-auth,fix-session"]);
626 match cli.command.unwrap() {
627 Command::Start { specs, .. } => {
628 assert_eq!(
629 specs,
630 Some(vec!["add-auth".to_string(), "fix-session".to_string()])
631 );
632 }
633 other => panic!("expected Start, got {other:?}"),
634 }
635 }
636
637 #[test]
638 fn start_with_specs_three_comma_separated_names() {
639 let cli = parse(&["start", "--specs", "add-auth,fix-session,add-logging"]);
640 match cli.command.unwrap() {
641 Command::Start { specs, .. } => {
642 assert_eq!(
643 specs,
644 Some(vec![
645 "add-auth".to_string(),
646 "fix-session".to_string(),
647 "add-logging".to_string(),
648 ])
649 );
650 }
651 other => panic!("expected Start, got {other:?}"),
652 }
653 }
654
655 #[test]
656 fn start_with_from_all_specs_and_specs_is_rejected() {
657 let result = Cli::try_parse_from([
658 "git-paw",
659 "start",
660 "--from-all-specs",
661 "--specs",
662 "add-auth",
663 ]);
664 assert!(result.is_err());
665 let err = result.unwrap_err().to_string();
666 assert!(err.contains("--from-all-specs"), "got: {err}");
667 assert!(err.contains("--specs"), "got: {err}");
668 }
669
670 #[test]
671 fn start_with_from_specs_alias_and_specs_is_rejected() {
672 let result =
673 Cli::try_parse_from(["git-paw", "start", "--from-specs", "--specs", "add-auth"]);
674 assert!(result.is_err());
675 }
676
677 #[test]
678 fn start_with_from_all_specs_and_supervisor_sets_both_flags() {
679 let cli = parse(&["start", "--from-all-specs", "--supervisor"]);
680 match cli.command.unwrap() {
681 Command::Start {
682 from_all_specs,
683 specs,
684 supervisor,
685 ..
686 } => {
687 assert!(from_all_specs);
688 assert!(supervisor);
689 assert!(specs.is_none());
690 }
691 other => panic!("expected Start, got {other:?}"),
692 }
693 }
694
695 #[test]
696 fn start_with_supervisor_only_leaves_spec_mode_unset() {
697 let cli = parse(&["start", "--supervisor"]);
698 match cli.command.unwrap() {
699 Command::Start {
700 from_all_specs,
701 specs,
702 supervisor,
703 ..
704 } => {
705 assert!(!from_all_specs);
706 assert!(specs.is_none());
707 assert!(supervisor);
708 }
709 other => panic!("expected Start, got {other:?}"),
710 }
711 }
712
713 #[test]
714 fn start_help_contains_from_all_specs_and_specs_but_not_alias() {
715 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
716 let err = result.unwrap_err();
717 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
718 let help = err.to_string();
719 assert!(
720 help.contains("--from-all-specs"),
721 "start --help should contain --from-all-specs; got: {help}"
722 );
723 assert!(
724 help.contains("--specs"),
725 "start --help should contain --specs; got: {help}"
726 );
727 assert!(
728 !help.contains("--from-specs"),
729 "start --help should NOT contain hidden alias --from-specs; got: {help}"
730 );
731 }
732
733 #[test]
734 fn start_with_branches_flag_comma_separated() {
735 let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
736 match cli.command.unwrap() {
737 Command::Start { branches, .. } => {
738 let b = branches.expect("branches should be set");
739 assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
740 }
741 other => panic!("expected Start, got {other:?}"),
742 }
743 }
744
745 #[test]
746 fn start_with_dry_run() {
747 let cli = parse(&["start", "--dry-run"]);
748 match cli.command.unwrap() {
749 Command::Start { dry_run, .. } => assert!(dry_run),
750 other => panic!("expected Start, got {other:?}"),
751 }
752 }
753
754 #[test]
755 fn start_with_preset() {
756 let cli = parse(&["start", "--preset", "backend"]);
757 match cli.command.unwrap() {
758 Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
759 other => panic!("expected Start, got {other:?}"),
760 }
761 }
762
763 #[test]
764 fn start_with_supervisor_flag() {
765 let cli = parse(&["start", "--supervisor"]);
766 match cli.command.unwrap() {
767 Command::Start { supervisor, .. } => assert!(supervisor),
768 other => panic!("expected Start, got {other:?}"),
769 }
770 }
771
772 #[test]
773 fn start_without_supervisor_defaults_false() {
774 let cli = parse(&["start", "--cli", "claude"]);
775 match cli.command.unwrap() {
776 Command::Start { supervisor, .. } => assert!(!supervisor),
777 other => panic!("expected Start, got {other:?}"),
778 }
779 }
780
781 #[test]
782 fn start_with_supervisor_and_other_flags() {
783 let cli = parse(&[
784 "start",
785 "--supervisor",
786 "--cli",
787 "claude",
788 "--branches",
789 "feat/a,feat/b",
790 ]);
791 match cli.command.unwrap() {
792 Command::Start {
793 supervisor,
794 cli,
795 branches,
796 ..
797 } => {
798 assert!(supervisor);
799 assert_eq!(cli.as_deref(), Some("claude"));
800 assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
801 }
802 other => panic!("expected Start, got {other:?}"),
803 }
804 }
805
806 #[test]
809 fn start_with_specs_format_speckit() {
810 let cli = parse(&["start", "--from-specs", "--specs-format", "speckit"]);
811 match cli.command.unwrap() {
812 Command::Start { specs_format, .. } => {
813 assert_eq!(specs_format, Some(SpecsFormat::Speckit));
814 }
815 other => panic!("expected Start, got {other:?}"),
816 }
817 }
818
819 #[test]
820 fn start_with_specs_format_openspec() {
821 let cli = parse(&["start", "--from-specs", "--specs-format", "openspec"]);
822 match cli.command.unwrap() {
823 Command::Start { specs_format, .. } => {
824 assert_eq!(specs_format, Some(SpecsFormat::Openspec));
825 }
826 other => panic!("expected Start, got {other:?}"),
827 }
828 }
829
830 #[test]
831 fn start_with_specs_format_markdown() {
832 let cli = parse(&["start", "--from-specs", "--specs-format", "markdown"]);
833 match cli.command.unwrap() {
834 Command::Start { specs_format, .. } => {
835 assert_eq!(specs_format, Some(SpecsFormat::Markdown));
836 }
837 other => panic!("expected Start, got {other:?}"),
838 }
839 }
840
841 #[test]
842 fn start_rejects_unknown_specs_format() {
843 let result = Cli::try_parse_from([
844 "git-paw",
845 "start",
846 "--from-specs",
847 "--specs-format",
848 "unknown-value",
849 ]);
850 assert!(result.is_err(), "unknown value should be rejected");
851 let err = result.unwrap_err().to_string();
852 assert!(
853 err.contains("openspec") && err.contains("markdown") && err.contains("speckit"),
854 "error should list all three valid values, got: {err}"
855 );
856 }
857
858 #[test]
859 fn specs_format_as_str_matches_backend_names() {
860 assert_eq!(SpecsFormat::Openspec.as_str(), "openspec");
861 assert_eq!(SpecsFormat::Markdown.as_str(), "markdown");
862 assert_eq!(SpecsFormat::Speckit.as_str(), "speckit");
863 }
864
865 #[test]
866 fn start_help_shows_specs_format_flag() {
867 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
868 assert!(result.is_err());
869 let err = result.unwrap_err();
870 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
871 let help = err.to_string();
872 assert!(
873 help.contains("--specs-format"),
874 "start --help should contain --specs-format"
875 );
876 }
877
878 #[test]
879 fn start_help_shows_supervisor_flag() {
880 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
881 assert!(result.is_err());
882 let err = result.unwrap_err();
883 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
884 let help = err.to_string();
885 assert!(
886 help.contains("--supervisor"),
887 "start --help should contain --supervisor"
888 );
889 }
890
891 #[test]
894 fn start_with_no_supervisor_flag() {
895 let cli = parse(&["start", "--no-supervisor"]);
896 match cli.command.unwrap() {
897 Command::Start {
898 supervisor,
899 no_supervisor,
900 ..
901 } => {
902 assert!(no_supervisor);
903 assert!(!supervisor);
904 }
905 other => panic!("expected Start, got {other:?}"),
906 }
907 }
908
909 #[test]
910 fn start_without_flags_leaves_no_supervisor_false() {
911 let cli = parse(&["start"]);
912 match cli.command.unwrap() {
913 Command::Start {
914 supervisor,
915 no_supervisor,
916 ..
917 } => {
918 assert!(!no_supervisor);
919 assert!(!supervisor);
920 }
921 other => panic!("expected Start, got {other:?}"),
922 }
923 }
924
925 #[test]
926 fn start_with_supervisor_and_no_supervisor_is_rejected() {
927 let result = Cli::try_parse_from(["git-paw", "start", "--supervisor", "--no-supervisor"]);
928 assert!(
929 result.is_err(),
930 "--supervisor + --no-supervisor must be rejected by clap"
931 );
932 let err = result.unwrap_err();
933 let msg = err.to_string();
934 assert!(
935 msg.contains("--supervisor") && msg.contains("--no-supervisor"),
936 "error should mention both flags, got: {msg}"
937 );
938 }
939
940 #[test]
941 fn start_with_no_supervisor_and_supervisor_reversed_is_also_rejected() {
942 let result = Cli::try_parse_from(["git-paw", "start", "--no-supervisor", "--supervisor"]);
944 assert!(result.is_err());
945 }
946
947 #[test]
948 fn start_no_supervisor_combines_with_other_flags() {
949 let cli = parse(&[
950 "start",
951 "--no-supervisor",
952 "--cli",
953 "claude",
954 "--branches",
955 "feat/a,feat/b",
956 ]);
957 match cli.command.unwrap() {
958 Command::Start {
959 no_supervisor,
960 cli,
961 branches,
962 ..
963 } => {
964 assert!(no_supervisor);
965 assert_eq!(cli.as_deref(), Some("claude"));
966 assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
967 }
968 other => panic!("expected Start, got {other:?}"),
969 }
970 }
971
972 #[test]
973 fn start_help_shows_no_supervisor_flag() {
974 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
975 assert!(result.is_err());
976 let err = result.unwrap_err();
977 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
978 let help = err.to_string();
979 assert!(
980 help.contains("--no-supervisor"),
981 "start --help should contain --no-supervisor, got: {help}"
982 );
983 }
984
985 #[test]
986 fn start_with_all_flags() {
987 let cli = parse(&[
988 "start",
989 "--cli",
990 "gemini",
991 "--branches",
992 "a,b",
993 "--dry-run",
994 "--preset",
995 "dev",
996 ]);
997 match cli.command.unwrap() {
998 Command::Start {
999 cli,
1000 branches,
1001 dry_run,
1002 preset,
1003 ..
1004 } => {
1005 assert_eq!(cli.as_deref(), Some("gemini"));
1006 assert_eq!(branches.unwrap(), vec!["a", "b"]);
1007 assert!(dry_run);
1008 assert_eq!(preset.as_deref(), Some("dev"));
1009 }
1010 other => panic!("expected Start, got {other:?}"),
1011 }
1012 }
1013
1014 #[test]
1017 fn add_with_branch_only() {
1018 let cli = parse(&["add", "feat/new"]);
1019 match cli.command.unwrap() {
1020 Command::Add {
1021 branch,
1022 cli,
1023 from_spec,
1024 } => {
1025 assert_eq!(branch.as_deref(), Some("feat/new"));
1026 assert!(cli.is_none());
1027 assert!(from_spec.is_none());
1028 }
1029 other => panic!("expected Add, got {other:?}"),
1030 }
1031 }
1032
1033 #[test]
1034 fn add_with_branch_and_cli() {
1035 let cli = parse(&["add", "feat/x", "--cli", "codex"]);
1036 match cli.command.unwrap() {
1037 Command::Add { branch, cli, .. } => {
1038 assert_eq!(branch.as_deref(), Some("feat/x"));
1039 assert_eq!(cli.as_deref(), Some("codex"));
1040 }
1041 other => panic!("expected Add, got {other:?}"),
1042 }
1043 }
1044
1045 #[test]
1046 fn add_with_from_spec_only_needs_no_branch() {
1047 let cli = parse(&["add", "--from-spec", "add-export"]);
1048 match cli.command.unwrap() {
1049 Command::Add {
1050 branch, from_spec, ..
1051 } => {
1052 assert!(branch.is_none());
1053 assert_eq!(from_spec.as_deref(), Some("add-export"));
1054 }
1055 other => panic!("expected Add, got {other:?}"),
1056 }
1057 }
1058
1059 #[test]
1060 fn add_with_no_branch_and_no_from_spec_is_rejected() {
1061 let result = Cli::try_parse_from(["git-paw", "add"]);
1062 assert!(
1063 result.is_err(),
1064 "add requires either a branch or --from-spec"
1065 );
1066 }
1067
1068 #[test]
1069 fn add_with_branch_and_from_spec_is_rejected() {
1070 let result = Cli::try_parse_from(["git-paw", "add", "feat/x", "--from-spec", "change"]);
1071 assert!(
1072 result.is_err(),
1073 "branch and --from-spec are mutually exclusive"
1074 );
1075 }
1076
1077 #[test]
1078 fn add_help_lists_flags_and_examples() {
1079 let result = Cli::try_parse_from(["git-paw", "add", "--help"]);
1080 let err = result.unwrap_err();
1081 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1082 let help = err.to_string();
1083 assert!(help.contains("--cli"), "got: {help}");
1084 assert!(help.contains("--from-spec"), "got: {help}");
1085 assert!(
1086 help.contains("git paw add feat/api --cli codex"),
1087 "add --help should include copy-pasteable examples; got: {help}"
1088 );
1089 }
1090
1091 #[test]
1094 fn remove_with_branch_only() {
1095 let cli = parse(&["remove", "feat/done"]);
1096 match cli.command.unwrap() {
1097 Command::Remove {
1098 branch,
1099 keep_worktree,
1100 force,
1101 } => {
1102 assert_eq!(branch, "feat/done");
1103 assert!(!keep_worktree);
1104 assert!(!force);
1105 }
1106 other => panic!("expected Remove, got {other:?}"),
1107 }
1108 }
1109
1110 #[test]
1111 fn remove_with_keep_worktree_and_force() {
1112 let cli = parse(&["remove", "feat/x", "--keep-worktree", "--force"]);
1113 match cli.command.unwrap() {
1114 Command::Remove {
1115 keep_worktree,
1116 force,
1117 ..
1118 } => {
1119 assert!(keep_worktree);
1120 assert!(force);
1121 }
1122 other => panic!("expected Remove, got {other:?}"),
1123 }
1124 }
1125
1126 #[test]
1127 fn remove_without_branch_is_rejected() {
1128 let result = Cli::try_parse_from(["git-paw", "remove"]);
1129 assert!(result.is_err(), "remove requires a branch");
1130 }
1131
1132 #[test]
1133 fn remove_help_lists_flags_and_examples() {
1134 let result = Cli::try_parse_from(["git-paw", "remove", "--help"]);
1135 let err = result.unwrap_err();
1136 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1137 let help = err.to_string();
1138 assert!(help.contains("--keep-worktree"), "got: {help}");
1139 assert!(help.contains("--force"), "got: {help}");
1140 assert!(
1141 help.contains("git paw remove feat/wip --force"),
1142 "remove --help should include copy-pasteable examples; got: {help}"
1143 );
1144 }
1145
1146 #[test]
1147 fn root_help_lists_add_and_remove() {
1148 let result = Cli::try_parse_from(["git-paw", "--help"]);
1149 let help = result.unwrap_err().to_string();
1150 assert!(
1151 help.contains("add"),
1152 "root help should list add; got: {help}"
1153 );
1154 assert!(
1155 help.contains("remove"),
1156 "root help should list remove; got: {help}"
1157 );
1158 }
1159
1160 #[test]
1163 fn pause_parses() {
1164 let cli = parse(&["pause"]);
1165 assert!(matches!(cli.command.unwrap(), Command::Pause));
1166 }
1167
1168 #[test]
1169 fn pause_help_mentions_ram_tradeoff() {
1170 let result = Cli::try_parse_from(["git-paw", "pause", "--help"]);
1171 assert!(result.is_err());
1172 let err = result.unwrap_err();
1173 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1174 let help = err.to_string();
1175 assert!(
1176 help.to_lowercase().contains("ram"),
1177 "pause --help should mention RAM tradeoff, got: {help}"
1178 );
1179 assert!(
1180 help.contains("stop"),
1181 "pause --help should cross-reference stop, got: {help}"
1182 );
1183 }
1184
1185 #[test]
1186 fn pause_rejects_unknown_flags() {
1187 let result = Cli::try_parse_from(["git-paw", "pause", "--anything"]);
1188 assert!(result.is_err(), "pause should reject unknown flags");
1189 }
1190
1191 #[test]
1192 fn root_help_lists_pause() {
1193 let result = Cli::try_parse_from(["git-paw", "--help"]);
1194 assert!(result.is_err());
1195 let err = result.unwrap_err();
1196 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1197 let help = err.to_string();
1198 assert!(
1199 help.contains("pause"),
1200 "root --help should list pause subcommand, got: {help}"
1201 );
1202 assert!(
1204 help.contains("git paw pause"),
1205 "after_help quick-start should mention `git paw pause`"
1206 );
1207 }
1208
1209 #[test]
1212 fn stop_parses() {
1213 let cli = parse(&["stop"]);
1214 assert!(matches!(
1215 cli.command.unwrap(),
1216 Command::Stop { force: false }
1217 ));
1218 }
1219
1220 #[test]
1221 fn stop_without_force() {
1222 let cli = parse(&["stop"]);
1223 match cli.command.unwrap() {
1224 Command::Stop { force } => assert!(!force),
1225 other => panic!("expected Stop, got {other:?}"),
1226 }
1227 }
1228
1229 #[test]
1230 fn stop_with_force() {
1231 let cli = parse(&["stop", "--force"]);
1232 match cli.command.unwrap() {
1233 Command::Stop { force } => assert!(force),
1234 other => panic!("expected Stop, got {other:?}"),
1235 }
1236 }
1237
1238 #[test]
1239 fn stop_help_mentions_pause_and_purge() {
1240 let result = Cli::try_parse_from(["git-paw", "stop", "--help"]);
1241 assert!(result.is_err());
1242 let err = result.unwrap_err();
1243 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1244 let help = err.to_string();
1245 assert!(
1246 help.contains("pause"),
1247 "stop --help should reference pause, got: {help}"
1248 );
1249 assert!(
1250 help.contains("purge"),
1251 "stop --help should reference purge, got: {help}"
1252 );
1253 assert!(
1254 help.contains("--force"),
1255 "stop --help should list --force, got: {help}"
1256 );
1257 }
1258
1259 #[test]
1262 fn purge_without_force() {
1263 let cli = parse(&["purge"]);
1264 match cli.command.unwrap() {
1265 Command::Purge { force, stale } => {
1266 assert!(!force);
1267 assert!(!stale);
1268 }
1269 other => panic!("expected Purge, got {other:?}"),
1270 }
1271 }
1272
1273 #[test]
1274 fn purge_with_force() {
1275 let cli = parse(&["purge", "--force"]);
1276 match cli.command.unwrap() {
1277 Command::Purge { force, stale } => {
1278 assert!(force);
1279 assert!(!stale);
1280 }
1281 other => panic!("expected Purge, got {other:?}"),
1282 }
1283 }
1284
1285 #[test]
1286 fn purge_with_stale() {
1287 let cli = parse(&["purge", "--stale"]);
1288 match cli.command.unwrap() {
1289 Command::Purge { force, stale } => {
1290 assert!(!force);
1291 assert!(stale);
1292 }
1293 other => panic!("expected Purge, got {other:?}"),
1294 }
1295 }
1296
1297 #[test]
1298 fn purge_with_stale_and_force() {
1299 let cli = parse(&["purge", "--stale", "--force"]);
1300 match cli.command.unwrap() {
1301 Command::Purge { force, stale } => {
1302 assert!(force);
1303 assert!(stale);
1304 }
1305 other => panic!("expected Purge, got {other:?}"),
1306 }
1307 }
1308
1309 #[test]
1312 fn status_parses() {
1313 let cli = parse(&["status"]);
1314 match cli.command.unwrap() {
1315 Command::Status { json } => assert!(!json),
1316 other => panic!("expected Status, got {other:?}"),
1317 }
1318 }
1319
1320 #[test]
1321 fn status_with_json() {
1322 let cli = parse(&["status", "--json"]);
1323 match cli.command.unwrap() {
1324 Command::Status { json } => assert!(json),
1325 other => panic!("expected Status, got {other:?}"),
1326 }
1327 }
1328
1329 #[test]
1332 fn list_clis_parses() {
1333 let cli = parse(&["list-clis"]);
1334 assert!(matches!(cli.command.unwrap(), Command::ListClis));
1335 }
1336
1337 #[test]
1340 fn add_cli_with_required_args() {
1341 let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
1342 match cli.command.unwrap() {
1343 Command::AddCli {
1344 name,
1345 command,
1346 display_name,
1347 } => {
1348 assert_eq!(name, "my-agent");
1349 assert_eq!(command, "/usr/local/bin/my-agent");
1350 assert!(display_name.is_none());
1351 }
1352 other => panic!("expected AddCli, got {other:?}"),
1353 }
1354 }
1355
1356 #[test]
1357 fn add_cli_with_display_name() {
1358 let cli = parse(&[
1359 "add-cli",
1360 "my-agent",
1361 "my-agent",
1362 "--display-name",
1363 "My Agent",
1364 ]);
1365 match cli.command.unwrap() {
1366 Command::AddCli {
1367 name,
1368 command,
1369 display_name,
1370 } => {
1371 assert_eq!(name, "my-agent");
1372 assert_eq!(command, "my-agent");
1373 assert_eq!(display_name.as_deref(), Some("My Agent"));
1374 }
1375 other => panic!("expected AddCli, got {other:?}"),
1376 }
1377 }
1378
1379 #[test]
1382 fn remove_cli_parses() {
1383 let cli = parse(&["remove-cli", "my-agent"]);
1384 match cli.command.unwrap() {
1385 Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
1386 other => panic!("expected RemoveCli, got {other:?}"),
1387 }
1388 }
1389
1390 #[test]
1393 fn version_flag_is_accepted() {
1394 let result = Cli::try_parse_from(["git-paw", "--version"]);
1395 assert!(result.is_err());
1397 let err = result.unwrap_err();
1398 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
1399 }
1400
1401 #[test]
1402 fn help_flag_is_accepted() {
1403 let result = Cli::try_parse_from(["git-paw", "--help"]);
1404 assert!(result.is_err());
1405 let err = result.unwrap_err();
1406 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1407 }
1408
1409 #[test]
1412 fn init_parses() {
1413 let cli = parse(&["init"]);
1414 assert!(matches!(cli.command.unwrap(), Command::Init));
1415 }
1416
1417 #[test]
1420 fn init_help_text() {
1421 let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
1422 assert!(result.is_err());
1423 let err = result.unwrap_err();
1424 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1425 }
1426
1427 #[test]
1428 fn unknown_subcommand_is_rejected() {
1429 let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
1430 assert!(result.is_err());
1431 }
1432
1433 #[test]
1434 fn add_cli_missing_required_args_is_rejected() {
1435 let result = Cli::try_parse_from(["git-paw", "add-cli"]);
1436 assert!(result.is_err());
1437 }
1438
1439 #[test]
1442 fn replay_with_branch() {
1443 let cli = parse(&["replay", "feat/add-auth"]);
1444 match cli.command.unwrap() {
1445 Command::Replay {
1446 branch,
1447 list,
1448 color,
1449 session,
1450 } => {
1451 assert_eq!(branch.as_deref(), Some("feat/add-auth"));
1452 assert!(!list);
1453 assert!(!color);
1454 assert!(session.is_none());
1455 }
1456 other => panic!("expected Replay, got {other:?}"),
1457 }
1458 }
1459
1460 #[test]
1461 fn replay_with_list() {
1462 let cli = parse(&["replay", "--list"]);
1463 match cli.command.unwrap() {
1464 Command::Replay { branch, list, .. } => {
1465 assert!(list);
1466 assert!(branch.is_none());
1467 }
1468 other => panic!("expected Replay, got {other:?}"),
1469 }
1470 }
1471
1472 #[test]
1473 fn replay_with_color() {
1474 let cli = parse(&["replay", "feat/add-auth", "--color"]);
1475 match cli.command.unwrap() {
1476 Command::Replay { color, .. } => assert!(color),
1477 other => panic!("expected Replay, got {other:?}"),
1478 }
1479 }
1480
1481 #[test]
1482 fn replay_with_session() {
1483 let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
1484 match cli.command.unwrap() {
1485 Command::Replay { session, .. } => {
1486 assert_eq!(session.as_deref(), Some("paw-myproject"));
1487 }
1488 other => panic!("expected Replay, got {other:?}"),
1489 }
1490 }
1491
1492 #[test]
1493 fn replay_no_args_fails() {
1494 let result = Cli::try_parse_from(["git-paw", "replay"]);
1495 assert!(result.is_err());
1496 }
1497
1498 #[test]
1499 fn replay_help_text() {
1500 let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
1501 assert!(result.is_err());
1502 let err = result.unwrap_err();
1503 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1504 let help = err.to_string();
1505 assert!(help.contains("--list"));
1506 assert!(help.contains("--color"));
1507 assert!(help.contains("--session"));
1508 }
1509
1510 #[test]
1511 fn help_shows_replay_subcommand() {
1512 let result = Cli::try_parse_from(["git-paw", "--help"]);
1513 let err = result.unwrap_err();
1514 let help = err.to_string();
1515 assert!(
1516 help.contains("replay"),
1517 "help should list the replay subcommand"
1518 );
1519 }
1520
1521 #[test]
1524 fn dashboard_parses() {
1525 let cli = parse(&["__dashboard"]);
1526 assert!(matches!(cli.command.unwrap(), Command::Dashboard));
1527 }
1528
1529 #[test]
1532 fn start_with_no_rebase_flag_sets_no_rebase_true() {
1533 let cli = parse(&["start", "--no-rebase"]);
1534 match cli.command.unwrap() {
1535 Command::Start { no_rebase, .. } => assert!(no_rebase),
1536 other => panic!("expected Start, got {other:?}"),
1537 }
1538 }
1539
1540 #[test]
1541 fn start_without_no_rebase_defaults_to_false() {
1542 let cli = parse(&["start"]);
1543 match cli.command.unwrap() {
1544 Command::Start { no_rebase, .. } => assert!(!no_rebase),
1545 other => panic!("expected Start, got {other:?}"),
1546 }
1547 }
1548
1549 #[test]
1550 fn start_no_rebase_combines_with_supervisor() {
1551 let cli = parse(&["start", "--no-rebase", "--supervisor"]);
1552 match cli.command.unwrap() {
1553 Command::Start {
1554 no_rebase,
1555 supervisor,
1556 ..
1557 } => {
1558 assert!(no_rebase);
1559 assert!(supervisor);
1560 }
1561 other => panic!("expected Start, got {other:?}"),
1562 }
1563 }
1564
1565 #[test]
1566 fn start_help_shows_no_rebase_flag() {
1567 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
1568 assert!(result.is_err());
1569 let err = result.unwrap_err();
1570 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1571 let help = err.to_string();
1572 assert!(
1573 help.contains("--no-rebase"),
1574 "start --help should contain --no-rebase, got: {help}"
1575 );
1576 }
1577
1578 #[test]
1581 fn approvals_parses_with_no_flags() {
1582 let cli = parse(&["approvals"]);
1583 match cli.command.unwrap() {
1584 Command::Approvals {
1585 session,
1586 limit,
1587 json,
1588 } => {
1589 assert!(session.is_none());
1590 assert!(limit.is_none());
1591 assert!(!json);
1592 }
1593 other => panic!("expected Approvals, got {other:?}"),
1594 }
1595 }
1596
1597 #[test]
1598 fn approvals_with_session_limit_and_json() {
1599 let cli = parse(&[
1600 "approvals",
1601 "--session",
1602 "paw-other",
1603 "--limit",
1604 "5",
1605 "--json",
1606 ]);
1607 match cli.command.unwrap() {
1608 Command::Approvals {
1609 session,
1610 limit,
1611 json,
1612 } => {
1613 assert_eq!(session.as_deref(), Some("paw-other"));
1614 assert_eq!(limit, Some(5));
1615 assert!(json);
1616 }
1617 other => panic!("expected Approvals, got {other:?}"),
1618 }
1619 }
1620
1621 #[test]
1622 fn approvals_rejects_non_numeric_limit() {
1623 let result = Cli::try_parse_from(["git-paw", "approvals", "--limit", "lots"]);
1624 assert!(result.is_err());
1625 }
1626
1627 #[test]
1628 fn approvals_help_lists_flags_and_examples() {
1629 let result = Cli::try_parse_from(["git-paw", "approvals", "--help"]);
1630 assert!(result.is_err());
1631 let err = result.unwrap_err();
1632 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1633 let help = err.to_string();
1634 assert!(help.contains("--session"), "got: {help}");
1635 assert!(help.contains("--limit"), "got: {help}");
1636 assert!(help.contains("--json"), "got: {help}");
1637 assert!(
1638 help.contains("git paw approvals --json"),
1639 "help should include examples, got: {help}"
1640 );
1641 }
1642
1643 #[test]
1644 fn help_shows_approvals_subcommand() {
1645 let result = Cli::try_parse_from(["git-paw", "--help"]);
1646 let err = result.unwrap_err();
1647 let help = err.to_string();
1648 assert!(
1649 help.contains("approvals"),
1650 "root help should list approvals subcommand, got: {help}"
1651 );
1652 }
1653
1654 #[test]
1655 fn dashboard_does_not_appear_in_help() {
1656 let result = Cli::try_parse_from(["git-paw", "--help"]);
1657 let err = result.unwrap_err();
1658 let help = err.to_string();
1659 assert!(
1660 !help.contains("__dashboard"),
1661 "hidden __dashboard subcommand should not appear in help output"
1662 );
1663 }
1664}