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 (or the current branch with --stack)
57    #[command(long_about = "\
58Create a new branch with an unambiguous base.
59
60From your home branch, gw new branches off a freshly fetched origin/main. If you
61already edited there, those changes are carried onto the new branch -- nothing is
62stranded on `main`. (Dirty changes are based on the current HEAD, so creating the
63branch never hits a merge conflict; if local main lagged, gw points you at a
64rebase afterwards.)
65
66From a feature branch, the base is ambiguous, so gw new refuses unless you say
67which you mean: --stack bases on the CURRENT branch (for stacked PRs, with a
68`gh pr create -B <parent>` hint), or run `gw home` first to start fresh from main.
69
70Examples:
71  gw new feature/add-login           # from home: branch off a fresh origin/main
72  gw new feature/child --stack       # from a feature branch: stack on top of it")]
73    New {
74        /// Name of the branch to create (e.g., feature/add-login)
75        branch: Option<String>,
76
77        /// Base the new branch on the current branch instead of origin/main (stacked PRs)
78        #[arg(long)]
79        stack: bool,
80    },
81
82    /// Delete merged branch and return to home
83    #[command(long_about = "\
84Delete a merged branch and return to your home branch.
85
86Verifies the PR was merged before deleting, then switches home and syncs with
87origin/main. Usually run for you by `gw await` -- reach for it directly only to
88clean up a leftover branch.
89
90Example:
91  gw cleanup                 # the current branch
92  gw cleanup feature/old     # a specific branch")]
93    Cleanup {
94        /// Branch to delete (defaults to current branch if not on home)
95        branch: Option<String>,
96    },
97
98    /// Show current repository state
99    #[command(long_about = "\
100Show the current repository state and the single next command to run.
101
102Inspects your working dir, upstream sync, home branch, and PR state, then prints
103one `Next:` line -- the engine of the gw workflow. When in doubt, run this.
104
105Example:
106  gw status")]
107    Status,
108
109    /// Pause current work: WIP commit and return to home branch
110    #[command(long_about = "\
111Pause current work: record a WIP commit, then return to your home branch.
112
113A safe way to switch tasks mid-change -- the WIP commit travels across worktrees,
114unlike `git stash`. Resume later by checking the branch back out.
115
116Example:
117  gw pause \"investigating flaky test\"")]
118    Pause {
119        /// Optional message describing why work is paused
120        message: Option<String>,
121    },
122
123    /// Abandon current changes and return to home branch
124    #[command(long_about = "\
125Discard all changes on the current branch and return to your home branch.
126
127Destructive: both committed and uncommitted work on this branch is thrown away.
128Use it when the branch is a dead end.
129
130Example:
131  gw abandon")]
132    Abandon,
133
134    /// Undo the last commit (soft reset HEAD~1)
135    #[command(long_about = "\
136Undo the last commit, keeping its changes staged (soft reset HEAD~1).
137
138Lets you amend what went into the most recent commit or rewrite its message.
139Touches history only -- your working files are left intact.
140
141Example:
142  gw undo")]
143    Undo,
144
145    /// Bring the current branch up to date with its base (rebase, force-push)
146    #[command(long_about = "\
147Bring this branch up to date with whatever it sits on.
148
149  home branch                 fast-forward pull from origin/main
150  branch targeting main       rebase onto the latest origin/main
151  stacked, base PR open       rebase onto the latest origin/<base>
152  stacked, base PR merged     restack: replay only this branch's commits onto
153                              main (rebase --onto), move the PR base to main
154
155After rebasing, a published branch is force-pushed (--force-with-lease). Use
156this instead of hand-rebasing -- especially for stacked PRs, where a plain
157`git rebase` would re-apply the merged base's commits.
158
159Rewrites history and force-pushes the branch.
160
161Example:
162  gw sync")]
163    Sync,
164
165    /// Open the PR for the current branch in the browser
166    #[command(long_about = "\
167Open the current branch's PR in your browser.
168
169The browser command is configured via GW_OPEN_URL_CMD in your dotfiles.
170
171Example:
172  gw open")]
173    Open,
174
175    /// Watch a specific PR until merged or closed, then clean up its branch
176    #[command(long_about = "\
177Watch a specific PR until it merges or closes, then clean up its branch.
178
179Takes a PR number (not a branch) so the watcher stays bound to that PR even if
180you switch branches. It waits for CI, then -- with --open -- opens the PR,
181watches it to merge, and runs `gw cleanup`. If CI fails it stops and reports, so
182you can fix, push, and rerun. Launch it in the background right after creating
183the PR.
184
185Example:
186  gw await 41 --open")]
187    Await {
188        /// PR number to watch (required so the watcher stays bound to one PR
189        /// even if you switch branches, e.g. while working a stacked PR)
190        pr: u64,
191
192        /// Also open the PR in the browser before watching
193        #[arg(long)]
194        open: bool,
195
196        /// Skip waiting for CI checks
197        #[arg(long = "no-wait")]
198        no_wait: bool,
199
200        /// Do not clean up the branch after the PR is merged
201        #[arg(long = "no-cleanup")]
202        no_cleanup: bool,
203
204        /// Continue watching for merge even if CI checks fail
205        /// (default: stop and report the failure)
206        #[arg(long = "ignore-ci-failure")]
207        ignore_ci_failure: bool,
208
209        /// Seconds between merge-status polls
210        #[arg(long, default_value_t = 30)]
211        interval: u64,
212    },
213
214    /// Manage worktrees
215    #[command(long_about = "\
216Manage git worktrees for running parallel work in isolation.
217
218Subcommands live under `gw worktree pool` -- a pre-warmed set of ready-to-use
219worktrees so parallel agents each get their own checkout.
220
221Example:
222  gw worktree pool warm 3")]
223    Worktree {
224        #[command(subcommand)]
225        command: WorktreeCommands,
226    },
227}
228
229#[derive(Subcommand)]
230pub enum WorktreeCommands {
231    /// Manage a pre-warmed worktree pool
232    #[command(long_about = "\
233Manage a pre-warmed pool of worktrees for parallel, isolated work.
234
235Warm the pool once, then acquire a worktree per task and release it when done so
236the next task can reuse it. Always release, even on error -- a forgotten release
237drains the pool.
238
239Example:
240  gw worktree pool warm 3                 # pre-create 3 worktrees
241  gw worktree pool acquire                # take one (prints its path)
242  gw worktree pool release <name>         # return it when done
243  gw worktree pool drain                  # remove them all")]
244    Pool {
245        #[command(subcommand)]
246        command: PoolCommands,
247    },
248}
249
250#[derive(Subcommand)]
251pub enum PoolCommands {
252    /// Pre-warm the pool with ready-to-use worktrees
253    #[command(long_about = "\
254Pre-create worktrees so the pool has `count` ready to acquire.
255
256Run once before fanning out parallel work.
257
258Example:
259  gw worktree pool warm 3")]
260    Warm {
261        /// Target number of available worktrees in the pool
262        count: usize,
263    },
264
265    /// Acquire a worktree from the pool (prints path to stdout)
266    #[command(long_about = "\
267Take a worktree from the pool and print its path to stdout.
268
269Capture the path and run the task inside it; release it when done. Acquire fails
270if the pool is empty -- `gw worktree pool warm <n>` first.
271
272Example:
273  WORKTREE_PATH=$(gw worktree pool acquire)")]
274    Acquire,
275
276    /// Show pool status
277    #[command(long_about = "\
278Show how many pool worktrees exist and how many are available to acquire.
279
280Example:
281  gw worktree pool status")]
282    Status,
283
284    /// Release acquired worktree(s) back to the pool
285    #[command(long_about = "\
286Return an acquired worktree to the pool so it can be reused.
287
288With no name, releases all worktrees acquired by this process. Always release,
289even on error -- a forgotten release drains the pool.
290
291Example:
292  gw worktree pool release          # all acquired
293  gw worktree pool release wt-2     # a specific one")]
294    Release {
295        /// Name of the worktree to release (defaults to all acquired)
296        name: Option<String>,
297    },
298
299    /// Remove all worktrees and clean up the pool
300    #[command(long_about = "\
301Remove every pool worktree and tear the pool down.
302
303Refuses to drain while worktrees are still acquired unless you pass --force.
304
305Example:
306  gw worktree pool drain
307  gw worktree pool drain --force")]
308    Drain {
309        /// Force drain even if worktrees are acquired
310        #[arg(long)]
311        force: bool,
312    },
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn await_requires_pr_number() {
321        // Without a PR number, parsing must fail.
322        assert!(Cli::try_parse_from(["gw", "await"]).is_err());
323    }
324
325    #[test]
326    fn await_defaults() {
327        let cli = Cli::try_parse_from(["gw", "await", "42"]).unwrap();
328        match cli.command {
329            Commands::Await {
330                pr,
331                open,
332                no_wait,
333                no_cleanup,
334                ignore_ci_failure,
335                interval,
336            } => {
337                assert_eq!(pr, 42);
338                assert!(!open);
339                assert!(!no_wait);
340                assert!(!no_cleanup);
341                assert!(!ignore_ci_failure);
342                assert_eq!(interval, 30);
343            }
344            _ => panic!("expected Await command"),
345        }
346    }
347
348    #[test]
349    fn await_flags() {
350        let cli = Cli::try_parse_from([
351            "gw",
352            "await",
353            "42",
354            "--open",
355            "--no-wait",
356            "--no-cleanup",
357            "--ignore-ci-failure",
358            "--interval",
359            "5",
360        ])
361        .unwrap();
362        match cli.command {
363            Commands::Await {
364                pr,
365                open,
366                no_wait,
367                no_cleanup,
368                ignore_ci_failure,
369                interval,
370            } => {
371                assert_eq!(pr, 42);
372                assert!(open);
373                assert!(no_wait);
374                assert!(no_cleanup);
375                assert!(ignore_ci_failure);
376                assert_eq!(interval, 5);
377            }
378            _ => panic!("expected Await command"),
379        }
380    }
381}