Skip to main content

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}