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