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