git_workty/
lib.rs

1pub mod commands;
2pub mod config;
3pub mod gh;
4pub mod git;
5pub mod shell;
6pub mod status;
7pub mod ui;
8pub mod worktree;
9
10use clap::{Parser, Subcommand};
11use clap_complete::Shell;
12use std::path::PathBuf;
13
14use crate::commands::{clean, completions, doctor, go, init, install_man, list, new, pick, pr, rm};
15use crate::git::GitRepo;
16use crate::ui::UiOptions;
17
18pub const ABOUT: &str = "Git worktrees as daily-driver workspaces
19
20workty makes Git worktrees feel like workspaces/tabs. Switch context without
21stashing or WIP commits, see everything in flight with a dashboard, and clean
22up merged work safely.";
23
24pub const AFTER_HELP: &str = "EXAMPLES:
25    git workty                    Show dashboard of all worktrees
26    git workty new feat/login     Create new workspace for feat/login
27    git workty go feat/login      Print path to feat/login worktree
28    git workty pick               Fuzzy select a worktree (interactive)
29    git workty rm feat/login      Remove the feat/login worktree
30    git workty clean --merged     Remove all merged worktrees
31
32SHELL INTEGRATION:
33    Add to your shell config:
34        eval \"$(git workty init zsh)\"
35
36    This provides:
37        wcd   - fuzzy select and cd to a worktree
38        wnew  - create new worktree and cd into it
39        wgo   - go to a worktree by name";
40
41#[derive(Parser)]
42#[command(name = "git-workty", bin_name = "git workty")]
43#[command(author, version, about = ABOUT, after_help = AFTER_HELP)]
44#[command(propagate_version = true)]
45pub struct Cli {
46    /// Disable colored output
47    #[arg(long, global = true, env = "NO_COLOR")]
48    pub no_color: bool,
49
50    /// Use ASCII-only symbols
51    #[arg(long, global = true)]
52    pub ascii: bool,
53
54    /// Output in JSON format
55    #[arg(long, global = true)]
56    pub json: bool,
57
58    /// Run as if started in <PATH>
59    #[arg(short = 'C', global = true, value_name = "PATH")]
60    pub directory: Option<PathBuf>,
61
62    /// Assume yes to prompts
63    #[arg(long, short = 'y', global = true)]
64    pub yes: bool,
65
66    #[command(subcommand)]
67    pub command: Option<Commands>,
68}
69
70#[derive(Subcommand)]
71pub enum Commands {
72    /// Show dashboard of all worktrees (default)
73    #[command(visible_alias = "ls")]
74    List,
75
76    /// Create a new workspace
77    #[command(after_help = "EXAMPLES:
78    git workty new feat/login
79    git workty new hotfix --from main
80    git workty new feature --print-path")]
81    New {
82        /// Branch name for the new workspace
83        name: String,
84
85        /// Base branch or commit to create from
86        #[arg(long, short = 'f')]
87        from: Option<String>,
88
89        /// Custom path for the worktree
90        #[arg(long, short = 'p')]
91        path: Option<PathBuf>,
92
93        /// Print only the created path to stdout
94        #[arg(long)]
95        print_path: bool,
96
97        /// Open the worktree in configured editor
98        #[arg(long, short = 'o')]
99        open: bool,
100    },
101
102    /// Print path to a worktree by name
103    #[command(after_help = "EXAMPLES:
104    cd \"$(git workty go feat/login)\"
105    git workty go main")]
106    Go {
107        /// Worktree name (branch name or directory name)
108        name: String,
109    },
110
111    /// Interactively select a worktree (fuzzy finder)
112    #[command(after_help = "EXAMPLES:
113    cd \"$(git workty pick)\"")]
114    Pick,
115
116    /// Remove a workspace
117    #[command(after_help = "EXAMPLES:
118    git workty rm feat/login
119    git workty rm feat/login --delete-branch
120    git workty rm feat/login --force")]
121    Rm {
122        /// Worktree name to remove
123        name: String,
124
125        /// Remove even if worktree has uncommitted changes
126        #[arg(long, short = 'f')]
127        force: bool,
128
129        /// Also delete the branch after removing worktree
130        #[arg(long, short = 'd')]
131        delete_branch: bool,
132    },
133
134    /// Remove merged or stale worktrees
135    #[command(after_help = "EXAMPLES:
136    git workty clean --merged --dry-run
137    git workty clean --merged --yes")]
138    Clean {
139        /// Only remove worktrees whose branch is merged into base
140        #[arg(long)]
141        merged: bool,
142
143        /// Show what would be removed without removing
144        #[arg(long, short = 'n')]
145        dry_run: bool,
146    },
147
148    /// Print shell integration script
149    #[command(after_help = "EXAMPLES:
150    eval \"$(git workty init zsh)\"
151    git workty init bash >> ~/.bashrc")]
152    Init {
153        /// Shell to generate script for (bash, zsh, fish, powershell)
154        shell: String,
155
156        /// Generate git wrapper that auto-cds
157        #[arg(long)]
158        wrap_git: bool,
159
160        /// Disable cd helpers (completions only)
161        #[arg(long)]
162        no_cd: bool,
163    },
164
165    /// Diagnose common issues
166    Doctor,
167
168    /// Generate shell completions
169    #[command(after_help = "EXAMPLES:
170    git workty completions zsh > _git-workty
171    git workty completions bash > /etc/bash_completion.d/git-workty")]
172    Completions {
173        /// Shell to generate completions for
174        shell: Shell,
175    },
176
177    /// Create a worktree for a GitHub PR (requires gh CLI)
178    #[command(after_help = "EXAMPLES:
179    git workty pr 123
180    cd \"$(git workty pr 123 --print-path)\"")]
181    Pr {
182        /// PR number
183        number: u32,
184
185        /// Print only the created path to stdout
186        #[arg(long)]
187        print_path: bool,
188
189        /// Open the worktree in configured editor
190        #[arg(long, short = 'o')]
191        open: bool,
192    },
193
194    /// Install manpage to ~/.local/share/man/man1
195    InstallMan,
196}
197
198pub fn run_cli() {
199    let cli = Cli::parse();
200
201    let ui_opts = UiOptions {
202        color: !cli.no_color && supports_color(),
203        ascii: cli.ascii,
204        json: cli.json,
205    };
206
207    let result = run(cli, &ui_opts);
208
209    if let Err(e) = result {
210        ui::print_error(&format!("{:#}", e), None);
211        std::process::exit(1);
212    }
213}
214
215fn run(cli: Cli, ui_opts: &UiOptions) -> anyhow::Result<()> {
216    let start_path = cli.directory.as_deref();
217
218    match cli.command {
219        None | Some(Commands::List) => {
220            let repo = GitRepo::discover(start_path)?;
221            list::execute(&repo, ui_opts)
222        }
223
224        Some(Commands::New {
225            name,
226            from,
227            path,
228            print_path,
229            open,
230        }) => {
231            let repo = GitRepo::discover(start_path)?;
232            new::execute(
233                &repo,
234                new::NewOptions {
235                    name,
236                    from,
237                    path,
238                    print_path,
239                    open,
240                },
241            )
242        }
243
244        Some(Commands::Go { name }) => {
245            let repo = GitRepo::discover(start_path)?;
246            go::execute(&repo, &name)
247        }
248
249        Some(Commands::Pick) => {
250            let repo = GitRepo::discover(start_path)?;
251            pick::execute(&repo, ui_opts)
252        }
253
254        Some(Commands::Rm {
255            name,
256            force,
257            delete_branch,
258        }) => {
259            let repo = GitRepo::discover(start_path)?;
260            rm::execute(
261                &repo,
262                rm::RmOptions {
263                    name,
264                    force,
265                    delete_branch,
266                    yes: cli.yes,
267                },
268            )
269        }
270
271        Some(Commands::Clean { merged, dry_run }) => {
272            let repo = GitRepo::discover(start_path)?;
273            clean::execute(
274                &repo,
275                clean::CleanOptions {
276                    merged,
277                    dry_run,
278                    yes: cli.yes,
279                },
280            )
281        }
282
283        Some(Commands::Init {
284            shell,
285            wrap_git,
286            no_cd,
287        }) => {
288            init::execute(init::InitOptions {
289                shell,
290                wrap_git,
291                no_cd,
292            });
293            Ok(())
294        }
295
296        Some(Commands::Doctor) => {
297            doctor::execute(start_path);
298            Ok(())
299        }
300
301        Some(Commands::Completions { shell }) => {
302            completions::execute::<Cli>(shell);
303            Ok(())
304        }
305
306        Some(Commands::Pr {
307            number,
308            print_path,
309            open,
310        }) => {
311            let repo = GitRepo::discover(start_path)?;
312            pr::execute(
313                &repo,
314                pr::PrOptions {
315                    number,
316                    print_path,
317                    open,
318                },
319            )
320        }
321
322        Some(Commands::InstallMan) => install_man::execute(cli.yes),
323    }
324}
325
326fn supports_color() -> bool {
327    use is_terminal::IsTerminal;
328
329    if std::env::var("NO_COLOR").is_ok() {
330        return false;
331    }
332
333    std::io::stdout().is_terminal()
334}