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};
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}