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 /// Skip the confirmation prompt; required for non-TTY environments.
442 #[arg(short, long)]
443 yes: bool,
444 },
445
446 /// Install Claude Code skill for worktree task delegation
447 #[command(name = "setup-claude")]
448 SetupClaude,
449
450 /// Interactive shell integration setup
451 ShellSetup,
452
453 /// Hook helper: read a Claude Code hook payload from stdin (or a file)
454 /// and decide whether to allow or block the inbound tool use. Exits 0
455 /// to allow; non-zero with stderr message to block.
456 Guard {
457 /// Path to read the hook payload from, or "-" for stdin.
458 #[arg(long, value_name = "PATH")]
459 tool_input: String,
460 },
461
462 /// [Internal] Get worktree path for a branch
463 #[command(name = "_path", hide = true)]
464 Path {
465 /// Branch name
466 branch: Option<String>,
467
468 /// List branch names (for tab completion)
469 #[arg(long)]
470 list_branches: bool,
471
472 /// Interactive worktree selection
473 #[arg(short, long)]
474 interactive: bool,
475 },
476
477 /// Generate shell function for gw-cd / cw-cd
478 #[command(name = "_shell-function", hide = true)]
479 ShellFunction {
480 /// Shell type: bash, zsh, fish, or powershell
481 shell: String,
482 },
483
484 /// List config keys (for tab completion)
485 #[command(name = "_config-keys", hide = true)]
486 ConfigKeys,
487
488 /// Refresh update cache (background process)
489 #[command(name = "_update-cache", hide = true)]
490 UpdateCache,
491
492 /// List terminal launch method values (for tab completion)
493 #[command(name = "_term-values", hide = true)]
494 TermValues,
495
496 /// List preset names (for tab completion)
497 #[command(name = "_preset-names", hide = true)]
498 PresetNames,
499
500 /// List hook event names (for tab completion)
501 #[command(name = "_hook-events", hide = true)]
502 HookEvents,
503
504 /// [Internal] Execute an AI tool spawn spec file
505 #[command(name = "_spawn-ai", hide = true)]
506 SpawnAi {
507 /// Path to the JSON spawn spec
508 #[arg(value_hint = ValueHint::FilePath)]
509 spec: PathBuf,
510 },
511}
512
513#[derive(Subcommand, Debug)]
514pub enum ConfigAction {
515 /// Show current configuration summary
516 Show,
517 /// List all configuration keys, values, and descriptions
518 #[command(alias = "ls")]
519 List,
520 /// Get a configuration value
521 Get {
522 /// Dot-separated config key (e.g., ai_tool.command)
523 #[arg(value_parser = parse_config_key)]
524 key: String,
525 },
526 /// Set a configuration value
527 Set {
528 /// Dot-separated config key (e.g., ai_tool.command)
529 #[arg(value_parser = parse_config_key)]
530 key: String,
531 /// Value to set
532 value: String,
533 },
534 /// Use a predefined AI tool preset
535 UsePreset {
536 /// Preset name (e.g., claude, codex, no-op)
537 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::PRESET_NAMES))]
538 name: String,
539 },
540 /// List available presets
541 ListPresets,
542 /// Reset configuration to defaults
543 Reset,
544}
545
546#[derive(Subcommand, Debug)]
547pub enum BackupAction {
548 /// Create backup of worktree(s) using git bundle
549 Create {
550 /// Branch name to backup (default: current worktree)
551 branch: Option<String>,
552
553 /// Backup all worktrees
554 #[arg(long)]
555 all: bool,
556
557 /// Output directory for backups
558 #[arg(short, long)]
559 output: Option<String>,
560 },
561 /// List available backups
562 List {
563 /// Filter by branch name
564 branch: Option<String>,
565
566 /// Show all backups (not just current repo)
567 #[arg(short, long)]
568 all: bool,
569 },
570 /// Restore worktree from backup
571 Restore {
572 /// Branch name to restore
573 branch: String,
574
575 /// Custom path for restored worktree
576 #[arg(short, long)]
577 path: Option<String>,
578
579 /// Backup ID (timestamp) to restore (default: latest)
580 #[arg(long)]
581 id: Option<String>,
582 },
583}
584
585#[derive(Subcommand, Debug)]
586pub enum StashAction {
587 /// Save changes in current worktree to stash
588 Save {
589 /// Optional message to describe the stash
590 message: Option<String>,
591 },
592 /// List all stashes organized by worktree/branch
593 List,
594 /// Apply a stash to a different worktree
595 Apply {
596 /// Branch name of worktree to apply stash to
597 target_branch: String,
598
599 /// Stash reference (default: stash@{0})
600 #[arg(short, long, default_value = "stash@{0}")]
601 stash: String,
602 },
603}
604
605#[derive(Subcommand, Debug)]
606pub enum HookAction {
607 /// Add a new hook for an event
608 Add {
609 /// Hook event (e.g., worktree.post_create, merge.pre)
610 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
611 event: String,
612 /// Shell command to execute
613 command: String,
614 /// Custom hook identifier
615 #[arg(long)]
616 id: Option<String>,
617 /// Human-readable description
618 #[arg(short, long)]
619 description: Option<String>,
620 },
621 /// Remove a hook
622 Remove {
623 /// Hook event
624 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
625 event: String,
626 /// Hook identifier to remove
627 hook_id: String,
628 },
629 /// List all hooks
630 List {
631 /// Filter by event
632 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
633 event: Option<String>,
634 },
635 /// Enable a disabled hook
636 Enable {
637 /// Hook event
638 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
639 event: String,
640 /// Hook identifier
641 hook_id: String,
642 },
643 /// Disable a hook without removing it
644 Disable {
645 /// Hook event
646 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
647 event: String,
648 /// Hook identifier
649 hook_id: String,
650 },
651 /// Manually run all hooks for an event
652 Run {
653 /// Hook event to run
654 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
655 event: String,
656 /// Show what would be executed without running
657 #[arg(long)]
658 dry_run: bool,
659 },
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use clap::Parser;
666
667 /// Assert that `gw clean --no-cache` parses correctly. Pins the CacheControl
668 /// flag on Clean so accidental removal breaks the test.
669 #[test]
670 fn clean_accepts_no_cache_flag() {
671 let cli = Cli::try_parse_from(["gw", "clean", "--no-cache"]).expect("parses");
672 let Some(Commands::Clean { cache, .. }) = cli.command else {
673 panic!("expected Clean variant, got {:?}", cli.command);
674 };
675 assert!(cache.no_cache);
676 }
677}