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 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 branch: Option<String>,
76
77 #[arg(long)]
79 stack: bool,
80 },
81
82 #[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: Option<String>,
96 },
97
98 #[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 #[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 message: Option<String>,
121 },
122
123 #[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 #[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 #[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 #[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 #[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: u64,
191
192 #[arg(long)]
194 open: bool,
195
196 #[arg(long = "no-wait")]
198 no_wait: bool,
199
200 #[arg(long = "no-cleanup")]
202 no_cleanup: bool,
203
204 #[arg(long = "ignore-ci-failure")]
207 ignore_ci_failure: bool,
208
209 #[arg(long, default_value_t = 30)]
211 interval: u64,
212 },
213
214 #[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 #[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 #[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 count: usize,
263 },
264
265 #[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 #[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 #[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: Option<String>,
297 },
298
299 #[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 #[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 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}