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 a worktree
215 Delete {
216 /// Branch name or path of worktree to delete (default: current worktree)
217 target: Option<String>,
218
219 /// Keep the branch (only remove worktree)
220 #[arg(short = 'k', long)]
221 keep_branch: bool,
222
223 /// Also delete the remote branch
224 #[arg(short = 'r', long)]
225 delete_remote: bool,
226
227 /// Force remove: also bypasses the busy-detection gate (skips the
228 /// "worktree is in use" check and deletes anyway)
229 #[arg(short, long, conflicts_with = "no_force")]
230 force: bool,
231
232 /// Don't use --force flag
233 #[arg(long)]
234 no_force: bool,
235
236 /// Resolve target as worktree name (instead of branch)
237 #[arg(short, long)]
238 worktree: bool,
239
240 /// Resolve target as branch name (instead of worktree)
241 #[arg(short, long, conflicts_with = "worktree")]
242 branch: bool,
243 },
244
245 /// List all worktrees
246 #[command(alias = "ls")]
247 List {
248 #[command(flatten)]
249 cache: CacheControl,
250 },
251
252 /// Batch cleanup of worktrees
253 ///
254 /// Note: `--no-cache` only affects the interactive listing path inside `clean`
255 /// (which calls `get_worktree_status`). Merge/age-based deletion logic in `clean`
256 /// uses git directly and does not consult the PR cache.
257 Clean {
258 #[command(flatten)]
259 cache: CacheControl,
260
261 /// Delete worktrees for branches already merged to base
262 #[arg(long)]
263 merged: bool,
264
265 /// Delete worktrees older than duration (e.g., 7, 30d, 2w, 1m)
266 #[arg(long, value_name = "DURATION", value_parser = parse_duration_days)]
267 older_than: Option<u64>,
268
269 /// Interactive selection UI
270 #[arg(short, long)]
271 interactive: bool,
272
273 /// Show what would be deleted without deleting
274 #[arg(long)]
275 dry_run: bool,
276
277 /// Bypass the busy-detection gate: delete busy worktrees too
278 /// (default: skip worktrees another session is using)
279 #[arg(short, long)]
280 force: bool,
281 },
282
283 /// Display worktree hierarchy as a tree
284 Tree {
285 #[command(flatten)]
286 cache: CacheControl,
287 },
288
289 /// Show worktree statistics
290 Stats {
291 #[command(flatten)]
292 cache: CacheControl,
293 },
294
295 /// Compare two branches
296 Diff {
297 /// First branch
298 branch1: String,
299 /// Second branch
300 branch2: String,
301 /// Show statistics only
302 #[arg(short, long)]
303 summary: bool,
304 /// Show changed files only
305 #[arg(short, long)]
306 files: bool,
307 },
308
309 /// Sync worktree with base branch
310 Sync {
311 /// Branch name (default: current worktree)
312 branch: Option<String>,
313
314 /// Sync all worktrees
315 #[arg(long)]
316 all: bool,
317
318 /// Only fetch updates without rebasing
319 #[arg(long)]
320 fetch_only: bool,
321
322 /// Use AI to resolve merge conflicts
323 #[arg(long)]
324 ai_merge: bool,
325
326 /// Resolve target as worktree name (instead of branch)
327 #[arg(short, long)]
328 worktree: bool,
329
330 /// Resolve target as branch name (instead of worktree)
331 #[arg(short, long, conflicts_with = "worktree")]
332 by_branch: bool,
333 },
334
335 /// Change base branch for a worktree
336 ChangeBase {
337 /// New base branch
338 new_base: String,
339 /// Branch name (default: current worktree)
340 branch: Option<String>,
341
342 /// Dry run (show what would happen)
343 #[arg(long)]
344 dry_run: bool,
345
346 /// Interactive rebase
347 #[arg(short, long)]
348 interactive: bool,
349
350 /// Resolve target as worktree name (instead of branch)
351 #[arg(short, long)]
352 worktree: bool,
353
354 /// Resolve target as branch name (instead of worktree)
355 #[arg(short, long, conflicts_with = "worktree")]
356 by_branch: bool,
357 },
358
359 /// Configuration management
360 Config {
361 #[command(subcommand)]
362 action: ConfigAction,
363 },
364
365 /// Backup and restore worktrees
366 Backup {
367 #[command(subcommand)]
368 action: BackupAction,
369 },
370
371 /// Stash management (worktree-aware)
372 Stash {
373 #[command(subcommand)]
374 action: StashAction,
375 },
376
377 /// Manage lifecycle hooks
378 Hook {
379 #[command(subcommand)]
380 action: HookAction,
381 },
382
383 /// Export worktree configuration to a file
384 Export {
385 /// Output file path
386 #[arg(short, long)]
387 output: Option<String>,
388 },
389
390 /// Import worktree configuration from a file
391 Import {
392 /// Path to the configuration file to import
393 import_file: String,
394
395 /// Apply the imported configuration (default: preview only)
396 #[arg(long)]
397 apply: bool,
398 },
399
400 /// Scan for repositories (global mode)
401 Scan {
402 /// Base directory to scan (default: home directory)
403 #[arg(short, long, value_hint = ValueHint::DirPath)]
404 dir: Option<std::path::PathBuf>,
405 },
406
407 /// Clean up stale registry entries (global mode)
408 Prune,
409
410 /// Run diagnostics
411 Doctor,
412
413 /// Check for updates / upgrade
414 Upgrade,
415
416 /// Install Claude Code skill for worktree task delegation
417 #[command(name = "setup-claude")]
418 SetupClaude,
419
420 /// Interactive shell integration setup
421 ShellSetup,
422
423 /// [Internal] Get worktree path for a branch
424 #[command(name = "_path", hide = true)]
425 Path {
426 /// Branch name
427 branch: Option<String>,
428
429 /// List branch names (for tab completion)
430 #[arg(long)]
431 list_branches: bool,
432
433 /// Interactive worktree selection
434 #[arg(short, long)]
435 interactive: bool,
436 },
437
438 /// Generate shell function for gw-cd / cw-cd
439 #[command(name = "_shell-function", hide = true)]
440 ShellFunction {
441 /// Shell type: bash, zsh, fish, or powershell
442 shell: String,
443 },
444
445 /// List config keys (for tab completion)
446 #[command(name = "_config-keys", hide = true)]
447 ConfigKeys,
448
449 /// Refresh update cache (background process)
450 #[command(name = "_update-cache", hide = true)]
451 UpdateCache,
452
453 /// List terminal launch method values (for tab completion)
454 #[command(name = "_term-values", hide = true)]
455 TermValues,
456
457 /// List preset names (for tab completion)
458 #[command(name = "_preset-names", hide = true)]
459 PresetNames,
460
461 /// List hook event names (for tab completion)
462 #[command(name = "_hook-events", hide = true)]
463 HookEvents,
464}
465
466#[derive(Subcommand, Debug)]
467pub enum ConfigAction {
468 /// Show current configuration summary
469 Show,
470 /// List all configuration keys, values, and descriptions
471 #[command(alias = "ls")]
472 List,
473 /// Get a configuration value
474 Get {
475 /// Dot-separated config key (e.g., ai_tool.command)
476 #[arg(value_parser = parse_config_key)]
477 key: String,
478 },
479 /// Set a configuration value
480 Set {
481 /// Dot-separated config key (e.g., ai_tool.command)
482 #[arg(value_parser = parse_config_key)]
483 key: String,
484 /// Value to set
485 value: String,
486 },
487 /// Use a predefined AI tool preset
488 UsePreset {
489 /// Preset name (e.g., claude, codex, no-op)
490 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::PRESET_NAMES))]
491 name: String,
492 },
493 /// List available presets
494 ListPresets,
495 /// Reset configuration to defaults
496 Reset,
497}
498
499#[derive(Subcommand, Debug)]
500pub enum BackupAction {
501 /// Create backup of worktree(s) using git bundle
502 Create {
503 /// Branch name to backup (default: current worktree)
504 branch: Option<String>,
505
506 /// Backup all worktrees
507 #[arg(long)]
508 all: bool,
509
510 /// Output directory for backups
511 #[arg(short, long)]
512 output: Option<String>,
513 },
514 /// List available backups
515 List {
516 /// Filter by branch name
517 branch: Option<String>,
518
519 /// Show all backups (not just current repo)
520 #[arg(short, long)]
521 all: bool,
522 },
523 /// Restore worktree from backup
524 Restore {
525 /// Branch name to restore
526 branch: String,
527
528 /// Custom path for restored worktree
529 #[arg(short, long)]
530 path: Option<String>,
531
532 /// Backup ID (timestamp) to restore (default: latest)
533 #[arg(long)]
534 id: Option<String>,
535 },
536}
537
538#[derive(Subcommand, Debug)]
539pub enum StashAction {
540 /// Save changes in current worktree to stash
541 Save {
542 /// Optional message to describe the stash
543 message: Option<String>,
544 },
545 /// List all stashes organized by worktree/branch
546 List,
547 /// Apply a stash to a different worktree
548 Apply {
549 /// Branch name of worktree to apply stash to
550 target_branch: String,
551
552 /// Stash reference (default: stash@{0})
553 #[arg(short, long, default_value = "stash@{0}")]
554 stash: String,
555 },
556}
557
558#[derive(Subcommand, Debug)]
559pub enum HookAction {
560 /// Add a new hook for an event
561 Add {
562 /// Hook event (e.g., worktree.post_create, merge.pre)
563 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
564 event: String,
565 /// Shell command to execute
566 command: String,
567 /// Custom hook identifier
568 #[arg(long)]
569 id: Option<String>,
570 /// Human-readable description
571 #[arg(short, long)]
572 description: Option<String>,
573 },
574 /// Remove a hook
575 Remove {
576 /// Hook event
577 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
578 event: String,
579 /// Hook identifier to remove
580 hook_id: String,
581 },
582 /// List all hooks
583 List {
584 /// Filter by event
585 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
586 event: Option<String>,
587 },
588 /// Enable a disabled hook
589 Enable {
590 /// Hook event
591 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
592 event: String,
593 /// Hook identifier
594 hook_id: String,
595 },
596 /// Disable a hook without removing it
597 Disable {
598 /// Hook event
599 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
600 event: String,
601 /// Hook identifier
602 hook_id: String,
603 },
604 /// Manually run all hooks for an event
605 Run {
606 /// Hook event to run
607 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
608 event: String,
609 /// Show what would be executed without running
610 #[arg(long)]
611 dry_run: bool,
612 },
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use clap::Parser;
619
620 /// Assert that `gw clean --no-cache` parses correctly. Pins the CacheControl
621 /// flag on Clean so accidental removal breaks the test.
622 #[test]
623 fn clean_accepts_no_cache_flag() {
624 let cli = Cli::try_parse_from(["gw", "clean", "--no-cache"]).expect("parses");
625 let Some(Commands::Clean { cache, .. }) = cli.command else {
626 panic!("expected Clean variant, got {:?}", cli.command);
627 };
628 assert!(cache.no_cache);
629 }
630}