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 --no-fetch --no-push")]
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        /// Skip fetching from remote before creating
108        #[arg(long)]
109        no_fetch: bool,
110
111        /// Skip pushing to set upstream after creating
112        #[arg(long)]
113        no_push: bool,
114    },
115
116    /// Print path to a worktree by name
117    #[command(after_help = "EXAMPLES:
118    cd \"$(git workty go feat/login)\"
119    git workty go main")]
120    Go {
121        /// Worktree name (branch name or directory name)
122        name: String,
123    },
124
125    /// Interactively select a worktree (fuzzy finder)
126    #[command(after_help = "EXAMPLES:
127    cd \"$(git workty pick)\"")]
128    Pick,
129
130    /// Remove a workspace
131    #[command(after_help = "EXAMPLES:
132    git workty rm feat/login
133    git workty rm feat/login --delete-branch
134    git workty rm feat/login --force")]
135    Rm {
136        /// Worktree name to remove
137        name: String,
138
139        /// Remove even if worktree has uncommitted changes
140        #[arg(long, short = 'f')]
141        force: bool,
142
143        /// Also delete the branch after removing worktree
144        #[arg(long, short = 'd')]
145        delete_branch: bool,
146    },
147
148    /// Remove merged or stale worktrees
149    #[command(after_help = "EXAMPLES:
150    git workty clean --merged --dry-run
151    git workty clean --gone --yes
152    git workty clean --stale 30")]
153    Clean {
154        /// Remove worktrees whose branch is merged into base
155        #[arg(long)]
156        merged: bool,
157
158        /// Remove worktrees whose upstream branch was deleted
159        #[arg(long)]
160        gone: bool,
161
162        /// Remove worktrees not touched in N days
163        #[arg(long, value_name = "DAYS")]
164        stale: Option<u32>,
165
166        /// Show what would be removed without removing
167        #[arg(long, short = 'n')]
168        dry_run: bool,
169    },
170
171    /// Print shell integration script
172    #[command(after_help = "EXAMPLES:
173    eval \"$(git workty init zsh)\"
174    git workty init bash >> ~/.bashrc")]
175    Init {
176        /// Shell to generate script for (bash, zsh, fish, powershell)
177        shell: String,
178
179        /// Generate git wrapper that auto-cds
180        #[arg(long)]
181        wrap_git: bool,
182
183        /// Disable cd helpers (completions only)
184        #[arg(long)]
185        no_cd: bool,
186    },
187
188    /// Diagnose common issues
189    Doctor,
190
191    /// Generate shell completions
192    #[command(after_help = "EXAMPLES:
193    git workty completions zsh > _git-workty
194    git workty completions bash > /etc/bash_completion.d/git-workty")]
195    Completions {
196        /// Shell to generate completions for
197        shell: Shell,
198    },
199
200    /// Create a worktree for a GitHub PR (requires gh CLI)
201    #[command(after_help = "EXAMPLES:
202    git workty pr 123
203    cd \"$(git workty pr 123 --print-path)\"")]
204    Pr {
205        /// PR number
206        number: u32,
207
208        /// Print only the created path to stdout
209        #[arg(long)]
210        print_path: bool,
211
212        /// Open the worktree in configured editor
213        #[arg(long, short = 'o')]
214        open: bool,
215    },
216
217    /// Fetch from remotes (updates tracking info for all worktrees)
218    #[command(after_help = "EXAMPLES:
219    git workty fetch
220    git workty fetch --all")]
221    Fetch {
222        /// Fetch from all remotes, not just origin
223        #[arg(long, short = 'a')]
224        all: bool,
225    },
226
227    /// Rebase all clean worktrees onto their upstream
228    #[command(after_help = "EXAMPLES:
229    git workty sync --dry-run
230    git workty sync --fetch")]
231    Sync {
232        /// Show what would be done without doing it
233        #[arg(long, short = 'n')]
234        dry_run: bool,
235
236        /// Fetch from origin before syncing
237        #[arg(long, short = 'f')]
238        fetch: bool,
239    },
240
241    /// Install manpage to ~/.local/share/man/man1
242    InstallMan,
243}
244
245pub fn run_cli() {
246    let cli = Cli::parse();
247
248    let ui_opts = UiOptions {
249        color: !cli.no_color && supports_color(),
250        ascii: cli.ascii,
251        json: cli.json,
252    };
253
254    let result = run(cli, &ui_opts);
255
256    if let Err(e) = result {
257        ui::print_error(&format!("{:#}", e), None);
258        std::process::exit(1);
259    }
260}
261
262fn run(cli: Cli, ui_opts: &UiOptions) -> anyhow::Result<()> {
263    let start_path = cli.directory.as_deref();
264
265    match cli.command {
266        None => {
267            let repo = GitRepo::discover(start_path)?;
268            list::execute(&repo, ui_opts, false)
269        }
270
271        Some(Commands::List { fast }) => {
272            let repo = GitRepo::discover(start_path)?;
273            list::execute(&repo, ui_opts, fast)
274        }
275
276        Some(Commands::New {
277            name,
278            from,
279            path,
280            print_path,
281            open,
282            no_fetch,
283            no_push,
284        }) => {
285            let repo = GitRepo::discover(start_path)?;
286            new::execute(
287                &repo,
288                new::NewOptions {
289                    name,
290                    from,
291                    path,
292                    print_path,
293                    open,
294                    no_fetch,
295                    no_push,
296                },
297            )
298        }
299
300        Some(Commands::Go { name }) => {
301            let repo = GitRepo::discover(start_path)?;
302            go::execute(&repo, &name)
303        }
304
305        Some(Commands::Pick) => {
306            let repo = GitRepo::discover(start_path)?;
307            pick::execute(&repo, ui_opts)
308        }
309
310        Some(Commands::Rm {
311            name,
312            force,
313            delete_branch,
314        }) => {
315            let repo = GitRepo::discover(start_path)?;
316            rm::execute(
317                &repo,
318                rm::RmOptions {
319                    name,
320                    force,
321                    delete_branch,
322                    yes: cli.yes,
323                },
324            )
325        }
326
327        Some(Commands::Clean {
328            merged,
329            gone,
330            stale,
331            dry_run,
332        }) => {
333            let repo = GitRepo::discover(start_path)?;
334            clean::execute(
335                &repo,
336                clean::CleanOptions {
337                    merged,
338                    gone,
339                    stale_days: stale,
340                    dry_run,
341                    yes: cli.yes,
342                },
343            )
344        }
345
346        Some(Commands::Init {
347            shell,
348            wrap_git,
349            no_cd,
350        }) => {
351            init::execute(init::InitOptions {
352                shell,
353                wrap_git,
354                no_cd,
355            });
356            Ok(())
357        }
358
359        Some(Commands::Doctor) => {
360            doctor::execute(start_path);
361            Ok(())
362        }
363
364        Some(Commands::Completions { shell }) => {
365            completions::execute::<Cli>(shell);
366            Ok(())
367        }
368
369        Some(Commands::Pr {
370            number,
371            print_path,
372            open,
373        }) => {
374            let repo = GitRepo::discover(start_path)?;
375            pr::execute(
376                &repo,
377                pr::PrOptions {
378                    number,
379                    print_path,
380                    open,
381                },
382            )
383        }
384
385        Some(Commands::Fetch { all }) => {
386            let repo = GitRepo::discover(start_path)?;
387            fetch::execute(&repo, all)
388        }
389
390        Some(Commands::Sync { dry_run, fetch }) => {
391            let repo = GitRepo::discover(start_path)?;
392            sync::execute(&repo, sync::SyncOptions { dry_run, fetch })
393        }
394
395        Some(Commands::InstallMan) => install_man::execute(cli.yes),
396    }
397}
398
399fn supports_color() -> bool {
400    use is_terminal::IsTerminal;
401
402    if std::env::var("NO_COLOR").is_ok() {
403        return false;
404    }
405
406    std::io::stdout().is_terminal()
407}