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