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 --dry-run\n \
49 git paw start --preset backend"
50 )]
51 Start {
52 #[arg(long, help = "AI CLI to use (skips CLI picker)")]
54 cli: Option<String>,
55
56 #[arg(
58 long,
59 value_delimiter = ',',
60 help = "Comma-separated branches (skips branch picker)"
61 )]
62 branches: Option<Vec<String>>,
63
64 #[arg(long, help = "Preview the session plan without executing")]
66 dry_run: bool,
67
68 #[arg(long, help = "Use a named preset from config")]
70 preset: Option<String>,
71 },
72
73 #[command(
75 about = "Stop the session (kills tmux, keeps worktrees and state)",
76 long_about = "Kills the tmux session but preserves worktrees and session state on disk. \
77 Run `git paw start` later to recover the session.\n\n\
78 Example:\n git paw stop"
79 )]
80 Stop,
81
82 #[command(
84 about = "Remove everything (tmux session, worktrees, and state)",
85 long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
86 session state. Requires confirmation unless --force is used.\n\n\
87 Examples:\n git paw purge\n git paw purge --force"
88 )]
89 Purge {
90 #[arg(long, help = "Skip confirmation prompt")]
92 force: bool,
93 },
94
95 #[command(
97 about = "Show session state for the current repo",
98 long_about = "Displays the current session status, branches, CLIs, and worktree paths \
99 for the repository in the current directory.\n\n\
100 Example:\n git paw status"
101 )]
102 Status,
103
104 #[command(
106 about = "List detected and custom AI CLIs",
107 long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
108 registered in your config.\n\n\
109 Example:\n git paw list-clis"
110 )]
111 ListClis,
112
113 #[command(
115 about = "Register a custom AI CLI",
116 long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
117 The command can be an absolute path or a binary name on PATH.\n\n\
118 Examples:\n \
119 git paw add-cli my-agent /usr/local/bin/my-agent\n \
120 git paw add-cli my-agent my-agent --display-name \"My Agent\""
121 )]
122 AddCli {
123 #[arg(help = "Name to register the CLI as")]
125 name: String,
126
127 #[arg(help = "Command or path to the CLI binary")]
129 command: String,
130
131 #[arg(long, help = "Display name shown in prompts")]
133 display_name: Option<String>,
134 },
135
136 #[command(
138 about = "Unregister a custom AI CLI",
139 long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
140 removed — auto-detected CLIs cannot.\n\n\
141 Example:\n git paw remove-cli my-agent"
142 )]
143 RemoveCli {
144 #[arg(help = "Name of the custom CLI to remove")]
146 name: String,
147 },
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use clap::Parser;
154
155 fn parse(args: &[&str]) -> Cli {
157 let mut full = vec!["git-paw"];
158 full.extend(args);
159 Cli::try_parse_from(full).expect("failed to parse")
160 }
161
162 #[test]
165 fn no_args_defaults_to_none_command() {
166 let cli = parse(&[]);
167 assert!(
168 cli.command.is_none(),
169 "no args should yield None (handled as Start in main)"
170 );
171 }
172
173 #[test]
176 fn start_with_no_flags() {
177 let cli = parse(&["start"]);
178 match cli.command.unwrap() {
179 Command::Start {
180 cli,
181 branches,
182 dry_run,
183 preset,
184 } => {
185 assert!(cli.is_none());
186 assert!(branches.is_none());
187 assert!(!dry_run);
188 assert!(preset.is_none());
189 }
190 other => panic!("expected Start, got {other:?}"),
191 }
192 }
193
194 #[test]
195 fn start_with_cli_flag() {
196 let cli = parse(&["start", "--cli", "claude"]);
197 match cli.command.unwrap() {
198 Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
199 other => panic!("expected Start, got {other:?}"),
200 }
201 }
202
203 #[test]
204 fn start_with_branches_flag_comma_separated() {
205 let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
206 match cli.command.unwrap() {
207 Command::Start { branches, .. } => {
208 let b = branches.expect("branches should be set");
209 assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
210 }
211 other => panic!("expected Start, got {other:?}"),
212 }
213 }
214
215 #[test]
216 fn start_with_dry_run() {
217 let cli = parse(&["start", "--dry-run"]);
218 match cli.command.unwrap() {
219 Command::Start { dry_run, .. } => assert!(dry_run),
220 other => panic!("expected Start, got {other:?}"),
221 }
222 }
223
224 #[test]
225 fn start_with_preset() {
226 let cli = parse(&["start", "--preset", "backend"]);
227 match cli.command.unwrap() {
228 Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
229 other => panic!("expected Start, got {other:?}"),
230 }
231 }
232
233 #[test]
234 fn start_with_all_flags() {
235 let cli = parse(&[
236 "start",
237 "--cli",
238 "gemini",
239 "--branches",
240 "a,b",
241 "--dry-run",
242 "--preset",
243 "dev",
244 ]);
245 match cli.command.unwrap() {
246 Command::Start {
247 cli,
248 branches,
249 dry_run,
250 preset,
251 } => {
252 assert_eq!(cli.as_deref(), Some("gemini"));
253 assert_eq!(branches.unwrap(), vec!["a", "b"]);
254 assert!(dry_run);
255 assert_eq!(preset.as_deref(), Some("dev"));
256 }
257 other => panic!("expected Start, got {other:?}"),
258 }
259 }
260
261 #[test]
264 fn stop_parses() {
265 let cli = parse(&["stop"]);
266 assert!(matches!(cli.command.unwrap(), Command::Stop));
267 }
268
269 #[test]
272 fn purge_without_force() {
273 let cli = parse(&["purge"]);
274 match cli.command.unwrap() {
275 Command::Purge { force } => assert!(!force),
276 other => panic!("expected Purge, got {other:?}"),
277 }
278 }
279
280 #[test]
281 fn purge_with_force() {
282 let cli = parse(&["purge", "--force"]);
283 match cli.command.unwrap() {
284 Command::Purge { force } => assert!(force),
285 other => panic!("expected Purge, got {other:?}"),
286 }
287 }
288
289 #[test]
292 fn status_parses() {
293 let cli = parse(&["status"]);
294 assert!(matches!(cli.command.unwrap(), Command::Status));
295 }
296
297 #[test]
300 fn list_clis_parses() {
301 let cli = parse(&["list-clis"]);
302 assert!(matches!(cli.command.unwrap(), Command::ListClis));
303 }
304
305 #[test]
308 fn add_cli_with_required_args() {
309 let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
310 match cli.command.unwrap() {
311 Command::AddCli {
312 name,
313 command,
314 display_name,
315 } => {
316 assert_eq!(name, "my-agent");
317 assert_eq!(command, "/usr/local/bin/my-agent");
318 assert!(display_name.is_none());
319 }
320 other => panic!("expected AddCli, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn add_cli_with_display_name() {
326 let cli = parse(&[
327 "add-cli",
328 "my-agent",
329 "my-agent",
330 "--display-name",
331 "My Agent",
332 ]);
333 match cli.command.unwrap() {
334 Command::AddCli {
335 name,
336 command,
337 display_name,
338 } => {
339 assert_eq!(name, "my-agent");
340 assert_eq!(command, "my-agent");
341 assert_eq!(display_name.as_deref(), Some("My Agent"));
342 }
343 other => panic!("expected AddCli, got {other:?}"),
344 }
345 }
346
347 #[test]
350 fn remove_cli_parses() {
351 let cli = parse(&["remove-cli", "my-agent"]);
352 match cli.command.unwrap() {
353 Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
354 other => panic!("expected RemoveCli, got {other:?}"),
355 }
356 }
357
358 #[test]
361 fn version_flag_is_accepted() {
362 let result = Cli::try_parse_from(["git-paw", "--version"]);
363 assert!(result.is_err());
365 let err = result.unwrap_err();
366 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
367 }
368
369 #[test]
370 fn help_flag_is_accepted() {
371 let result = Cli::try_parse_from(["git-paw", "--help"]);
372 assert!(result.is_err());
373 let err = result.unwrap_err();
374 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
375 }
376
377 #[test]
378 fn unknown_subcommand_is_rejected() {
379 let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
380 assert!(result.is_err());
381 }
382
383 #[test]
384 fn add_cli_missing_required_args_is_rejected() {
385 let result = Cli::try_parse_from(["git-paw", "add-cli"]);
386 assert!(result.is_err());
387 }
388}