Skip to main content

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