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 about = "View captured session logs",
170 long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
171 for clean output. Use --color to view with colors via less -R.\n\n\
172 Examples:\n \
173 git paw replay --list\n \
174 git paw replay feat/add-auth\n \
175 git paw replay feat/add-auth --color\n \
176 git paw replay feat/add-auth --session paw-myproject"
177 )]
178 Replay {
179 #[arg(required_unless_present = "list", help = "Branch to replay")]
181 branch: Option<String>,
182
183 #[arg(long, help = "List available log sessions and branches")]
185 list: bool,
186
187 #[arg(long, help = "Display with colors via less -R")]
189 color: bool,
190
191 #[arg(long, help = "Session to replay from (defaults to most recent)")]
193 session: Option<String>,
194 },
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use clap::Parser;
201
202 fn parse(args: &[&str]) -> Cli {
204 let mut full = vec!["git-paw"];
205 full.extend(args);
206 Cli::try_parse_from(full).expect("failed to parse")
207 }
208
209 #[test]
212 fn no_args_defaults_to_none_command() {
213 let cli = parse(&[]);
214 assert!(
215 cli.command.is_none(),
216 "no args should yield None (handled as Start in main)"
217 );
218 }
219
220 #[test]
223 fn start_with_no_flags() {
224 let cli = parse(&["start"]);
225 match cli.command.unwrap() {
226 Command::Start {
227 cli,
228 branches,
229 from_specs,
230 dry_run,
231 preset,
232 } => {
233 assert!(cli.is_none());
234 assert!(branches.is_none());
235 assert!(!from_specs);
236 assert!(!dry_run);
237 assert!(preset.is_none());
238 }
239 other => panic!("expected Start, got {other:?}"),
240 }
241 }
242
243 #[test]
244 fn start_with_cli_flag() {
245 let cli = parse(&["start", "--cli", "claude"]);
246 match cli.command.unwrap() {
247 Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
248 other => panic!("expected Start, got {other:?}"),
249 }
250 }
251
252 #[test]
253 fn start_with_branches_flag_comma_separated() {
254 let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
255 match cli.command.unwrap() {
256 Command::Start { branches, .. } => {
257 let b = branches.expect("branches should be set");
258 assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
259 }
260 other => panic!("expected Start, got {other:?}"),
261 }
262 }
263
264 #[test]
265 fn start_with_dry_run() {
266 let cli = parse(&["start", "--dry-run"]);
267 match cli.command.unwrap() {
268 Command::Start { dry_run, .. } => assert!(dry_run),
269 other => panic!("expected Start, got {other:?}"),
270 }
271 }
272
273 #[test]
274 fn start_with_preset() {
275 let cli = parse(&["start", "--preset", "backend"]);
276 match cli.command.unwrap() {
277 Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
278 other => panic!("expected Start, got {other:?}"),
279 }
280 }
281
282 #[test]
283 fn start_with_all_flags() {
284 let cli = parse(&[
285 "start",
286 "--cli",
287 "gemini",
288 "--branches",
289 "a,b",
290 "--dry-run",
291 "--preset",
292 "dev",
293 ]);
294 match cli.command.unwrap() {
295 Command::Start {
296 cli,
297 branches,
298 dry_run,
299 preset,
300 ..
301 } => {
302 assert_eq!(cli.as_deref(), Some("gemini"));
303 assert_eq!(branches.unwrap(), vec!["a", "b"]);
304 assert!(dry_run);
305 assert_eq!(preset.as_deref(), Some("dev"));
306 }
307 other => panic!("expected Start, got {other:?}"),
308 }
309 }
310
311 #[test]
314 fn stop_parses() {
315 let cli = parse(&["stop"]);
316 assert!(matches!(cli.command.unwrap(), Command::Stop));
317 }
318
319 #[test]
322 fn purge_without_force() {
323 let cli = parse(&["purge"]);
324 match cli.command.unwrap() {
325 Command::Purge { force } => assert!(!force),
326 other => panic!("expected Purge, got {other:?}"),
327 }
328 }
329
330 #[test]
331 fn purge_with_force() {
332 let cli = parse(&["purge", "--force"]);
333 match cli.command.unwrap() {
334 Command::Purge { force } => assert!(force),
335 other => panic!("expected Purge, got {other:?}"),
336 }
337 }
338
339 #[test]
342 fn status_parses() {
343 let cli = parse(&["status"]);
344 assert!(matches!(cli.command.unwrap(), Command::Status));
345 }
346
347 #[test]
350 fn list_clis_parses() {
351 let cli = parse(&["list-clis"]);
352 assert!(matches!(cli.command.unwrap(), Command::ListClis));
353 }
354
355 #[test]
358 fn add_cli_with_required_args() {
359 let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
360 match cli.command.unwrap() {
361 Command::AddCli {
362 name,
363 command,
364 display_name,
365 } => {
366 assert_eq!(name, "my-agent");
367 assert_eq!(command, "/usr/local/bin/my-agent");
368 assert!(display_name.is_none());
369 }
370 other => panic!("expected AddCli, got {other:?}"),
371 }
372 }
373
374 #[test]
375 fn add_cli_with_display_name() {
376 let cli = parse(&[
377 "add-cli",
378 "my-agent",
379 "my-agent",
380 "--display-name",
381 "My Agent",
382 ]);
383 match cli.command.unwrap() {
384 Command::AddCli {
385 name,
386 command,
387 display_name,
388 } => {
389 assert_eq!(name, "my-agent");
390 assert_eq!(command, "my-agent");
391 assert_eq!(display_name.as_deref(), Some("My Agent"));
392 }
393 other => panic!("expected AddCli, got {other:?}"),
394 }
395 }
396
397 #[test]
400 fn remove_cli_parses() {
401 let cli = parse(&["remove-cli", "my-agent"]);
402 match cli.command.unwrap() {
403 Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
404 other => panic!("expected RemoveCli, got {other:?}"),
405 }
406 }
407
408 #[test]
411 fn version_flag_is_accepted() {
412 let result = Cli::try_parse_from(["git-paw", "--version"]);
413 assert!(result.is_err());
415 let err = result.unwrap_err();
416 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
417 }
418
419 #[test]
420 fn help_flag_is_accepted() {
421 let result = Cli::try_parse_from(["git-paw", "--help"]);
422 assert!(result.is_err());
423 let err = result.unwrap_err();
424 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
425 }
426
427 #[test]
430 fn init_parses() {
431 let cli = parse(&["init"]);
432 assert!(matches!(cli.command.unwrap(), Command::Init));
433 }
434
435 #[test]
438 fn init_help_text() {
439 let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
440 assert!(result.is_err());
441 let err = result.unwrap_err();
442 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
443 }
444
445 #[test]
446 fn unknown_subcommand_is_rejected() {
447 let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
448 assert!(result.is_err());
449 }
450
451 #[test]
452 fn add_cli_missing_required_args_is_rejected() {
453 let result = Cli::try_parse_from(["git-paw", "add-cli"]);
454 assert!(result.is_err());
455 }
456
457 #[test]
460 fn replay_with_branch() {
461 let cli = parse(&["replay", "feat/add-auth"]);
462 match cli.command.unwrap() {
463 Command::Replay {
464 branch,
465 list,
466 color,
467 session,
468 } => {
469 assert_eq!(branch.as_deref(), Some("feat/add-auth"));
470 assert!(!list);
471 assert!(!color);
472 assert!(session.is_none());
473 }
474 other => panic!("expected Replay, got {other:?}"),
475 }
476 }
477
478 #[test]
479 fn replay_with_list() {
480 let cli = parse(&["replay", "--list"]);
481 match cli.command.unwrap() {
482 Command::Replay { branch, list, .. } => {
483 assert!(list);
484 assert!(branch.is_none());
485 }
486 other => panic!("expected Replay, got {other:?}"),
487 }
488 }
489
490 #[test]
491 fn replay_with_color() {
492 let cli = parse(&["replay", "feat/add-auth", "--color"]);
493 match cli.command.unwrap() {
494 Command::Replay { color, .. } => assert!(color),
495 other => panic!("expected Replay, got {other:?}"),
496 }
497 }
498
499 #[test]
500 fn replay_with_session() {
501 let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
502 match cli.command.unwrap() {
503 Command::Replay { session, .. } => {
504 assert_eq!(session.as_deref(), Some("paw-myproject"));
505 }
506 other => panic!("expected Replay, got {other:?}"),
507 }
508 }
509
510 #[test]
511 fn replay_no_args_fails() {
512 let result = Cli::try_parse_from(["git-paw", "replay"]);
513 assert!(result.is_err());
514 }
515
516 #[test]
517 fn replay_help_text() {
518 let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
519 assert!(result.is_err());
520 let err = result.unwrap_err();
521 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
522 let help = err.to_string();
523 assert!(help.contains("--list"));
524 assert!(help.contains("--color"));
525 assert!(help.contains("--session"));
526 }
527
528 #[test]
529 fn help_shows_replay_subcommand() {
530 let result = Cli::try_parse_from(["git-paw", "--help"]);
531 let err = result.unwrap_err();
532 let help = err.to_string();
533 assert!(
534 help.contains("replay"),
535 "help should list the replay subcommand"
536 );
537 }
538}