Skip to main content

git_workflow/
cli.rs

1//! CLI argument parsing with clap
2
3use clap::{Parser, Subcommand};
4
5#[derive(Parser)]
6#[command(name = "gw")]
7#[command(about = "\
8Worktree-aware git workflow: branch -> PR -> cleanup, safely.
9
10Start here:  gw new feature/login     # the full flow is in --help")]
11#[command(long_about = "\
12Worktree-aware git workflow: branch -> PR -> cleanup, safely.
13
14For developers using a PR-per-branch workflow. gw drives each change from a
15fresh branch through review to a cleaned-up merge, and keeps parallel worktrees
16in sync so day-to-day work never touches `main` directly.
17
18\"home branch\": the branch a worktree returns to (the main worktree's home is
19`main`). `gw home` switches to it and syncs with origin/main.
20
21TYPICAL FLOW:
22  gw new feature/login    # branch off a fresh origin/main
23  # ...edit, then: git commit  ->  git push -u origin <branch>  ->  gh pr create
24  gw await <pr> --open    # wait for CI, open the PR, watch to merge, then clean up
25
26COMMANDS BY SITUATION:
27  Everyday:   new, status, open, sync, cleanup, home
28  Recovery:   pause, abandon, undo
29  Worktrees:  worktree pool   (give parallel agents isolated worktrees)
30
31Lost? Run `gw status` -- it prints the single next command for where you are.")]
32#[command(version)]
33pub struct Cli {
34    #[command(subcommand)]
35    pub command: Commands,
36
37    /// Show verbose output (git commands being run)
38    #[arg(short, long, global = true)]
39    pub verbose: bool,
40}
41
42#[derive(Subcommand)]
43pub enum Commands {
44    /// Switch to home branch and sync with origin/main
45    #[command(long_about = "\
46Switch to your home branch and sync it with origin/main.
47
48\"home branch\" = the branch a worktree returns to (the main worktree's home is
49`main`). Use this instead of `git checkout main`, which conflicts across
50worktrees.
51
52Example:
53  gw home")]
54    Home,
55
56    /// Create new branch from origin/main
57    #[command(long_about = "\
58Create a new branch off a freshly fetched origin/main.
59
60If you already edited on your home branch, gw new carries those changes onto the
61new branch -- nothing is stranded on `main`.
62
63Example:
64  gw new feature/add-login")]
65    New {
66        /// Name of the branch to create (e.g., feature/add-login)
67        branch: Option<String>,
68    },
69
70    /// Delete merged branch and return to home
71    #[command(long_about = "\
72Delete a merged branch and return to your home branch.
73
74Verifies the PR was merged before deleting, then switches home and syncs with
75origin/main. Usually run for you by `gw await` -- reach for it directly only to
76clean up a leftover branch.
77
78Example:
79  gw cleanup                 # the current branch
80  gw cleanup feature/old     # a specific branch")]
81    Cleanup {
82        /// Branch to delete (defaults to current branch if not on home)
83        branch: Option<String>,
84    },
85
86    /// Show current repository state
87    #[command(long_about = "\
88Show the current repository state and the single next command to run.
89
90Inspects your working dir, upstream sync, home branch, and PR state, then prints
91one `Next:` line -- the engine of the gw workflow. When in doubt, run this.
92
93Example:
94  gw status")]
95    Status,
96
97    /// Pause current work: WIP commit and return to home branch
98    #[command(long_about = "\
99Pause current work: record a WIP commit, then return to your home branch.
100
101A safe way to switch tasks mid-change -- the WIP commit travels across worktrees,
102unlike `git stash`. Resume later by checking the branch back out.
103
104Example:
105  gw pause \"investigating flaky test\"")]
106    Pause {
107        /// Optional message describing why work is paused
108        message: Option<String>,
109    },
110
111    /// Abandon current changes and return to home branch
112    #[command(long_about = "\
113Discard all changes on the current branch and return to your home branch.
114
115Destructive: both committed and uncommitted work on this branch is thrown away.
116Use it when the branch is a dead end.
117
118Example:
119  gw abandon")]
120    Abandon,
121
122    /// Undo the last commit (soft reset HEAD~1)
123    #[command(long_about = "\
124Undo the last commit, keeping its changes as unstaged edits (soft reset HEAD~1).
125
126Lets you re-stage or rewrite the most recent commit. Touches history only -- your
127working files are left intact.
128
129Example:
130  gw undo")]
131    Undo,
132
133    /// Sync current branch after base PR is merged (update base to main, rebase, force push)
134    #[command(long_about = "\
135Restack this branch after the PR it was based on merged.
136
137Updates the PR's base to main on GitHub, rebases onto the latest main, and
138force-pushes. Use this instead of hand-rebasing stacked PRs.
139
140Rewrites history and force-pushes the branch.
141
142Example:
143  gw sync")]
144    Sync,
145
146    /// Open the PR for the current branch in the browser
147    #[command(long_about = "\
148Open the current branch's PR in your browser.
149
150The browser command is configured via GW_OPEN_URL_CMD in your dotfiles.
151
152Example:
153  gw open")]
154    Open,
155
156    /// Watch a specific PR until merged or closed, then clean up its branch
157    #[command(long_about = "\
158Watch a specific PR until it merges or closes, then clean up its branch.
159
160Takes a PR number (not a branch) so the watcher stays bound to that PR even if
161you switch branches. It waits for CI, then -- with --open -- opens the PR,
162watches it to merge, and runs `gw cleanup`. If CI fails it stops and reports, so
163you can fix, push, and rerun. Launch it in the background right after creating
164the PR.
165
166Example:
167  gw await 41 --open")]
168    Await {
169        /// PR number to watch (required so the watcher stays bound to one PR
170        /// even if you switch branches, e.g. while working a stacked PR)
171        pr: u64,
172
173        /// Also open the PR in the browser before watching
174        #[arg(long)]
175        open: bool,
176
177        /// Skip waiting for CI checks
178        #[arg(long = "no-wait")]
179        no_wait: bool,
180
181        /// Do not clean up the branch after the PR is merged
182        #[arg(long = "no-cleanup")]
183        no_cleanup: bool,
184
185        /// Continue watching for merge even if CI checks fail
186        /// (default: stop and report the failure)
187        #[arg(long = "ignore-ci-failure")]
188        ignore_ci_failure: bool,
189
190        /// Seconds between merge-status polls
191        #[arg(long, default_value_t = 30)]
192        interval: u64,
193    },
194
195    /// Manage worktrees
196    #[command(long_about = "\
197Manage git worktrees for running parallel work in isolation.
198
199Subcommands live under `gw worktree pool` -- a pre-warmed set of ready-to-use
200worktrees so parallel agents each get their own checkout.
201
202Example:
203  gw worktree pool warm 3")]
204    Worktree {
205        #[command(subcommand)]
206        command: WorktreeCommands,
207    },
208}
209
210#[derive(Subcommand)]
211pub enum WorktreeCommands {
212    /// Manage a pre-warmed worktree pool
213    #[command(long_about = "\
214Manage a pre-warmed pool of worktrees for parallel, isolated work.
215
216Warm the pool once, then acquire a worktree per task and release it when done so
217the next task can reuse it. Always release, even on error -- a forgotten release
218drains the pool.
219
220Example:
221  gw worktree pool warm 3                 # pre-create 3 worktrees
222  gw worktree pool acquire                # take one (prints its path)
223  gw worktree pool release <name>         # return it when done
224  gw worktree pool drain                  # remove them all")]
225    Pool {
226        #[command(subcommand)]
227        command: PoolCommands,
228    },
229}
230
231#[derive(Subcommand)]
232pub enum PoolCommands {
233    /// Pre-warm the pool with ready-to-use worktrees
234    #[command(long_about = "\
235Pre-create worktrees so the pool has `count` ready to acquire.
236
237Run once before fanning out parallel work.
238
239Example:
240  gw worktree pool warm 3")]
241    Warm {
242        /// Target number of available worktrees in the pool
243        count: usize,
244    },
245
246    /// Acquire a worktree from the pool (prints path to stdout)
247    #[command(long_about = "\
248Take a worktree from the pool and print its path to stdout.
249
250Capture the path and run the task inside it; release it when done. Acquire fails
251if the pool is empty -- `gw worktree pool warm <n>` first.
252
253Example:
254  WORKTREE_PATH=$(gw worktree pool acquire)")]
255    Acquire,
256
257    /// Show pool status
258    #[command(long_about = "\
259Show how many pool worktrees exist and how many are available to acquire.
260
261Example:
262  gw worktree pool status")]
263    Status,
264
265    /// Release acquired worktree(s) back to the pool
266    #[command(long_about = "\
267Return an acquired worktree to the pool so it can be reused.
268
269With no name, releases all worktrees acquired by this process. Always release,
270even on error -- a forgotten release drains the pool.
271
272Example:
273  gw worktree pool release          # all acquired
274  gw worktree pool release wt-2     # a specific one")]
275    Release {
276        /// Name of the worktree to release (defaults to all acquired)
277        name: Option<String>,
278    },
279
280    /// Remove all worktrees and clean up the pool
281    #[command(long_about = "\
282Remove every pool worktree and tear the pool down.
283
284Refuses to drain while worktrees are still acquired unless you pass --force.
285
286Example:
287  gw worktree pool drain
288  gw worktree pool drain --force")]
289    Drain {
290        /// Force drain even if worktrees are acquired
291        #[arg(long)]
292        force: bool,
293    },
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn await_requires_pr_number() {
302        // Without a PR number, parsing must fail.
303        assert!(Cli::try_parse_from(["gw", "await"]).is_err());
304    }
305
306    #[test]
307    fn await_defaults() {
308        let cli = Cli::try_parse_from(["gw", "await", "42"]).unwrap();
309        match cli.command {
310            Commands::Await {
311                pr,
312                open,
313                no_wait,
314                no_cleanup,
315                ignore_ci_failure,
316                interval,
317            } => {
318                assert_eq!(pr, 42);
319                assert!(!open);
320                assert!(!no_wait);
321                assert!(!no_cleanup);
322                assert!(!ignore_ci_failure);
323                assert_eq!(interval, 30);
324            }
325            _ => panic!("expected Await command"),
326        }
327    }
328
329    #[test]
330    fn await_flags() {
331        let cli = Cli::try_parse_from([
332            "gw",
333            "await",
334            "42",
335            "--open",
336            "--no-wait",
337            "--no-cleanup",
338            "--ignore-ci-failure",
339            "--interval",
340            "5",
341        ])
342        .unwrap();
343        match cli.command {
344            Commands::Await {
345                pr,
346                open,
347                no_wait,
348                no_cleanup,
349                ignore_ci_failure,
350                interval,
351            } => {
352                assert_eq!(pr, 42);
353                assert!(open);
354                assert!(no_wait);
355                assert!(no_cleanup);
356                assert!(ignore_ci_failure);
357                assert_eq!(interval, 5);
358            }
359            _ => panic!("expected Await command"),
360        }
361    }
362}