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
442    /// Install Claude Code skill for worktree task delegation
443    #[command(name = "setup-claude")]
444    SetupClaude,
445
446    /// Interactive shell integration setup
447    ShellSetup,
448
449    /// Hook helper: read a Claude Code hook payload from stdin (or a file)
450    /// and decide whether to allow or block the inbound tool use. Exits 0
451    /// to allow; non-zero with stderr message to block.
452    Guard {
453        /// Path to read the hook payload from, or "-" for stdin.
454        #[arg(long, value_name = "PATH")]
455        tool_input: String,
456    },
457
458    /// [Internal] Get worktree path for a branch
459    #[command(name = "_path", hide = true)]
460    Path {
461        /// Branch name
462        branch: Option<String>,
463
464        /// List branch names (for tab completion)
465        #[arg(long)]
466        list_branches: bool,
467
468        /// Interactive worktree selection
469        #[arg(short, long)]
470        interactive: bool,
471    },
472
473    /// Generate shell function for gw-cd / cw-cd
474    #[command(name = "_shell-function", hide = true)]
475    ShellFunction {
476        /// Shell type: bash, zsh, fish, or powershell
477        shell: String,
478    },
479
480    /// List config keys (for tab completion)
481    #[command(name = "_config-keys", hide = true)]
482    ConfigKeys,
483
484    /// Refresh update cache (background process)
485    #[command(name = "_update-cache", hide = true)]
486    UpdateCache,
487
488    /// List terminal launch method values (for tab completion)
489    #[command(name = "_term-values", hide = true)]
490    TermValues,
491
492    /// List preset names (for tab completion)
493    #[command(name = "_preset-names", hide = true)]
494    PresetNames,
495
496    /// List hook event names (for tab completion)
497    #[command(name = "_hook-events", hide = true)]
498    HookEvents,
499
500    /// [Internal] Execute an AI tool spawn spec file
501    #[command(name = "_spawn-ai", hide = true)]
502    SpawnAi {
503        /// Path to the JSON spawn spec
504        #[arg(value_hint = ValueHint::FilePath)]
505        spec: PathBuf,
506    },
507}
508
509#[derive(Subcommand, Debug)]
510pub enum ConfigAction {
511    /// Show current configuration summary
512    Show,
513    /// List all configuration keys, values, and descriptions
514    #[command(alias = "ls")]
515    List,
516    /// Get a configuration value
517    Get {
518        /// Dot-separated config key (e.g., ai_tool.command)
519        #[arg(value_parser = parse_config_key)]
520        key: String,
521    },
522    /// Set a configuration value
523    Set {
524        /// Dot-separated config key (e.g., ai_tool.command)
525        #[arg(value_parser = parse_config_key)]
526        key: String,
527        /// Value to set
528        value: String,
529    },
530    /// Use a predefined AI tool preset
531    UsePreset {
532        /// Preset name (e.g., claude, codex, no-op)
533        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::PRESET_NAMES))]
534        name: String,
535    },
536    /// List available presets
537    ListPresets,
538    /// Reset configuration to defaults
539    Reset,
540}
541
542#[derive(Subcommand, Debug)]
543pub enum BackupAction {
544    /// Create backup of worktree(s) using git bundle
545    Create {
546        /// Branch name to backup (default: current worktree)
547        branch: Option<String>,
548
549        /// Backup all worktrees
550        #[arg(long)]
551        all: bool,
552
553        /// Output directory for backups
554        #[arg(short, long)]
555        output: Option<String>,
556    },
557    /// List available backups
558    List {
559        /// Filter by branch name
560        branch: Option<String>,
561
562        /// Show all backups (not just current repo)
563        #[arg(short, long)]
564        all: bool,
565    },
566    /// Restore worktree from backup
567    Restore {
568        /// Branch name to restore
569        branch: String,
570
571        /// Custom path for restored worktree
572        #[arg(short, long)]
573        path: Option<String>,
574
575        /// Backup ID (timestamp) to restore (default: latest)
576        #[arg(long)]
577        id: Option<String>,
578    },
579}
580
581#[derive(Subcommand, Debug)]
582pub enum StashAction {
583    /// Save changes in current worktree to stash
584    Save {
585        /// Optional message to describe the stash
586        message: Option<String>,
587    },
588    /// List all stashes organized by worktree/branch
589    List,
590    /// Apply a stash to a different worktree
591    Apply {
592        /// Branch name of worktree to apply stash to
593        target_branch: String,
594
595        /// Stash reference (default: stash@{0})
596        #[arg(short, long, default_value = "stash@{0}")]
597        stash: String,
598    },
599}
600
601#[derive(Subcommand, Debug)]
602pub enum HookAction {
603    /// Add a new hook for an event
604    Add {
605        /// Hook event (e.g., worktree.post_create, merge.pre)
606        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
607        event: String,
608        /// Shell command to execute
609        command: String,
610        /// Custom hook identifier
611        #[arg(long)]
612        id: Option<String>,
613        /// Human-readable description
614        #[arg(short, long)]
615        description: Option<String>,
616    },
617    /// Remove a hook
618    Remove {
619        /// Hook event
620        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
621        event: String,
622        /// Hook identifier to remove
623        hook_id: String,
624    },
625    /// List all hooks
626    List {
627        /// Filter by event
628        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
629        event: Option<String>,
630    },
631    /// Enable a disabled hook
632    Enable {
633        /// Hook event
634        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
635        event: String,
636        /// Hook identifier
637        hook_id: String,
638    },
639    /// Disable a hook without removing it
640    Disable {
641        /// Hook event
642        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
643        event: String,
644        /// Hook identifier
645        hook_id: String,
646    },
647    /// Manually run all hooks for an event
648    Run {
649        /// Hook event to run
650        #[arg(value_parser = clap::builder::PossibleValuesParser::new(crate::constants::HOOK_EVENTS))]
651        event: String,
652        /// Show what would be executed without running
653        #[arg(long)]
654        dry_run: bool,
655    },
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use clap::Parser;
662
663    /// Assert that `gw clean --no-cache` parses correctly. Pins the CacheControl
664    /// flag on Clean so accidental removal breaks the test.
665    #[test]
666    fn clean_accepts_no_cache_flag() {
667        let cli = Cli::try_parse_from(["gw", "clean", "--no-cache"]).expect("parses");
668        let Some(Commands::Clean { cache, .. }) = cli.command else {
669            panic!("expected Clean variant, got {:?}", cli.command);
670        };
671        assert!(cache.no_cache);
672    }
673}