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