Skip to main content

git_paw/
cli.rs

1//! CLI argument parsing.
2//!
3//! Defines the command-line interface using `clap` v4 with derive macros.
4//! All subcommands, flags, and options are declared here.
5
6use clap::{Parser, Subcommand};
7
8/// Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions
9/// across git worktrees from a single terminal using tmux.
10#[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    /// Subcommand to run. Defaults to `start` if omitted.
32    #[command(subcommand)]
33    pub command: Option<Command>,
34}
35
36/// Available subcommands.
37#[derive(Debug, Subcommand)]
38pub enum Command {
39    /// Launch a new session or reattach to an existing one
40    #[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        /// AI CLI to use (e.g., claude, codex, gemini). Skips CLI picker if provided.
55        #[arg(long, help = "AI CLI to use (skips CLI picker)")]
56        cli: Option<String>,
57
58        /// Comma-separated branch names. Skips branch picker if provided.
59        #[arg(
60            long,
61            value_delimiter = ',',
62            help = "Comma-separated branches (skips branch picker)"
63        )]
64        branches: Option<Vec<String>>,
65
66        /// Launch from spec files instead of interactive selection.
67        #[arg(
68            long,
69            help = "Launch from spec files (reads .git-paw/config.toml [specs])"
70        )]
71        from_specs: bool,
72
73        /// Preview the session plan without executing.
74        #[arg(long, help = "Preview the session plan without executing")]
75        dry_run: bool,
76
77        /// Use a named preset from config.
78        #[arg(long, help = "Use a named preset from config")]
79        preset: Option<String>,
80    },
81
82    /// Stop the session (kills tmux, keeps worktrees and state)
83    #[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    /// Remove everything (tmux session, worktrees, and state)
92    #[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        /// Skip confirmation prompt.
100        #[arg(long, help = "Skip confirmation prompt")]
101        force: bool,
102    },
103
104    /// Show session state for the current repo
105    #[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    /// List detected and custom AI CLIs
114    #[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    /// Register a custom AI CLI
123    #[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        /// Name to register the CLI as.
133        #[arg(help = "Name to register the CLI as")]
134        name: String,
135
136        /// Command or path to the CLI binary.
137        #[arg(help = "Command or path to the CLI binary")]
138        command: String,
139
140        /// Optional display name for the CLI.
141        #[arg(long, help = "Display name shown in prompts")]
142        display_name: Option<String>,
143    },
144
145    /// Unregister a custom AI CLI
146    #[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        /// Name of the custom CLI to remove.
154        #[arg(help = "Name of the custom CLI to remove")]
155        name: String,
156    },
157
158    /// Initialize .git-paw/ directory and configuration
159    #[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    /// View captured session logs
168    #[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        /// Branch name to replay (fuzzy-matched against log filenames).
180        #[arg(required_unless_present = "list", help = "Branch to replay")]
181        branch: Option<String>,
182
183        /// List available log sessions and branches.
184        #[arg(long, help = "List available log sessions and branches")]
185        list: bool,
186
187        /// Display with ANSI colors via less -R.
188        #[arg(long, help = "Display with colors via less -R")]
189        color: bool,
190
191        /// Session name to replay from (defaults to most recent).
192        #[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    /// Helper: parse args as if running `git-paw <args>`.
203    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    // -- Default subcommand --
210
211    #[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    // -- Start subcommand --
221
222    #[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    // -- Stop subcommand --
312
313    #[test]
314    fn stop_parses() {
315        let cli = parse(&["stop"]);
316        assert!(matches!(cli.command.unwrap(), Command::Stop));
317    }
318
319    // -- Purge subcommand --
320
321    #[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    // -- Status subcommand --
340
341    #[test]
342    fn status_parses() {
343        let cli = parse(&["status"]);
344        assert!(matches!(cli.command.unwrap(), Command::Status));
345    }
346
347    // -- List-CLIs subcommand --
348
349    #[test]
350    fn list_clis_parses() {
351        let cli = parse(&["list-clis"]);
352        assert!(matches!(cli.command.unwrap(), Command::ListClis));
353    }
354
355    // -- Add-CLI subcommand --
356
357    #[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    // -- Remove-CLI subcommand --
398
399    #[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    // -- Help text quality --
409
410    #[test]
411    fn version_flag_is_accepted() {
412        let result = Cli::try_parse_from(["git-paw", "--version"]);
413        // clap returns Err(DisplayVersion) for --version, which is expected
414        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    // --- Gap #6: init_parses ---
428
429    #[test]
430    fn init_parses() {
431        let cli = parse(&["init"]);
432        assert!(matches!(cli.command.unwrap(), Command::Init));
433    }
434
435    // --- Gap #7: init_help_text ---
436
437    #[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    // -- Replay subcommand --
458
459    #[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}