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 matching filters (`--merged`, `--older-than`).
284 ///
285 /// For interactive selection, use `gw delete -i` instead.
286 Clean {
287 /// Delete worktrees for branches already merged to base
288 #[arg(long)]
289 merged: bool,
290
291 /// Delete worktrees older than duration (e.g., 7, 30d, 2w, 1m)
292 #[arg(long, value_name = "DURATION", value_parser = parse_duration_days)]
293 older_than: Option<u64>,
294
295 /// Show what would be deleted without deleting
296 #[arg(long)]
297 dry_run: bool,
298
299 /// Bypass the busy-detection gate: delete busy worktrees too
300 /// (default: skip worktrees another session is using)
301 #[arg(short, long)]
302 force: bool,
303
304 /// [Deprecated] Use `gw delete -i` for interactive selection.
305 #[arg(short, long, hide = true)]
306 interactive: bool,
307 },
308
309 /// Display worktree hierarchy as a tree
310 Tree {
311 #[command(flatten)]
312 cache: CacheControl,
313 },
314
315 /// Show worktree statistics
316 Stats {
317 #[command(flatten)]
318 cache: CacheControl,
319 },
320
321 /// Compare two branches
322 Diff {
323 /// First branch
324 branch1: String,
325 /// Second branch
326 branch2: String,
327 /// Show statistics only
328 #[arg(short, long)]
329 summary: bool,
330 /// Show changed files only
331 #[arg(short, long)]
332 files: bool,
333 },
334
335 /// Sync worktree with base branch
336 Sync {
337 /// Branch name (default: current worktree)
338 branch: Option<String>,
339
340 /// Sync all worktrees
341 #[arg(long)]
342 all: bool,
343
344 /// Only fetch updates without rebasing
345 #[arg(long)]
346 fetch_only: bool,
347
348 /// Use AI to resolve merge conflicts
349 #[arg(long)]
350 ai_merge: bool,
351
352 /// Resolve target as worktree name (instead of branch)
353 #[arg(short, long)]
354 worktree: bool,
355
356 /// Resolve target as branch name (instead of worktree)
357 #[arg(short, long, conflicts_with = "worktree")]
358 by_branch: bool,
359 },
360
361 /// Change base branch for a worktree
362 ChangeBase {
363 /// New base branch
364 new_base: String,
365 /// Branch name (default: current worktree)
366 branch: Option<String>,
367
368 /// Dry run (show what would happen)
369 #[arg(long)]
370 dry_run: bool,
371
372 /// Interactive rebase
373 #[arg(short, long)]
374 interactive: bool,
375
376 /// Resolve target as worktree name (instead of branch)
377 #[arg(short, long)]
378 worktree: bool,
379
380 /// Resolve target as branch name (instead of worktree)
381 #[arg(short, long, conflicts_with = "worktree")]
382 by_branch: bool,
383 },
384
385 /// Configuration management
386 Config {
387 #[command(subcommand)]
388 action: ConfigAction,
389 },
390
391 /// Backup and restore worktrees
392 Backup {
393 #[command(subcommand)]
394 action: BackupAction,
395 },
396
397 /// Stash management (worktree-aware)
398 Stash {
399 #[command(subcommand)]
400 action: StashAction,
401 },
402
403 /// Manage lifecycle hooks
404 Hook {
405 #[command(subcommand)]
406 action: HookAction,
407 },
408
409 /// Export worktree configuration to a file
410 Export {
411 /// Output file path
412 #[arg(short, long)]
413 output: Option<String>,
414 },
415
416 /// Import worktree configuration from a file
417 Import {
418 /// Path to the configuration file to import
419 import_file: String,
420
421 /// Apply the imported configuration (default: preview only)
422 #[arg(long)]
423 apply: bool,
424 },
425
426 /// Scan for repositories (global mode)
427 Scan {
428 /// Base directory to scan (default: home directory)
429 #[arg(short, long, value_hint = ValueHint::DirPath)]
430 dir: Option<std::path::PathBuf>,
431 },
432
433 /// Clean up stale registry entries (global mode)
434 Prune,
435
436 /// Run diagnostics
437 Doctor {
438 /// Hook-friendly mode: emit a single-line summary and exit 0.
439 #[arg(long)]
440 session_start: bool,
441 /// Suppress informational chatter; keep only the summary.
442 #[arg(long)]
443 quiet: bool,
444 },
445
446 /// Check for updates / upgrade
447 Upgrade {
448 /// Skip the confirmation prompt; required for non-TTY environments.
449 #[arg(short, long)]
450 yes: bool,
451 },
452
453 /// Install Claude Code skill for worktree task delegation
454 #[command(name = "setup-claude")]
455 SetupClaude,
456
457 /// Interactive shell integration setup
458 ShellSetup,
459
460 /// Hook helper: read a Claude Code hook payload from stdin (or a file)
461 /// and decide whether to allow or block the inbound tool use. Exits 0
462 /// to allow; non-zero with stderr message to block.
463 Guard {
464 /// Path to read the hook payload from, or "-" for stdin.
465 #[arg(long, value_name = "PATH")]
466 tool_input: String,
467 },
468
469 /// [Internal] Get worktree path for a branch
470 #[command(name = "_path", hide = true)]
471 Path {
472 /// Branch name
473 branch: Option<String>,
474
475 /// List branch names (for tab completion)
476 #[arg(long)]
477 list_branches: bool,
478
479 /// Interactive worktree selection
480 #[arg(short, long)]
481 interactive: bool,
482 },
483
484 /// Generate shell function for gw-cd / cw-cd
485 #[command(name = "_shell-function", hide = true)]
486 ShellFunction {
487 /// Shell type: bash, zsh, fish, or powershell
488 shell: String,
489 },
490
491 /// List config keys (for tab completion)
492 #[command(name = "_config-keys", hide = true)]
493 ConfigKeys,
494
495 /// Refresh update cache (background process)
496 #[command(name = "_update-cache", hide = true)]
497 UpdateCache,
498
499 /// List terminal launch method values (for tab completion)
500 #[command(name = "_term-values", hide = true)]
501 TermValues,
502
503 /// List preset names (for tab completion)
504 #[command(name = "_preset-names", hide = true)]
505 PresetNames,
506
507 /// List hook event names (for tab completion)
508 #[command(name = "_hook-events", hide = true)]
509 HookEvents,
510
511 /// [Internal] Execute an AI tool spawn spec file
512 #[command(name = "_spawn-ai", hide = true)]
513 SpawnAi {
514 /// Path to the JSON spawn spec. If omitted, resolves the most recent
515 /// spec for the current worktree from `<git-dir>/gw-spawn-last.json`.
516 #[arg(value_hint = ValueHint::FilePath)]
517 spec: Option<PathBuf>,
518 },
519}
520
521#[derive(Subcommand, Debug)]
522pub enum ConfigAction {
523 /// Show current configuration summary
524 Show,
525 /// List all configuration keys, values, and descriptions
526 #[command(alias = "ls")]
527 List,
528 /// Get a configuration value
529 Get {
530 /// Dot-separated config key (e.g., ai_tool.command)
531 #[arg(value_parser = parse_config_key)]
532 key: String,
533 },
534 /// Set a configuration value
535 Set {
536 /// Dot-separated config key (e.g., ai_tool.command)
537 #[arg(value_parser = parse_config_key)]
538 key: String,
539 /// Value to set
540 value: String,
541 },
542 /// Use a predefined AI tool preset
543 UsePreset {
544 /// Preset name (e.g., claude, codex, no-op)
545 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::PRESET_NAMES))]
546 name: String,
547 },
548 /// List available presets
549 ListPresets,
550 /// Reset configuration to defaults
551 Reset,
552}
553
554#[derive(Subcommand, Debug)]
555pub enum BackupAction {
556 /// Create backup of worktree(s) using git bundle
557 Create {
558 /// Branch name to backup (default: current worktree)
559 branch: Option<String>,
560
561 /// Backup all worktrees
562 #[arg(long)]
563 all: bool,
564
565 /// Output directory for backups
566 #[arg(short, long)]
567 output: Option<String>,
568 },
569 /// List available backups
570 List {
571 /// Filter by branch name
572 branch: Option<String>,
573
574 /// Show all backups (not just current repo)
575 #[arg(short, long)]
576 all: bool,
577 },
578 /// Restore worktree from backup
579 Restore {
580 /// Branch name to restore
581 branch: String,
582
583 /// Custom path for restored worktree
584 #[arg(short, long)]
585 path: Option<String>,
586
587 /// Backup ID (timestamp) to restore (default: latest)
588 #[arg(long)]
589 id: Option<String>,
590 },
591}
592
593#[derive(Subcommand, Debug)]
594pub enum StashAction {
595 /// Save changes in current worktree to stash
596 Save {
597 /// Optional message to describe the stash
598 message: Option<String>,
599 },
600 /// List all stashes organized by worktree/branch
601 List,
602 /// Apply a stash to a different worktree
603 Apply {
604 /// Branch name of worktree to apply stash to
605 target_branch: String,
606
607 /// Stash reference (default: stash@{0})
608 #[arg(short, long, default_value = "stash@{0}")]
609 stash: String,
610 },
611}
612
613#[derive(Subcommand, Debug)]
614pub enum HookAction {
615 /// Add a new hook for an event
616 Add {
617 /// Hook event (e.g., worktree.post_create, merge.pre)
618 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
619 event: String,
620 /// Shell command to execute
621 command: String,
622 /// Custom hook identifier
623 #[arg(long)]
624 id: Option<String>,
625 /// Human-readable description
626 #[arg(short, long)]
627 description: Option<String>,
628 },
629 /// Remove a hook
630 Remove {
631 /// Hook event
632 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
633 event: String,
634 /// Hook identifier to remove
635 hook_id: String,
636 },
637 /// List all hooks
638 List {
639 /// Filter by event
640 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
641 event: Option<String>,
642 },
643 /// Enable a disabled hook
644 Enable {
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 /// Disable a hook without removing it
652 Disable {
653 /// Hook event
654 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
655 event: String,
656 /// Hook identifier
657 hook_id: String,
658 },
659 /// Manually run all hooks for an event
660 Run {
661 /// Hook event to run
662 #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
663 event: String,
664 /// Show what would be executed without running
665 #[arg(long)]
666 dry_run: bool,
667 },
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use clap::Parser;
674
675 /// Assert that `gw clean --merged` parses correctly.
676 #[test]
677 fn clean_accepts_merged_flag() {
678 let cli = Cli::try_parse_from(["gw", "clean", "--merged"]).expect("parses");
679 let Some(Commands::Clean { merged, .. }) = cli.command else {
680 panic!("expected Clean variant, got {:?}", cli.command);
681 };
682 assert!(merged);
683 }
684}