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