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