git_worktree_manager/cli.rs
1/// CLI definitions using clap derive.
2///
3/// Mirrors the Typer-based CLI in src/git_worktree_manager/cli.py.
4pub mod completions;
5pub mod global;
6
7use clap::{Args, Parser, Subcommand, ValueHint};
8use std::path::PathBuf;
9
10/// Shared cache-bypass flag, flattened into subcommands that query PR status.
11#[derive(Args, Debug, Clone)]
12pub struct CacheControl {
13 /// Bypass PR status cache (60s TTL) and refresh from gh
14 #[arg(long)]
15 pub no_cache: bool,
16}
17
18/// Validate config key (accepts any string but provides completion hints).
19fn parse_config_key(s: &str) -> Result<String, String> {
20 Ok(s.to_string())
21}
22
23/// Parse duration strings like "30", "30d", "2w", "1m" into days.
24fn parse_duration_days(s: &str) -> Result<u64, String> {
25 let s = s.trim();
26 if s.is_empty() {
27 return Err("empty duration".into());
28 }
29
30 // Pure number = days
31 if let Ok(n) = s.parse::<u64>() {
32 return Ok(n);
33 }
34
35 let (num_str, suffix) = s.split_at(s.len() - 1);
36 let n: u64 = num_str
37 .parse()
38 .map_err(|_| format!("invalid duration: '{}'. Use e.g. 30, 7d, 2w, 1m", s))?;
39
40 match suffix {
41 "d" => Ok(n),
42 "w" => Ok(n * 7),
43 "m" => Ok(n * 30),
44 "y" => Ok(n * 365),
45 _ => Err(format!(
46 "unknown duration suffix '{}'. Use d (days), w (weeks), m (months), y (years)",
47 suffix
48 )),
49 }
50}
51
52/// Git worktree manager CLI.
53#[derive(Parser, Debug)]
54#[command(
55 name = "gw",
56 version,
57 about = "git worktree manager — AI coding assistant integration",
58 long_about = None,
59 arg_required_else_help = true,
60)]
61pub struct Cli {
62 /// Run in global mode (across all registered repositories)
63 #[arg(short = 'g', long = "global", global = true)]
64 pub global: bool,
65
66 /// Generate shell completions for the given shell
67 #[arg(long, value_name = "SHELL", value_parser = clap::builder::PossibleValuesParser::new(["bash", "zsh", "fish", "powershell", "elvish"]))]
68 pub generate_completion: Option<String>,
69
70 #[command(subcommand)]
71 pub command: Option<Commands>,
72}
73
74#[derive(Subcommand, Debug)]
75pub enum Commands {
76 /// Create new worktree for feature branch
77 #[command(group(
78 clap::ArgGroup::new("prompt_source")
79 .args(["prompt", "prompt_file", "prompt_stdin"])
80 .multiple(false)
81 .required(false)
82 ))]
83 New {
84 /// Branch name for the new worktree
85 name: String,
86
87 /// Custom worktree path (default: ../<repo>-<branch>)
88 #[arg(short, long, value_hint = ValueHint::DirPath)]
89 path: Option<String>,
90
91 /// Base branch to create from (default: from config)
92 #[arg(short = 'b', long = "base")]
93 base: Option<String>,
94
95 /// Skip AI tool launch
96 #[arg(long = "no-term")]
97 no_term: bool,
98
99 /// Terminal launch method (e.g., tmux, iterm-tab, zellij)
100 #[arg(short = 'T', long)]
101 term: Option<String>,
102
103 /// Launch AI tool in background
104 #[arg(long)]
105 bg: bool,
106
107 /// Initial prompt to pass to the AI tool (starts interactive session with task)
108 #[arg(long)]
109 prompt: Option<String>,
110
111 /// Read the initial prompt from a file (recommended for multi-line prompts)
112 #[arg(long = "prompt-file", value_hint = ValueHint::FilePath)]
113 prompt_file: Option<PathBuf>,
114
115 /// Read the initial prompt from standard input
116 #[arg(long = "prompt-stdin")]
117 prompt_stdin: bool,
118 },
119
120 /// Create GitHub Pull Request from worktree
121 Pr {
122 /// Branch name (default: current worktree branch)
123 branch: Option<String>,
124
125 /// PR title
126 #[arg(short, long)]
127 title: Option<String>,
128
129 /// PR body
130 #[arg(short = 'B', long)]
131 body: Option<String>,
132
133 /// Create as draft PR
134 #[arg(short, long)]
135 draft: bool,
136
137 /// Skip pushing to remote
138 #[arg(long)]
139 no_push: bool,
140
141 /// Resolve target as worktree name (instead of branch)
142 #[arg(short, long)]
143 worktree: bool,
144
145 /// Resolve target as branch name (instead of worktree)
146 #[arg(short = 'b', long = "by-branch", conflicts_with = "worktree")]
147 by_branch: bool,
148 },
149
150 /// Merge feature branch into base branch
151 Merge {
152 /// Branch name (default: current worktree branch)
153 branch: Option<String>,
154
155 /// Interactive rebase
156 #[arg(short, long)]
157 interactive: bool,
158
159 /// Dry run (show what would happen)
160 #[arg(long)]
161 dry_run: bool,
162
163 /// Push to remote after merge
164 #[arg(long)]
165 push: bool,
166
167 /// Use AI to resolve merge conflicts
168 #[arg(long)]
169 ai_merge: bool,
170
171 /// Resolve target as worktree name (instead of branch)
172 #[arg(short, long)]
173 worktree: bool,
174 },
175
176 /// Resume AI work in a worktree
177 Resume {
178 /// Branch name to resume (default: current worktree)
179 branch: Option<String>,
180
181 /// Terminal launch method
182 #[arg(short = 'T', long)]
183 term: Option<String>,
184
185 /// Launch AI tool in background
186 #[arg(long)]
187 bg: bool,
188
189 /// Resolve target as worktree name (instead of branch)
190 #[arg(short, long)]
191 worktree: bool,
192
193 /// Resolve target as branch name (instead of worktree)
194 #[arg(short, long, conflicts_with = "worktree")]
195 by_branch: bool,
196 },
197
198 /// Open interactive shell or execute command in a worktree
199 Shell {
200 /// Worktree branch to shell into
201 worktree: Option<String>,
202
203 /// Command and arguments to execute
204 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
205 args: Vec<String>,
206 },
207
208 /// Show current worktree status
209 Status {
210 #[command(flatten)]
211 cache: CacheControl,
212 },
213
214 /// Delete one or more worktrees.
215 ///
216 /// With no arguments: deletes the current worktree (must be inside one).
217 /// With one or more positional targets: deletes each of them; flags apply
218 /// to every target.
219 /// With `-i`: opens a multi-select UI.
220 ///
221 /// Exits 0 on full success, 1 if the user cancelled at the confirmation
222 /// prompt or in the interactive UI, 2 if any target could not be deleted
223 /// (not found, busy, or an error).
224 Delete {
225 /// Branch names or paths of worktrees to delete.
226 /// If empty and --interactive is not set, deletes the current worktree.
227 #[arg(conflicts_with = "interactive")]
228 targets: Vec<String>,
229
230 /// Interactive multi-select UI (mutually exclusive with positional targets)
231 #[arg(short, long, conflicts_with = "targets")]
232 interactive: bool,
233
234 /// Show what would be deleted without deleting
235 #[arg(long)]
236 dry_run: bool,
237
238 /// Keep the branch (only remove worktree)
239 #[arg(short = 'k', long)]
240 keep_branch: bool,
241
242 /// Also delete the remote branch
243 #[arg(short = 'r', long)]
244 delete_remote: bool,
245
246 /// Force remove: also bypasses the busy-detection gate (skips the
247 /// "worktree is in use" check and deletes anyway)
248 #[arg(short, long, conflicts_with = "no_force")]
249 force: bool,
250
251 /// Don't use --force flag
252 #[arg(long)]
253 no_force: bool,
254
255 /// Resolve targets as worktree names (instead of branches)
256 #[arg(short, long)]
257 worktree: bool,
258
259 /// Resolve targets as branch names (instead of worktrees)
260 #[arg(short, long, conflicts_with = "worktree")]
261 branch: bool,
262 },
263
264 /// List all worktrees
265 #[command(alias = "ls")]
266 List {
267 #[command(flatten)]
268 cache: CacheControl,
269 },
270
271 /// Batch cleanup of worktrees
272 ///
273 /// Note: `--no-cache` only affects the interactive listing path inside `clean`
274 /// (which calls `get_worktree_status`). Merge/age-based deletion logic in `clean`
275 /// uses git directly and does not consult the PR cache.
276 Clean {
277 #[command(flatten)]
278 cache: CacheControl,
279
280 /// Delete worktrees for branches already merged to base
281 #[arg(long)]
282 merged: bool,
283
284 /// Delete worktrees older than duration (e.g., 7, 30d, 2w, 1m)
285 #[arg(long, value_name = "DURATION", value_parser = parse_duration_days)]
286 older_than: Option<u64>,
287
288 /// Interactive selection UI
289 #[arg(short, long)]
290 interactive: bool,
291
292 /// Show what would be deleted without deleting
293 #[arg(long)]
294 dry_run: bool,
295
296 /// Bypass the busy-detection gate: delete busy worktrees too
297 /// (default: skip worktrees another session is using)
298 #[arg(short, long)]
299 force: bool,
300 },
301
302 /// Display worktree hierarchy as a tree
303 Tree {
304 #[command(flatten)]
305 cache: CacheControl,
306 },
307
308 /// Show worktree statistics
309 Stats {
310 #[command(flatten)]
311 cache: CacheControl,
312 },
313
314 /// Compare two branches
315 Diff {
316 /// First branch
317 branch1: String,
318 /// Second branch
319 branch2: String,
320 /// Show statistics only
321 #[arg(short, long)]
322 summary: bool,
323 /// Show changed files only
324 #[arg(short, long)]
325 files: bool,
326 },
327
328 /// Sync worktree with base branch
329 Sync {
330 /// Branch name (default: current worktree)
331 branch: Option<String>,
332
333 /// Sync all worktrees
334 #[arg(long)]
335 all: bool,
336
337 /// Only fetch updates without rebasing
338 #[arg(long)]
339 fetch_only: bool,
340
341 /// Use AI to resolve merge conflicts
342 #[arg(long)]
343 ai_merge: bool,
344
345 /// Resolve target as worktree name (instead of branch)
346 #[arg(short, long)]
347 worktree: bool,
348
349 /// Resolve target as branch name (instead of worktree)
350 #[arg(short, long, conflicts_with = "worktree")]
351 by_branch: bool,
352 },
353
354 /// Change base branch for a worktree
355 ChangeBase {
356 /// New base branch
357 new_base: String,
358 /// Branch name (default: current worktree)
359 branch: Option<String>,
360
361 /// Dry run (show what would happen)
362 #[arg(long)]
363 dry_run: bool,
364
365 /// Interactive rebase
366 #[arg(short, long)]
367 interactive: bool,
368
369 /// Resolve target as worktree name (instead of branch)
370 #[arg(short, long)]
371 worktree: bool,
372
373 /// Resolve target as branch name (instead of worktree)
374 #[arg(short, long, conflicts_with = "worktree")]
375 by_branch: bool,
376 },
377
378 /// Configuration management
379 Config {
380 #[command(subcommand)]
381 action: ConfigAction,
382 },
383
384 /// Backup and restore worktrees
385 Backup {
386 #[command(subcommand)]
387 action: BackupAction,
388 },
389
390 /// Stash management (worktree-aware)
391 Stash {
392 #[command(subcommand)]
393 action: StashAction,
394 },
395
396 /// Manage lifecycle hooks
397 Hook {
398 #[command(subcommand)]
399 action: HookAction,
400 },
401
402 /// Export worktree configuration to a file
403 Export {
404 /// Output file path
405 #[arg(short, long)]
406 output: Option<String>,
407 },
408
409 /// Import worktree configuration from a file
410 Import {
411 /// Path to the configuration file to import
412 import_file: String,
413
414 /// Apply the imported configuration (default: preview only)
415 #[arg(long)]
416 apply: bool,
417 },
418
419 /// Scan for repositories (global mode)
420 Scan {
421 /// Base directory to scan (default: home directory)
422 #[arg(short, long, value_hint = ValueHint::DirPath)]
423 dir: Option<std::path::PathBuf>,
424 },
425
426 /// Clean up stale registry entries (global mode)
427 Prune,
428
429 /// Run diagnostics
430 Doctor {
431 /// Hook-friendly mode: emit a single-line summary and exit 0.
432 #[arg(long)]
433 session_start: bool,
434 /// Suppress informational chatter; keep only the summary.
435 #[arg(long)]
436 quiet: bool,
437 },
438
439 /// Check for updates / upgrade
440 Upgrade,
441
442 /// Install Claude Code skill for worktree task delegation
443 #[command(name = "setup-claude")]
444 SetupClaude,
445
446 /// Interactive shell integration setup
447 ShellSetup,
448
449 /// Hook helper: read a Claude Code hook payload from stdin (or a file)
450 /// and decide whether to allow or block the inbound tool use. Exits 0
451 /// to allow; non-zero with stderr message to block.
452 Guard {
453 /// Path to read the hook payload from, or "-" for stdin.
454 #[arg(long, value_name = "PATH")]
455 tool_input: String,
456 },
457
458 /// [Internal] Get worktree path for a branch
459 #[command(name = "_path", hide = true)]
460 Path {
461 /// Branch name
462 branch: Option<String>,
463
464 /// List branch names (for tab completion)
465 #[arg(long)]
466 list_branches: bool,
467
468 /// Interactive worktree selection
469 #[arg(short, long)]
470 interactive: bool,
471 },
472
473 /// Generate shell function for gw-cd / cw-cd
474 #[command(name = "_shell-function", hide = true)]
475 ShellFunction {
476 /// Shell type: bash, zsh, fish, or powershell
477 shell: String,
478 },
479
480 /// List config keys (for tab completion)
481 #[command(name = "_config-keys", hide = true)]
482 ConfigKeys,
483
484 /// Refresh update cache (background process)
485 #[command(name = "_update-cache", hide = true)]
486 UpdateCache,
487
488 /// List terminal launch method values (for tab completion)
489 #[command(name = "_term-values", hide = true)]
490 TermValues,
491
492 /// List preset names (for tab completion)
493 #[command(name = "_preset-names", hide = true)]
494 PresetNames,
495
496 /// List hook event names (for tab completion)
497 #[command(name = "_hook-events", hide = true)]
498 HookEvents,
499
500 /// [Internal] Execute an AI tool spawn spec file
501 #[command(name = "_spawn-ai", hide = true)]
502 SpawnAi {
503 /// Path to the JSON spawn spec
504 #[arg(value_hint = ValueHint::FilePath)]
505 spec: PathBuf,
506 },
507}
508
509#[derive(Subcommand, Debug)]
510pub enum ConfigAction {
511 /// Show current configuration summary
512 Show,
513 /// List all configuration keys, values, and descriptions
514 #[command(alias = "ls")]
515 List,
516 /// Get a configuration value
517 Get {
518 /// Dot-separated config key (e.g., ai_tool.command)
519 #[arg(value_parser = parse_config_key)]
520 key: String,
521 },
522 /// Set a configuration value
523 Set {
524 /// Dot-separated config key (e.g., ai_tool.command)
525 #[arg(value_parser = parse_config_key)]
526 key: String,
527 /// Value to set
528 value: String,
529 },
530 /// Use a predefined AI tool preset
531 UsePreset {
532 /// Preset name (e.g., claude, codex, no-op)
533 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::PRESET_NAMES))]
534 name: String,
535 },
536 /// List available presets
537 ListPresets,
538 /// Reset configuration to defaults
539 Reset,
540}
541
542#[derive(Subcommand, Debug)]
543pub enum BackupAction {
544 /// Create backup of worktree(s) using git bundle
545 Create {
546 /// Branch name to backup (default: current worktree)
547 branch: Option<String>,
548
549 /// Backup all worktrees
550 #[arg(long)]
551 all: bool,
552
553 /// Output directory for backups
554 #[arg(short, long)]
555 output: Option<String>,
556 },
557 /// List available backups
558 List {
559 /// Filter by branch name
560 branch: Option<String>,
561
562 /// Show all backups (not just current repo)
563 #[arg(short, long)]
564 all: bool,
565 },
566 /// Restore worktree from backup
567 Restore {
568 /// Branch name to restore
569 branch: String,
570
571 /// Custom path for restored worktree
572 #[arg(short, long)]
573 path: Option<String>,
574
575 /// Backup ID (timestamp) to restore (default: latest)
576 #[arg(long)]
577 id: Option<String>,
578 },
579}
580
581#[derive(Subcommand, Debug)]
582pub enum StashAction {
583 /// Save changes in current worktree to stash
584 Save {
585 /// Optional message to describe the stash
586 message: Option<String>,
587 },
588 /// List all stashes organized by worktree/branch
589 List,
590 /// Apply a stash to a different worktree
591 Apply {
592 /// Branch name of worktree to apply stash to
593 target_branch: String,
594
595 /// Stash reference (default: stash@{0})
596 #[arg(short, long, default_value = "stash@{0}")]
597 stash: String,
598 },
599}
600
601#[derive(Subcommand, Debug)]
602pub enum HookAction {
603 /// Add a new hook for an event
604 Add {
605 /// Hook event (e.g., worktree.post_create, merge.pre)
606 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
607 event: String,
608 /// Shell command to execute
609 command: String,
610 /// Custom hook identifier
611 #[arg(long)]
612 id: Option<String>,
613 /// Human-readable description
614 #[arg(short, long)]
615 description: Option<String>,
616 },
617 /// Remove a hook
618 Remove {
619 /// Hook event
620 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
621 event: String,
622 /// Hook identifier to remove
623 hook_id: String,
624 },
625 /// List all hooks
626 List {
627 /// Filter by event
628 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
629 event: Option<String>,
630 },
631 /// Enable a disabled hook
632 Enable {
633 /// Hook event
634 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
635 event: String,
636 /// Hook identifier
637 hook_id: String,
638 },
639 /// Disable a hook without removing it
640 Disable {
641 /// Hook event
642 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
643 event: String,
644 /// Hook identifier
645 hook_id: String,
646 },
647 /// Manually run all hooks for an event
648 Run {
649 /// Hook event to run
650 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
651 event: String,
652 /// Show what would be executed without running
653 #[arg(long)]
654 dry_run: bool,
655 },
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use clap::Parser;
662
663 /// Assert that `gw clean --no-cache` parses correctly. Pins the CacheControl
664 /// flag on Clean so accidental removal breaks the test.
665 #[test]
666 fn clean_accepts_no_cache_flag() {
667 let cli = Cli::try_parse_from(["gw", "clean", "--no-cache"]).expect("parses");
668 let Some(Commands::Clean { cache, .. }) = cli.command else {
669 panic!("expected Clean variant, got {:?}", cli.command);
670 };
671 assert!(cache.no_cache);
672 }
673}