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 = "Pause the session (detaches client, stops broker, leaves CLIs running)",
198 long_about = "Detaches the tmux client and stops the broker, but leaves all CLI \
199 processes running in the background. This preserves agent conversation \
200 state for instant resume via `git paw start`. RAM stays allocated \
201 (~300 MB per Claude pane).\n\n\
202 Use pause for short breaks (lunch, meetings, end-of-day). For longer \
203 breaks, use `git paw stop` to kill the CLIs and release RAM (worktrees \
204 preserved). A future `git paw hibernate` (v1.0.0) will snapshot state \
205 to disk.\n\n\
206 Example:\n git paw pause"
207 )]
208 Pause,
209
210 #[command(
212 about = "Stop the session (kills tmux, keeps worktrees and state)",
213 long_about = "Kills the tmux session and every CLI pane process, but preserves \
214 worktrees and session state on disk. CLI conversation context is lost. \
215 Run `git paw start` later to recover the session with fresh CLI \
216 processes.\n\n\
217 Three teardown verbs:\n \
218 pause — soft stop (detach + broker stop; CLIs keep running, RAM held)\n \
219 stop — kills CLI processes; preserves worktrees on disk (this command)\n \
220 purge — full reset; removes worktrees, branches, and state\n\n\
221 `stop` prompts for confirmation in interactive terminals. Use \
222 `--force` to skip the prompt (scripts) or pipe stdin from \
223 `/dev/null` for non-interactive contexts.\n\n\
224 Examples:\n git paw stop\n git paw stop --force"
225 )]
226 Stop {
227 #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
229 force: bool,
230 },
231
232 #[command(
234 about = "Remove everything (tmux session, worktrees, and state)",
235 long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
236 session state. Requires confirmation unless --force is used.\n\n\
237 Examples:\n git paw purge\n git paw purge --force"
238 )]
239 Purge {
240 #[arg(long, help = "Skip confirmation prompt")]
242 force: bool,
243 },
244
245 #[command(
247 about = "Show session state for the current repo",
248 long_about = "Displays the current session status, branches, CLIs, and worktree paths \
249 for the repository in the current directory.\n\n\
250 Example:\n git paw status"
251 )]
252 Status,
253
254 #[command(
256 about = "List detected and custom AI CLIs",
257 long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
258 registered in your config.\n\n\
259 Example:\n git paw list-clis"
260 )]
261 ListClis,
262
263 #[command(
265 about = "Register a custom AI CLI",
266 long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
267 The command can be an absolute path or a binary name on PATH.\n\n\
268 Examples:\n \
269 git paw add-cli my-agent /usr/local/bin/my-agent\n \
270 git paw add-cli my-agent my-agent --display-name \"My Agent\""
271 )]
272 AddCli {
273 #[arg(help = "Name to register the CLI as")]
275 name: String,
276
277 #[arg(help = "Command or path to the CLI binary")]
279 command: String,
280
281 #[arg(long, help = "Display name shown in prompts")]
283 display_name: Option<String>,
284 },
285
286 #[command(
288 about = "Unregister a custom AI CLI",
289 long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
290 removed — auto-detected CLIs cannot.\n\n\
291 Example:\n git paw remove-cli my-agent"
292 )]
293 RemoveCli {
294 #[arg(help = "Name of the custom CLI to remove")]
296 name: String,
297 },
298
299 #[command(
301 about = "Initialize .git-paw/ directory and configuration",
302 long_about = "Creates the .git-paw/ directory with a default config and sets up \
303 .gitignore for logs.\n\n\
304 Examples:\n git paw init"
305 )]
306 Init,
307
308 #[command(
310 hide = true,
311 name = "__dashboard",
312 about = "Internal: run the broker and dashboard in pane 0",
313 long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
314 in pane 0 of a tmux session. Not intended for direct invocation."
315 )]
316 Dashboard,
317
318 #[command(
320 about = "View captured session logs",
321 long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
322 for clean output. Use --color to view with colors via less -R.\n\n\
323 Examples:\n \
324 git paw replay --list\n \
325 git paw replay feat/add-auth\n \
326 git paw replay feat/add-auth --color\n \
327 git paw replay feat/add-auth --session paw-myproject"
328 )]
329 Replay {
330 #[arg(required_unless_present = "list", help = "Branch to replay")]
332 branch: Option<String>,
333
334 #[arg(long, help = "List available log sessions and branches")]
336 list: bool,
337
338 #[arg(long, help = "Display with colors via less -R")]
340 color: bool,
341
342 #[arg(long, help = "Session to replay from (defaults to most recent)")]
344 session: Option<String>,
345 },
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use clap::Parser;
352
353 fn parse(args: &[&str]) -> Cli {
355 let mut full = vec!["git-paw"];
356 full.extend(args);
357 Cli::try_parse_from(full).expect("failed to parse")
358 }
359
360 #[test]
363 fn no_args_defaults_to_none_command() {
364 let cli = parse(&[]);
365 assert!(
366 cli.command.is_none(),
367 "no args should yield None (handled as Start in main)"
368 );
369 }
370
371 #[test]
374 fn start_with_no_flags() {
375 let cli = parse(&["start"]);
376 match cli.command.unwrap() {
377 Command::Start {
378 cli,
379 branches,
380 from_all_specs,
381 specs,
382 specs_format,
383 dry_run,
384 preset,
385 supervisor,
386 no_supervisor,
387 force,
388 no_rebase,
389 } => {
390 assert!(cli.is_none());
391 assert!(branches.is_none());
392 assert!(!from_all_specs);
393 assert!(specs.is_none());
394 assert!(specs_format.is_none());
395 assert!(!dry_run);
396 assert!(preset.is_none());
397 assert!(!supervisor);
398 assert!(!no_supervisor);
399 assert!(!force);
400 assert!(!no_rebase);
401 }
402 other => panic!("expected Start, got {other:?}"),
403 }
404 }
405
406 #[test]
407 fn start_with_cli_flag() {
408 let cli = parse(&["start", "--cli", "claude"]);
409 match cli.command.unwrap() {
410 Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
411 other => panic!("expected Start, got {other:?}"),
412 }
413 }
414
415 #[test]
416 fn start_with_from_all_specs_sets_flag_and_leaves_specs_unset() {
417 let cli = parse(&["start", "--from-all-specs"]);
418 match cli.command.unwrap() {
419 Command::Start {
420 from_all_specs,
421 specs,
422 ..
423 } => {
424 assert!(from_all_specs);
425 assert!(specs.is_none());
426 }
427 other => panic!("expected Start, got {other:?}"),
428 }
429 }
430
431 #[test]
432 fn start_with_from_specs_alias_parses_identically_to_from_all_specs() {
433 let alias_args = parse(&["start", "--from-specs"]);
434 let canonical_args = parse(&["start", "--from-all-specs"]);
435 match (alias_args.command.unwrap(), canonical_args.command.unwrap()) {
436 (
437 Command::Start {
438 from_all_specs: a_all,
439 specs: a_specs,
440 supervisor: a_sup,
441 ..
442 },
443 Command::Start {
444 from_all_specs: c_all,
445 specs: c_specs,
446 supervisor: c_sup,
447 ..
448 },
449 ) => {
450 assert_eq!(a_all, c_all);
451 assert_eq!(a_specs, c_specs);
452 assert_eq!(a_sup, c_sup);
453 assert!(a_all);
454 }
455 other => panic!("expected two Start variants, got {other:?}"),
456 }
457 }
458
459 #[test]
460 fn start_with_bare_specs_yields_empty_vec_picker_mode() {
461 let cli = parse(&["start", "--specs"]);
462 match cli.command.unwrap() {
463 Command::Start {
464 from_all_specs,
465 specs,
466 ..
467 } => {
468 assert!(!from_all_specs);
469 assert_eq!(specs, Some(Vec::<String>::new()));
470 }
471 other => panic!("expected Start, got {other:?}"),
472 }
473 }
474
475 #[test]
476 fn start_with_specs_single_name() {
477 let cli = parse(&["start", "--specs", "add-auth"]);
478 match cli.command.unwrap() {
479 Command::Start { specs, .. } => {
480 assert_eq!(specs, Some(vec!["add-auth".to_string()]));
481 }
482 other => panic!("expected Start, got {other:?}"),
483 }
484 }
485
486 #[test]
487 fn start_with_specs_two_comma_separated_names() {
488 let cli = parse(&["start", "--specs", "add-auth,fix-session"]);
489 match cli.command.unwrap() {
490 Command::Start { specs, .. } => {
491 assert_eq!(
492 specs,
493 Some(vec!["add-auth".to_string(), "fix-session".to_string()])
494 );
495 }
496 other => panic!("expected Start, got {other:?}"),
497 }
498 }
499
500 #[test]
501 fn start_with_specs_three_comma_separated_names() {
502 let cli = parse(&["start", "--specs", "add-auth,fix-session,add-logging"]);
503 match cli.command.unwrap() {
504 Command::Start { specs, .. } => {
505 assert_eq!(
506 specs,
507 Some(vec![
508 "add-auth".to_string(),
509 "fix-session".to_string(),
510 "add-logging".to_string(),
511 ])
512 );
513 }
514 other => panic!("expected Start, got {other:?}"),
515 }
516 }
517
518 #[test]
519 fn start_with_from_all_specs_and_specs_is_rejected() {
520 let result = Cli::try_parse_from([
521 "git-paw",
522 "start",
523 "--from-all-specs",
524 "--specs",
525 "add-auth",
526 ]);
527 assert!(result.is_err());
528 let err = result.unwrap_err().to_string();
529 assert!(err.contains("--from-all-specs"), "got: {err}");
530 assert!(err.contains("--specs"), "got: {err}");
531 }
532
533 #[test]
534 fn start_with_from_specs_alias_and_specs_is_rejected() {
535 let result =
536 Cli::try_parse_from(["git-paw", "start", "--from-specs", "--specs", "add-auth"]);
537 assert!(result.is_err());
538 }
539
540 #[test]
541 fn start_with_from_all_specs_and_supervisor_sets_both_flags() {
542 let cli = parse(&["start", "--from-all-specs", "--supervisor"]);
543 match cli.command.unwrap() {
544 Command::Start {
545 from_all_specs,
546 specs,
547 supervisor,
548 ..
549 } => {
550 assert!(from_all_specs);
551 assert!(supervisor);
552 assert!(specs.is_none());
553 }
554 other => panic!("expected Start, got {other:?}"),
555 }
556 }
557
558 #[test]
559 fn start_with_supervisor_only_leaves_spec_mode_unset() {
560 let cli = parse(&["start", "--supervisor"]);
561 match cli.command.unwrap() {
562 Command::Start {
563 from_all_specs,
564 specs,
565 supervisor,
566 ..
567 } => {
568 assert!(!from_all_specs);
569 assert!(specs.is_none());
570 assert!(supervisor);
571 }
572 other => panic!("expected Start, got {other:?}"),
573 }
574 }
575
576 #[test]
577 fn start_help_contains_from_all_specs_and_specs_but_not_alias() {
578 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
579 let err = result.unwrap_err();
580 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
581 let help = err.to_string();
582 assert!(
583 help.contains("--from-all-specs"),
584 "start --help should contain --from-all-specs; got: {help}"
585 );
586 assert!(
587 help.contains("--specs"),
588 "start --help should contain --specs; got: {help}"
589 );
590 assert!(
591 !help.contains("--from-specs"),
592 "start --help should NOT contain hidden alias --from-specs; got: {help}"
593 );
594 }
595
596 #[test]
597 fn start_with_branches_flag_comma_separated() {
598 let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
599 match cli.command.unwrap() {
600 Command::Start { branches, .. } => {
601 let b = branches.expect("branches should be set");
602 assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
603 }
604 other => panic!("expected Start, got {other:?}"),
605 }
606 }
607
608 #[test]
609 fn start_with_dry_run() {
610 let cli = parse(&["start", "--dry-run"]);
611 match cli.command.unwrap() {
612 Command::Start { dry_run, .. } => assert!(dry_run),
613 other => panic!("expected Start, got {other:?}"),
614 }
615 }
616
617 #[test]
618 fn start_with_preset() {
619 let cli = parse(&["start", "--preset", "backend"]);
620 match cli.command.unwrap() {
621 Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
622 other => panic!("expected Start, got {other:?}"),
623 }
624 }
625
626 #[test]
627 fn start_with_supervisor_flag() {
628 let cli = parse(&["start", "--supervisor"]);
629 match cli.command.unwrap() {
630 Command::Start { supervisor, .. } => assert!(supervisor),
631 other => panic!("expected Start, got {other:?}"),
632 }
633 }
634
635 #[test]
636 fn start_without_supervisor_defaults_false() {
637 let cli = parse(&["start", "--cli", "claude"]);
638 match cli.command.unwrap() {
639 Command::Start { supervisor, .. } => assert!(!supervisor),
640 other => panic!("expected Start, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn start_with_supervisor_and_other_flags() {
646 let cli = parse(&[
647 "start",
648 "--supervisor",
649 "--cli",
650 "claude",
651 "--branches",
652 "feat/a,feat/b",
653 ]);
654 match cli.command.unwrap() {
655 Command::Start {
656 supervisor,
657 cli,
658 branches,
659 ..
660 } => {
661 assert!(supervisor);
662 assert_eq!(cli.as_deref(), Some("claude"));
663 assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
664 }
665 other => panic!("expected Start, got {other:?}"),
666 }
667 }
668
669 #[test]
672 fn start_with_specs_format_speckit() {
673 let cli = parse(&["start", "--from-specs", "--specs-format", "speckit"]);
674 match cli.command.unwrap() {
675 Command::Start { specs_format, .. } => {
676 assert_eq!(specs_format, Some(SpecsFormat::Speckit));
677 }
678 other => panic!("expected Start, got {other:?}"),
679 }
680 }
681
682 #[test]
683 fn start_with_specs_format_openspec() {
684 let cli = parse(&["start", "--from-specs", "--specs-format", "openspec"]);
685 match cli.command.unwrap() {
686 Command::Start { specs_format, .. } => {
687 assert_eq!(specs_format, Some(SpecsFormat::Openspec));
688 }
689 other => panic!("expected Start, got {other:?}"),
690 }
691 }
692
693 #[test]
694 fn start_with_specs_format_markdown() {
695 let cli = parse(&["start", "--from-specs", "--specs-format", "markdown"]);
696 match cli.command.unwrap() {
697 Command::Start { specs_format, .. } => {
698 assert_eq!(specs_format, Some(SpecsFormat::Markdown));
699 }
700 other => panic!("expected Start, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn start_rejects_unknown_specs_format() {
706 let result = Cli::try_parse_from([
707 "git-paw",
708 "start",
709 "--from-specs",
710 "--specs-format",
711 "unknown-value",
712 ]);
713 assert!(result.is_err(), "unknown value should be rejected");
714 let err = result.unwrap_err().to_string();
715 assert!(
716 err.contains("openspec") && err.contains("markdown") && err.contains("speckit"),
717 "error should list all three valid values, got: {err}"
718 );
719 }
720
721 #[test]
722 fn specs_format_as_str_matches_backend_names() {
723 assert_eq!(SpecsFormat::Openspec.as_str(), "openspec");
724 assert_eq!(SpecsFormat::Markdown.as_str(), "markdown");
725 assert_eq!(SpecsFormat::Speckit.as_str(), "speckit");
726 }
727
728 #[test]
729 fn start_help_shows_specs_format_flag() {
730 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
731 assert!(result.is_err());
732 let err = result.unwrap_err();
733 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
734 let help = err.to_string();
735 assert!(
736 help.contains("--specs-format"),
737 "start --help should contain --specs-format"
738 );
739 }
740
741 #[test]
742 fn start_help_shows_supervisor_flag() {
743 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
744 assert!(result.is_err());
745 let err = result.unwrap_err();
746 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
747 let help = err.to_string();
748 assert!(
749 help.contains("--supervisor"),
750 "start --help should contain --supervisor"
751 );
752 }
753
754 #[test]
757 fn start_with_no_supervisor_flag() {
758 let cli = parse(&["start", "--no-supervisor"]);
759 match cli.command.unwrap() {
760 Command::Start {
761 supervisor,
762 no_supervisor,
763 ..
764 } => {
765 assert!(no_supervisor);
766 assert!(!supervisor);
767 }
768 other => panic!("expected Start, got {other:?}"),
769 }
770 }
771
772 #[test]
773 fn start_without_flags_leaves_no_supervisor_false() {
774 let cli = parse(&["start"]);
775 match cli.command.unwrap() {
776 Command::Start {
777 supervisor,
778 no_supervisor,
779 ..
780 } => {
781 assert!(!no_supervisor);
782 assert!(!supervisor);
783 }
784 other => panic!("expected Start, got {other:?}"),
785 }
786 }
787
788 #[test]
789 fn start_with_supervisor_and_no_supervisor_is_rejected() {
790 let result = Cli::try_parse_from(["git-paw", "start", "--supervisor", "--no-supervisor"]);
791 assert!(
792 result.is_err(),
793 "--supervisor + --no-supervisor must be rejected by clap"
794 );
795 let err = result.unwrap_err();
796 let msg = err.to_string();
797 assert!(
798 msg.contains("--supervisor") && msg.contains("--no-supervisor"),
799 "error should mention both flags, got: {msg}"
800 );
801 }
802
803 #[test]
804 fn start_with_no_supervisor_and_supervisor_reversed_is_also_rejected() {
805 let result = Cli::try_parse_from(["git-paw", "start", "--no-supervisor", "--supervisor"]);
807 assert!(result.is_err());
808 }
809
810 #[test]
811 fn start_no_supervisor_combines_with_other_flags() {
812 let cli = parse(&[
813 "start",
814 "--no-supervisor",
815 "--cli",
816 "claude",
817 "--branches",
818 "feat/a,feat/b",
819 ]);
820 match cli.command.unwrap() {
821 Command::Start {
822 no_supervisor,
823 cli,
824 branches,
825 ..
826 } => {
827 assert!(no_supervisor);
828 assert_eq!(cli.as_deref(), Some("claude"));
829 assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
830 }
831 other => panic!("expected Start, got {other:?}"),
832 }
833 }
834
835 #[test]
836 fn start_help_shows_no_supervisor_flag() {
837 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
838 assert!(result.is_err());
839 let err = result.unwrap_err();
840 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
841 let help = err.to_string();
842 assert!(
843 help.contains("--no-supervisor"),
844 "start --help should contain --no-supervisor, got: {help}"
845 );
846 }
847
848 #[test]
849 fn start_with_all_flags() {
850 let cli = parse(&[
851 "start",
852 "--cli",
853 "gemini",
854 "--branches",
855 "a,b",
856 "--dry-run",
857 "--preset",
858 "dev",
859 ]);
860 match cli.command.unwrap() {
861 Command::Start {
862 cli,
863 branches,
864 dry_run,
865 preset,
866 ..
867 } => {
868 assert_eq!(cli.as_deref(), Some("gemini"));
869 assert_eq!(branches.unwrap(), vec!["a", "b"]);
870 assert!(dry_run);
871 assert_eq!(preset.as_deref(), Some("dev"));
872 }
873 other => panic!("expected Start, got {other:?}"),
874 }
875 }
876
877 #[test]
880 fn pause_parses() {
881 let cli = parse(&["pause"]);
882 assert!(matches!(cli.command.unwrap(), Command::Pause));
883 }
884
885 #[test]
886 fn pause_help_mentions_ram_tradeoff() {
887 let result = Cli::try_parse_from(["git-paw", "pause", "--help"]);
888 assert!(result.is_err());
889 let err = result.unwrap_err();
890 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
891 let help = err.to_string();
892 assert!(
893 help.to_lowercase().contains("ram"),
894 "pause --help should mention RAM tradeoff, got: {help}"
895 );
896 assert!(
897 help.contains("stop"),
898 "pause --help should cross-reference stop, got: {help}"
899 );
900 }
901
902 #[test]
903 fn pause_rejects_unknown_flags() {
904 let result = Cli::try_parse_from(["git-paw", "pause", "--anything"]);
905 assert!(result.is_err(), "pause should reject unknown flags");
906 }
907
908 #[test]
909 fn root_help_lists_pause() {
910 let result = Cli::try_parse_from(["git-paw", "--help"]);
911 assert!(result.is_err());
912 let err = result.unwrap_err();
913 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
914 let help = err.to_string();
915 assert!(
916 help.contains("pause"),
917 "root --help should list pause subcommand, got: {help}"
918 );
919 assert!(
921 help.contains("git paw pause"),
922 "after_help quick-start should mention `git paw pause`"
923 );
924 }
925
926 #[test]
929 fn stop_parses() {
930 let cli = parse(&["stop"]);
931 assert!(matches!(
932 cli.command.unwrap(),
933 Command::Stop { force: false }
934 ));
935 }
936
937 #[test]
938 fn stop_without_force() {
939 let cli = parse(&["stop"]);
940 match cli.command.unwrap() {
941 Command::Stop { force } => assert!(!force),
942 other => panic!("expected Stop, got {other:?}"),
943 }
944 }
945
946 #[test]
947 fn stop_with_force() {
948 let cli = parse(&["stop", "--force"]);
949 match cli.command.unwrap() {
950 Command::Stop { force } => assert!(force),
951 other => panic!("expected Stop, got {other:?}"),
952 }
953 }
954
955 #[test]
956 fn stop_help_mentions_pause_and_purge() {
957 let result = Cli::try_parse_from(["git-paw", "stop", "--help"]);
958 assert!(result.is_err());
959 let err = result.unwrap_err();
960 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
961 let help = err.to_string();
962 assert!(
963 help.contains("pause"),
964 "stop --help should reference pause, got: {help}"
965 );
966 assert!(
967 help.contains("purge"),
968 "stop --help should reference purge, got: {help}"
969 );
970 assert!(
971 help.contains("--force"),
972 "stop --help should list --force, got: {help}"
973 );
974 }
975
976 #[test]
979 fn purge_without_force() {
980 let cli = parse(&["purge"]);
981 match cli.command.unwrap() {
982 Command::Purge { force } => assert!(!force),
983 other => panic!("expected Purge, got {other:?}"),
984 }
985 }
986
987 #[test]
988 fn purge_with_force() {
989 let cli = parse(&["purge", "--force"]);
990 match cli.command.unwrap() {
991 Command::Purge { force } => assert!(force),
992 other => panic!("expected Purge, got {other:?}"),
993 }
994 }
995
996 #[test]
999 fn status_parses() {
1000 let cli = parse(&["status"]);
1001 assert!(matches!(cli.command.unwrap(), Command::Status));
1002 }
1003
1004 #[test]
1007 fn list_clis_parses() {
1008 let cli = parse(&["list-clis"]);
1009 assert!(matches!(cli.command.unwrap(), Command::ListClis));
1010 }
1011
1012 #[test]
1015 fn add_cli_with_required_args() {
1016 let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
1017 match cli.command.unwrap() {
1018 Command::AddCli {
1019 name,
1020 command,
1021 display_name,
1022 } => {
1023 assert_eq!(name, "my-agent");
1024 assert_eq!(command, "/usr/local/bin/my-agent");
1025 assert!(display_name.is_none());
1026 }
1027 other => panic!("expected AddCli, got {other:?}"),
1028 }
1029 }
1030
1031 #[test]
1032 fn add_cli_with_display_name() {
1033 let cli = parse(&[
1034 "add-cli",
1035 "my-agent",
1036 "my-agent",
1037 "--display-name",
1038 "My Agent",
1039 ]);
1040 match cli.command.unwrap() {
1041 Command::AddCli {
1042 name,
1043 command,
1044 display_name,
1045 } => {
1046 assert_eq!(name, "my-agent");
1047 assert_eq!(command, "my-agent");
1048 assert_eq!(display_name.as_deref(), Some("My Agent"));
1049 }
1050 other => panic!("expected AddCli, got {other:?}"),
1051 }
1052 }
1053
1054 #[test]
1057 fn remove_cli_parses() {
1058 let cli = parse(&["remove-cli", "my-agent"]);
1059 match cli.command.unwrap() {
1060 Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
1061 other => panic!("expected RemoveCli, got {other:?}"),
1062 }
1063 }
1064
1065 #[test]
1068 fn version_flag_is_accepted() {
1069 let result = Cli::try_parse_from(["git-paw", "--version"]);
1070 assert!(result.is_err());
1072 let err = result.unwrap_err();
1073 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
1074 }
1075
1076 #[test]
1077 fn help_flag_is_accepted() {
1078 let result = Cli::try_parse_from(["git-paw", "--help"]);
1079 assert!(result.is_err());
1080 let err = result.unwrap_err();
1081 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1082 }
1083
1084 #[test]
1087 fn init_parses() {
1088 let cli = parse(&["init"]);
1089 assert!(matches!(cli.command.unwrap(), Command::Init));
1090 }
1091
1092 #[test]
1095 fn init_help_text() {
1096 let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
1097 assert!(result.is_err());
1098 let err = result.unwrap_err();
1099 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1100 }
1101
1102 #[test]
1103 fn unknown_subcommand_is_rejected() {
1104 let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
1105 assert!(result.is_err());
1106 }
1107
1108 #[test]
1109 fn add_cli_missing_required_args_is_rejected() {
1110 let result = Cli::try_parse_from(["git-paw", "add-cli"]);
1111 assert!(result.is_err());
1112 }
1113
1114 #[test]
1117 fn replay_with_branch() {
1118 let cli = parse(&["replay", "feat/add-auth"]);
1119 match cli.command.unwrap() {
1120 Command::Replay {
1121 branch,
1122 list,
1123 color,
1124 session,
1125 } => {
1126 assert_eq!(branch.as_deref(), Some("feat/add-auth"));
1127 assert!(!list);
1128 assert!(!color);
1129 assert!(session.is_none());
1130 }
1131 other => panic!("expected Replay, got {other:?}"),
1132 }
1133 }
1134
1135 #[test]
1136 fn replay_with_list() {
1137 let cli = parse(&["replay", "--list"]);
1138 match cli.command.unwrap() {
1139 Command::Replay { branch, list, .. } => {
1140 assert!(list);
1141 assert!(branch.is_none());
1142 }
1143 other => panic!("expected Replay, got {other:?}"),
1144 }
1145 }
1146
1147 #[test]
1148 fn replay_with_color() {
1149 let cli = parse(&["replay", "feat/add-auth", "--color"]);
1150 match cli.command.unwrap() {
1151 Command::Replay { color, .. } => assert!(color),
1152 other => panic!("expected Replay, got {other:?}"),
1153 }
1154 }
1155
1156 #[test]
1157 fn replay_with_session() {
1158 let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
1159 match cli.command.unwrap() {
1160 Command::Replay { session, .. } => {
1161 assert_eq!(session.as_deref(), Some("paw-myproject"));
1162 }
1163 other => panic!("expected Replay, got {other:?}"),
1164 }
1165 }
1166
1167 #[test]
1168 fn replay_no_args_fails() {
1169 let result = Cli::try_parse_from(["git-paw", "replay"]);
1170 assert!(result.is_err());
1171 }
1172
1173 #[test]
1174 fn replay_help_text() {
1175 let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
1176 assert!(result.is_err());
1177 let err = result.unwrap_err();
1178 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1179 let help = err.to_string();
1180 assert!(help.contains("--list"));
1181 assert!(help.contains("--color"));
1182 assert!(help.contains("--session"));
1183 }
1184
1185 #[test]
1186 fn help_shows_replay_subcommand() {
1187 let result = Cli::try_parse_from(["git-paw", "--help"]);
1188 let err = result.unwrap_err();
1189 let help = err.to_string();
1190 assert!(
1191 help.contains("replay"),
1192 "help should list the replay subcommand"
1193 );
1194 }
1195
1196 #[test]
1199 fn dashboard_parses() {
1200 let cli = parse(&["__dashboard"]);
1201 assert!(matches!(cli.command.unwrap(), Command::Dashboard));
1202 }
1203
1204 #[test]
1207 fn start_with_no_rebase_flag_sets_no_rebase_true() {
1208 let cli = parse(&["start", "--no-rebase"]);
1209 match cli.command.unwrap() {
1210 Command::Start { no_rebase, .. } => assert!(no_rebase),
1211 other => panic!("expected Start, got {other:?}"),
1212 }
1213 }
1214
1215 #[test]
1216 fn start_without_no_rebase_defaults_to_false() {
1217 let cli = parse(&["start"]);
1218 match cli.command.unwrap() {
1219 Command::Start { no_rebase, .. } => assert!(!no_rebase),
1220 other => panic!("expected Start, got {other:?}"),
1221 }
1222 }
1223
1224 #[test]
1225 fn start_no_rebase_combines_with_supervisor() {
1226 let cli = parse(&["start", "--no-rebase", "--supervisor"]);
1227 match cli.command.unwrap() {
1228 Command::Start {
1229 no_rebase,
1230 supervisor,
1231 ..
1232 } => {
1233 assert!(no_rebase);
1234 assert!(supervisor);
1235 }
1236 other => panic!("expected Start, got {other:?}"),
1237 }
1238 }
1239
1240 #[test]
1241 fn start_help_shows_no_rebase_flag() {
1242 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
1243 assert!(result.is_err());
1244 let err = result.unwrap_err();
1245 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1246 let help = err.to_string();
1247 assert!(
1248 help.contains("--no-rebase"),
1249 "start --help should contain --no-rebase, got: {help}"
1250 );
1251 }
1252
1253 #[test]
1254 fn dashboard_does_not_appear_in_help() {
1255 let result = Cli::try_parse_from(["git-paw", "--help"]);
1256 let err = result.unwrap_err();
1257 let help = err.to_string();
1258 assert!(
1259 !help.contains("__dashboard"),
1260 "hidden __dashboard subcommand should not appear in help output"
1261 );
1262 }
1263}