1use 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 #[arg(short, long, global = true)]
39 pub verbose: bool,
40}
41
42#[derive(Subcommand)]
43pub enum Commands {
44 #[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 #[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 branch: Option<String>,
68 },
69
70 #[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: Option<String>,
84 },
85
86 #[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 #[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 message: Option<String>,
109 },
110
111 #[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 #[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 #[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 #[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 #[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: u64,
172
173 #[arg(long)]
175 open: bool,
176
177 #[arg(long = "no-wait")]
179 no_wait: bool,
180
181 #[arg(long = "no-cleanup")]
183 no_cleanup: bool,
184
185 #[arg(long = "ignore-ci-failure")]
188 ignore_ci_failure: bool,
189
190 #[arg(long, default_value_t = 30)]
192 interval: u64,
193 },
194
195 #[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 #[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 #[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 count: usize,
244 },
245
246 #[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 #[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 #[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: Option<String>,
278 },
279
280 #[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 #[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 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}