1use clap::{Parser, Subcommand};
7
8#[derive(Debug, Parser)]
11#[command(
12 name = "git-paw",
13 version,
14 about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
15 long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
16 across git worktrees from a single terminal using tmux. Each branch gets its own \
17 worktree and AI session, running in parallel.",
18 after_help = "\x1b[1mQuick Start:\x1b[0m\n\n \
19 # Launch interactive session (picks CLI and branches)\n \
20 git paw\n\n \
21 # Use Claude on specific branches\n \
22 git paw start --cli claude --branches feat/auth,feat/api\n\n \
23 # Check session status\n \
24 git paw status\n\n \
25 # Stop session (preserves worktrees for later)\n \
26 git paw stop\n\n \
27 # Remove everything\n \
28 git paw purge"
29)]
30pub struct Cli {
31 #[command(subcommand)]
33 pub command: Option<Command>,
34}
35
36#[derive(Debug, Subcommand)]
38pub enum Command {
39 #[command(
41 about = "Launch a new session or reattach to an existing one",
42 long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
43 or launches a new interactive session.\n\n\
44 Examples:\n \
45 git paw start\n \
46 git paw start --cli claude\n \
47 git paw start --cli claude --branches feat/auth,feat/api\n \
48 git paw start --from-specs\n \
49 git paw start --from-specs --cli claude\n \
50 git paw start --dry-run\n \
51 git paw start --preset backend"
52 )]
53 Start {
54 #[arg(long, help = "AI CLI to use (skips CLI picker)")]
56 cli: Option<String>,
57
58 #[arg(
60 long,
61 value_delimiter = ',',
62 help = "Comma-separated branches (skips branch picker)"
63 )]
64 branches: Option<Vec<String>>,
65
66 #[arg(
68 long,
69 help = "Launch from spec files (reads .git-paw/config.toml [specs])"
70 )]
71 from_specs: bool,
72
73 #[arg(long, help = "Preview the session plan without executing")]
75 dry_run: bool,
76
77 #[arg(long, help = "Use a named preset from config")]
79 preset: Option<String>,
80 },
81
82 #[command(
84 about = "Stop the session (kills tmux, keeps worktrees and state)",
85 long_about = "Kills the tmux session but preserves worktrees and session state on disk. \
86 Run `git paw start` later to recover the session.\n\n\
87 Example:\n git paw stop"
88 )]
89 Stop,
90
91 #[command(
93 about = "Remove everything (tmux session, worktrees, and state)",
94 long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
95 session state. Requires confirmation unless --force is used.\n\n\
96 Examples:\n git paw purge\n git paw purge --force"
97 )]
98 Purge {
99 #[arg(long, help = "Skip confirmation prompt")]
101 force: bool,
102 },
103
104 #[command(
106 about = "Show session state for the current repo",
107 long_about = "Displays the current session status, branches, CLIs, and worktree paths \
108 for the repository in the current directory.\n\n\
109 Example:\n git paw status"
110 )]
111 Status,
112
113 #[command(
115 about = "List detected and custom AI CLIs",
116 long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
117 registered in your config.\n\n\
118 Example:\n git paw list-clis"
119 )]
120 ListClis,
121
122 #[command(
124 about = "Register a custom AI CLI",
125 long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
126 The command can be an absolute path or a binary name on PATH.\n\n\
127 Examples:\n \
128 git paw add-cli my-agent /usr/local/bin/my-agent\n \
129 git paw add-cli my-agent my-agent --display-name \"My Agent\""
130 )]
131 AddCli {
132 #[arg(help = "Name to register the CLI as")]
134 name: String,
135
136 #[arg(help = "Command or path to the CLI binary")]
138 command: String,
139
140 #[arg(long, help = "Display name shown in prompts")]
142 display_name: Option<String>,
143 },
144
145 #[command(
147 about = "Unregister a custom AI CLI",
148 long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
149 removed — auto-detected CLIs cannot.\n\n\
150 Example:\n git paw remove-cli my-agent"
151 )]
152 RemoveCli {
153 #[arg(help = "Name of the custom CLI to remove")]
155 name: String,
156 },
157
158 #[command(
160 about = "Initialize .git-paw/ directory and configuration",
161 long_about = "Creates the .git-paw/ directory with a default config and sets up \
162 .gitignore for logs.\n\n\
163 Examples:\n git paw init"
164 )]
165 Init,
166
167 #[command(
169 hide = true,
170 name = "__dashboard",
171 about = "Internal: run the broker and dashboard in pane 0",
172 long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
173 in pane 0 of a tmux session. Not intended for direct invocation."
174 )]
175 Dashboard,
176
177 #[command(
179 about = "View captured session logs",
180 long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
181 for clean output. Use --color to view with colors via less -R.\n\n\
182 Examples:\n \
183 git paw replay --list\n \
184 git paw replay feat/add-auth\n \
185 git paw replay feat/add-auth --color\n \
186 git paw replay feat/add-auth --session paw-myproject"
187 )]
188 Replay {
189 #[arg(required_unless_present = "list", help = "Branch to replay")]
191 branch: Option<String>,
192
193 #[arg(long, help = "List available log sessions and branches")]
195 list: bool,
196
197 #[arg(long, help = "Display with colors via less -R")]
199 color: bool,
200
201 #[arg(long, help = "Session to replay from (defaults to most recent)")]
203 session: Option<String>,
204 },
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use clap::Parser;
211
212 fn parse(args: &[&str]) -> Cli {
214 let mut full = vec!["git-paw"];
215 full.extend(args);
216 Cli::try_parse_from(full).expect("failed to parse")
217 }
218
219 #[test]
222 fn no_args_defaults_to_none_command() {
223 let cli = parse(&[]);
224 assert!(
225 cli.command.is_none(),
226 "no args should yield None (handled as Start in main)"
227 );
228 }
229
230 #[test]
233 fn start_with_no_flags() {
234 let cli = parse(&["start"]);
235 match cli.command.unwrap() {
236 Command::Start {
237 cli,
238 branches,
239 from_specs,
240 dry_run,
241 preset,
242 } => {
243 assert!(cli.is_none());
244 assert!(branches.is_none());
245 assert!(!from_specs);
246 assert!(!dry_run);
247 assert!(preset.is_none());
248 }
249 other => panic!("expected Start, got {other:?}"),
250 }
251 }
252
253 #[test]
254 fn start_with_cli_flag() {
255 let cli = parse(&["start", "--cli", "claude"]);
256 match cli.command.unwrap() {
257 Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
258 other => panic!("expected Start, got {other:?}"),
259 }
260 }
261
262 #[test]
263 fn start_with_branches_flag_comma_separated() {
264 let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
265 match cli.command.unwrap() {
266 Command::Start { branches, .. } => {
267 let b = branches.expect("branches should be set");
268 assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
269 }
270 other => panic!("expected Start, got {other:?}"),
271 }
272 }
273
274 #[test]
275 fn start_with_dry_run() {
276 let cli = parse(&["start", "--dry-run"]);
277 match cli.command.unwrap() {
278 Command::Start { dry_run, .. } => assert!(dry_run),
279 other => panic!("expected Start, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn start_with_preset() {
285 let cli = parse(&["start", "--preset", "backend"]);
286 match cli.command.unwrap() {
287 Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
288 other => panic!("expected Start, got {other:?}"),
289 }
290 }
291
292 #[test]
293 fn start_with_all_flags() {
294 let cli = parse(&[
295 "start",
296 "--cli",
297 "gemini",
298 "--branches",
299 "a,b",
300 "--dry-run",
301 "--preset",
302 "dev",
303 ]);
304 match cli.command.unwrap() {
305 Command::Start {
306 cli,
307 branches,
308 dry_run,
309 preset,
310 ..
311 } => {
312 assert_eq!(cli.as_deref(), Some("gemini"));
313 assert_eq!(branches.unwrap(), vec!["a", "b"]);
314 assert!(dry_run);
315 assert_eq!(preset.as_deref(), Some("dev"));
316 }
317 other => panic!("expected Start, got {other:?}"),
318 }
319 }
320
321 #[test]
324 fn stop_parses() {
325 let cli = parse(&["stop"]);
326 assert!(matches!(cli.command.unwrap(), Command::Stop));
327 }
328
329 #[test]
332 fn purge_without_force() {
333 let cli = parse(&["purge"]);
334 match cli.command.unwrap() {
335 Command::Purge { force } => assert!(!force),
336 other => panic!("expected Purge, got {other:?}"),
337 }
338 }
339
340 #[test]
341 fn purge_with_force() {
342 let cli = parse(&["purge", "--force"]);
343 match cli.command.unwrap() {
344 Command::Purge { force } => assert!(force),
345 other => panic!("expected Purge, got {other:?}"),
346 }
347 }
348
349 #[test]
352 fn status_parses() {
353 let cli = parse(&["status"]);
354 assert!(matches!(cli.command.unwrap(), Command::Status));
355 }
356
357 #[test]
360 fn list_clis_parses() {
361 let cli = parse(&["list-clis"]);
362 assert!(matches!(cli.command.unwrap(), Command::ListClis));
363 }
364
365 #[test]
368 fn add_cli_with_required_args() {
369 let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
370 match cli.command.unwrap() {
371 Command::AddCli {
372 name,
373 command,
374 display_name,
375 } => {
376 assert_eq!(name, "my-agent");
377 assert_eq!(command, "/usr/local/bin/my-agent");
378 assert!(display_name.is_none());
379 }
380 other => panic!("expected AddCli, got {other:?}"),
381 }
382 }
383
384 #[test]
385 fn add_cli_with_display_name() {
386 let cli = parse(&[
387 "add-cli",
388 "my-agent",
389 "my-agent",
390 "--display-name",
391 "My Agent",
392 ]);
393 match cli.command.unwrap() {
394 Command::AddCli {
395 name,
396 command,
397 display_name,
398 } => {
399 assert_eq!(name, "my-agent");
400 assert_eq!(command, "my-agent");
401 assert_eq!(display_name.as_deref(), Some("My Agent"));
402 }
403 other => panic!("expected AddCli, got {other:?}"),
404 }
405 }
406
407 #[test]
410 fn remove_cli_parses() {
411 let cli = parse(&["remove-cli", "my-agent"]);
412 match cli.command.unwrap() {
413 Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
414 other => panic!("expected RemoveCli, got {other:?}"),
415 }
416 }
417
418 #[test]
421 fn version_flag_is_accepted() {
422 let result = Cli::try_parse_from(["git-paw", "--version"]);
423 assert!(result.is_err());
425 let err = result.unwrap_err();
426 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
427 }
428
429 #[test]
430 fn help_flag_is_accepted() {
431 let result = Cli::try_parse_from(["git-paw", "--help"]);
432 assert!(result.is_err());
433 let err = result.unwrap_err();
434 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
435 }
436
437 #[test]
440 fn init_parses() {
441 let cli = parse(&["init"]);
442 assert!(matches!(cli.command.unwrap(), Command::Init));
443 }
444
445 #[test]
448 fn init_help_text() {
449 let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
450 assert!(result.is_err());
451 let err = result.unwrap_err();
452 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
453 }
454
455 #[test]
456 fn unknown_subcommand_is_rejected() {
457 let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
458 assert!(result.is_err());
459 }
460
461 #[test]
462 fn add_cli_missing_required_args_is_rejected() {
463 let result = Cli::try_parse_from(["git-paw", "add-cli"]);
464 assert!(result.is_err());
465 }
466
467 #[test]
470 fn replay_with_branch() {
471 let cli = parse(&["replay", "feat/add-auth"]);
472 match cli.command.unwrap() {
473 Command::Replay {
474 branch,
475 list,
476 color,
477 session,
478 } => {
479 assert_eq!(branch.as_deref(), Some("feat/add-auth"));
480 assert!(!list);
481 assert!(!color);
482 assert!(session.is_none());
483 }
484 other => panic!("expected Replay, got {other:?}"),
485 }
486 }
487
488 #[test]
489 fn replay_with_list() {
490 let cli = parse(&["replay", "--list"]);
491 match cli.command.unwrap() {
492 Command::Replay { branch, list, .. } => {
493 assert!(list);
494 assert!(branch.is_none());
495 }
496 other => panic!("expected Replay, got {other:?}"),
497 }
498 }
499
500 #[test]
501 fn replay_with_color() {
502 let cli = parse(&["replay", "feat/add-auth", "--color"]);
503 match cli.command.unwrap() {
504 Command::Replay { color, .. } => assert!(color),
505 other => panic!("expected Replay, got {other:?}"),
506 }
507 }
508
509 #[test]
510 fn replay_with_session() {
511 let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
512 match cli.command.unwrap() {
513 Command::Replay { session, .. } => {
514 assert_eq!(session.as_deref(), Some("paw-myproject"));
515 }
516 other => panic!("expected Replay, got {other:?}"),
517 }
518 }
519
520 #[test]
521 fn replay_no_args_fails() {
522 let result = Cli::try_parse_from(["git-paw", "replay"]);
523 assert!(result.is_err());
524 }
525
526 #[test]
527 fn replay_help_text() {
528 let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
529 assert!(result.is_err());
530 let err = result.unwrap_err();
531 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
532 let help = err.to_string();
533 assert!(help.contains("--list"));
534 assert!(help.contains("--color"));
535 assert!(help.contains("--session"));
536 }
537
538 #[test]
539 fn help_shows_replay_subcommand() {
540 let result = Cli::try_parse_from(["git-paw", "--help"]);
541 let err = result.unwrap_err();
542 let help = err.to_string();
543 assert!(
544 help.contains("replay"),
545 "help should list the replay subcommand"
546 );
547 }
548
549 #[test]
552 fn dashboard_parses() {
553 let cli = parse(&["__dashboard"]);
554 assert!(matches!(cli.command.unwrap(), Command::Dashboard));
555 }
556
557 #[test]
558 fn dashboard_does_not_appear_in_help() {
559 let result = Cli::try_parse_from(["git-paw", "--help"]);
560 let err = result.unwrap_err();
561 let help = err.to_string();
562 assert!(
563 !help.contains("__dashboard"),
564 "hidden __dashboard subcommand should not appear in help output"
565 );
566 }
567}