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