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 --dry-run\n  \
49                      git paw start --preset backend"
50    )]
51    Start {
52        /// AI CLI to use (e.g., claude, codex, gemini). Skips CLI picker if provided.
53        #[arg(long, help = "AI CLI to use (skips CLI picker)")]
54        cli: Option<String>,
55
56        /// Comma-separated branch names. Skips branch picker if provided.
57        #[arg(
58            long,
59            value_delimiter = ',',
60            help = "Comma-separated branches (skips branch picker)"
61        )]
62        branches: Option<Vec<String>>,
63
64        /// Preview the session plan without executing.
65        #[arg(long, help = "Preview the session plan without executing")]
66        dry_run: bool,
67
68        /// Use a named preset from config.
69        #[arg(long, help = "Use a named preset from config")]
70        preset: Option<String>,
71    },
72
73    /// Stop the session (kills tmux, keeps worktrees and state)
74    #[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    /// Remove everything (tmux session, worktrees, and state)
83    #[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        /// Skip confirmation prompt.
91        #[arg(long, help = "Skip confirmation prompt")]
92        force: bool,
93    },
94
95    /// Show session state for the current repo
96    #[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    /// List detected and custom AI CLIs
105    #[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    /// Register a custom AI CLI
114    #[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        /// Name to register the CLI as.
124        #[arg(help = "Name to register the CLI as")]
125        name: String,
126
127        /// Command or path to the CLI binary.
128        #[arg(help = "Command or path to the CLI binary")]
129        command: String,
130
131        /// Optional display name for the CLI.
132        #[arg(long, help = "Display name shown in prompts")]
133        display_name: Option<String>,
134    },
135
136    /// Unregister a custom AI CLI
137    #[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        /// Name of the custom CLI to remove.
145        #[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    /// Helper: parse args as if running `git-paw <args>`.
156    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    // -- Default subcommand --
163
164    #[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    // -- Start subcommand --
174
175    #[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    // -- Stop subcommand --
262
263    #[test]
264    fn stop_parses() {
265        let cli = parse(&["stop"]);
266        assert!(matches!(cli.command.unwrap(), Command::Stop));
267    }
268
269    // -- Purge subcommand --
270
271    #[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    // -- Status subcommand --
290
291    #[test]
292    fn status_parses() {
293        let cli = parse(&["status"]);
294        assert!(matches!(cli.command.unwrap(), Command::Status));
295    }
296
297    // -- List-CLIs subcommand --
298
299    #[test]
300    fn list_clis_parses() {
301        let cli = parse(&["list-clis"]);
302        assert!(matches!(cli.command.unwrap(), Command::ListClis));
303    }
304
305    // -- Add-CLI subcommand --
306
307    #[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    // -- Remove-CLI subcommand --
348
349    #[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    // -- Help text quality --
359
360    #[test]
361    fn version_flag_is_accepted() {
362        let result = Cli::try_parse_from(["git-paw", "--version"]);
363        // clap returns Err(DisplayVersion) for --version, which is expected
364        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}