1mod commands;
4pub mod engine;
5mod gh;
6mod git;
7mod ident;
8mod meta;
9mod queue;
10mod render;
11mod requeue;
12mod view;
13
14use clap::{CommandFactory, Parser, Subcommand};
15use std::path::PathBuf;
16
17#[derive(Parser)]
18#[command(
19 name = "git-queue",
20 bin_name = "git queue",
21 version,
22 about = "Manage queues of dependent branches and their numbered pull requests",
23 long_about = "A PR queue is an ordered series of dependent branches: each branch builds on \
24 the one before it, and the PRs merge in FIFO order — front of the queue \
25 first. git-queue tracks that order, keeps the queue rebased on its base \
26 branch, and opens numbered, cross-linked pull requests — one per branch. \
27 Run `git queue setup` after installing to add man pages, shell completion, \
28 and an optional `git q` alias."
29)]
30struct Cli {
31 #[command(subcommand)]
32 command: Command,
33}
34
35#[derive(Subcommand)]
36enum Command {
37 #[command(
39 long_about = "Creates the next branch of the queue at the current branch's tip (or at `--base <branch>`'s tip). In a namespaced queue — one with an explicit name, or whose branches already live under queue/<name>/… — you give the short name and the branch is created as queue/<name>/<short>, exactly like edit's sections; names containing `/` are used as-is, and plain-named queues stay plain. Extending a queue inherits its name; starting a new one asks (or takes `--queue`). The front PR targets the base branch, which is how queues can be built on release branches. Branch arguments elsewhere (`--base`, `track --parent`, `checkout`) accept the same short names."
40 )]
41 Create {
42 name: String,
44 #[arg(long)]
46 base: Option<String>,
47 #[arg(long)]
49 queue: Option<String>,
50 },
51 #[command(
53 long_about = "Read-only view of the current line: one row per branch, front of the queue at the bottom, with PR number and state, a left-margin marker on the checked-out branch, and — when a branch holds persisted conflict markers — a warning listing the conflicting files. Markers are detected live from each tip, so the warning can never be stale. Output is colourised on a terminal (set NO_COLOR to disable), and PR numbers are clickable links in terminals that render hyperlinks."
54 )]
55 Status,
56 #[command(visible_alias = "list")]
58 #[command(
59 long_about = "Lists every queue in the repository, most recently touched first — each with its branch roster, the first line of its description, and a marker for the queue you're on. Activity timestamps update whenever a queue operation runs, so the top entry is what you worked on last. Unnamed queues are listed with instructions to name them."
60 )]
61 Ls,
62 #[command(
64 long_about = "Shows the current queue's name, or (re)names it. Every queue is named: the name appears in each PR's header, namespaces the queue's branches (`queue/<name>/<branch>`), and keys the queue-level description and activity time. Naming records membership on every branch of the line, so branches that don't follow the `queue/<name>/…` convention still resolve."
65 )]
66 Name {
67 name: Option<String>,
69 },
70 #[command(
72 long_about = "`status` with one more level of depth: each branch's commits are listed beneath it, newest first, prefixed with the abbreviated Stable-Commit-Id (`(no id)` for unstamped commits). Those abbreviations are accepted by every command that takes a commit, so `log` is the natural way to find the argument for `move`, `checkout` or `reword`."
73 )]
74 Log,
75 #[command(
77 long_about = "Detaches HEAD on a commit of the current queue — named by SHA or Stable-Commit-Id — after validating membership and a clean stage. From there, edit and `git add`, then: plain `git commit` INSERTS a new commit right after it, while `git commit --amend` REVISES it (the message carries over, so its Stable-Commit-Id is preserved). Either way everything that followed rebases onto the new commit, automatically with hooks installed or via `git queue requeue`. HEAD stays detached at the edited commit so you can keep going; `git queue checkout <branch>` reattaches and ends the session."
78 )]
79 Checkout {
80 commit: String,
82 },
83 #[command(visible_alias = "split")]
85 #[command(
86 long_about = "Opens the whole queue in an editor: every branch is a `[name]` section header, with the commits belonging to it listed beneath (the first section is the front of the queue — it merges first). The commit sequence is fixed — commits cannot be reordered or deleted, only assigned to branches — so editing means moving, renaming, adding, or removing the `[name]` header lines: add a header to split a branch in two, remove one to dissolve a branch into its neighbours, move one to shift commits between adjacent branches. Branch refs simply move to the new section boundaries; no commit is rewritten. Removed branches are deleted (their commits are covered by the remaining branches). Short header names resolve within the queue and new ones are created as `queue/<name>/<short>` in namespaced queues. Also works on an untracked branch: it becomes one section over trunk, ready to divide."
87 )]
88 Edit {
89 #[arg(long)]
91 queue: Option<String>,
92 },
93 #[command(
95 long_about = "Opens the current queue line in an interactive, keyboard-driven terminal editor: reorder, squash, split, reword and delete commits, and edit branch boundaries, with per-operation undo/redo. Unlike `edit` — which is ref-only, scriptable, and never rewrites a commit — `tui` rewrites history immediately, so the panes always show true, materialised state. It requires an interactive terminal (errors and points you to `edit` otherwise), a clean worktree, and a non-empty queue, and refuses a forked line (rewriting shared history is out of scope)."
96 )]
97 Tui,
98 #[command(
100 long_about = "Sets the QUEUE's description — the \"About this queue\" section rendered into every PR of the queue on the next submit/sync. Use it for the narrative that spans the whole queue: what the series achieves and how the pieces fit. Opens `$EDITOR` without `-m`; an empty message clears it."
101 )]
102 Describe {
103 #[arg(short = 'm', long)]
105 message: Option<String>,
106 },
107 #[command(name = "describe-branch")]
109 #[command(
110 long_about = "Sets the current BRANCH's description — the \"About this branch\" section of its PR. Use it for what this one slice does. A hand-written body on a PR adopted from before the queue existed is imported here automatically rather than overwritten. Opens `$EDITOR` without `-m`; an empty message clears it."
111 )]
112 DescribeBranch {
113 #[arg(short = 'm', long)]
115 message: Option<String>,
116 },
117 #[command(
119 long_about = "Adopts an existing branch into a queue: records its parent (trunk by default, or `--parent`), asks for a queue name when starting a new one, and offers to stamp Stable-Commit-Ids onto the adopted commits — asking first because stamping rewrites them (hashes change; an already-pushed branch will be force-pushed with lease on the next sync). `--stamp-ids`/`--no-stamp-ids` decide non-interactively, and `--edit` continues straight into the queue editor to divide the adopted commits into multiple branches."
120 )]
121 Track {
122 #[arg(long)]
124 parent: Option<String>,
125 #[arg(long, conflicts_with = "no_stamp_ids")]
127 stamp_ids: bool,
128 #[arg(long)]
130 no_stamp_ids: bool,
131 #[arg(long, alias = "split")]
134 edit: bool,
135 #[arg(long)]
137 queue: Option<String>,
138 },
139 #[command(
141 long_about = "Forgets the current branch's queue metadata (parent, anchor, cached PR number, descriptions, membership). The branch and its commits are untouched — this only removes it from the queue's structure."
142 )]
143 Untrack,
144 #[command(visible_alias = "up")]
146 #[command(
147 long_about = "Checks out the child branch — one step toward the back of the queue. Errors helpfully at the top or at a fork (listing the children so you can pick one)."
148 )]
149 Next,
150 #[command(visible_alias = "down")]
152 #[command(
153 long_about = "Checks out the parent branch — one step toward the front of the queue."
154 )]
155 Prev,
156 #[command(
158 long_about = "Commits like `git commit`, then requeues every descendant branch onto the new tip in one atomic pass (engine: `git replay`), so the branches behind yours never go stale. Stamps a Stable-Commit-Id if the commit-msg hook didn't. With hooks installed, plain `git commit` behaves the same — this command exists for hookless repositories and for scripting."
159 )]
160 Commit {
161 #[arg(short = 'm', long)]
163 message: Option<String>,
164 },
165 #[command(
167 long_about = "Folds the STAGED changes into the current branch's tip commit and updates every descendant, atomically (engine: `git history fixup`): if propagating would conflict with a descendant, it aborts and changes nothing — it cannot leave conflict markers. This is the everyday tool for addressing review feedback on a PR that has others queued behind it. The commit message, and with it the Stable-Commit-Id, is preserved."
168 )]
169 Amend,
170 #[command(
172 long_about = "Rewrites a commit's message — HEAD by default, or any queue commit named by SHA or Stable-Commit-Id — and updates every descendant (engine: `git history reword`; atomic, aborts cleanly on conflict). Content is untouched."
173 )]
174 Reword {
175 commit: Option<String>,
177 },
178 #[command(
180 long_about = "Relocates a commit, or an inclusive `<first>..<last>` range of consecutive commits, to directly follow `--new-parent` — reordering within one PR or moving work to a different PR (the commits join the branch segment their new parent belongs to). All arguments accept SHAs or Stable-Commit-Ids. The whole line is rewritten in one `git rebase --update-refs` pass: branch refs ride along, conflicts are persisted as markers and flagged, and passing the base branch's tip as `--new-parent` moves commits to the very front of the queue."
181 )]
182 Move {
183 commit: String,
187 #[arg(long)]
191 new_parent: String,
192 },
193 #[command(visible_alias = "restack")]
195 #[command(
196 long_about = "The repair primitive: makes every descendant of the current branch consistent with its tip — nothing else. No network, no PR edits, no pruning. It is what the hooks run after a plain `git commit`/`--amend` (`--auto` stays silent when there is nothing to do), and the only command that reintegrates a `git queue checkout` editing session when hooks aren't installed. Reach for it manually after hand-made history surgery (cherry-picks, resets) leaves the branches above you stale."
197 )]
198 Requeue {
199 #[arg(long)]
201 auto: bool,
202 },
203 #[command(
205 long_about = "The converge-with-reality command. Fetches with `--prune`, fast-forwards each queue's base branch, stamps Stable-Commit-Ids onto any queue commits missing them, drops branches whose work has landed (merged PRs, and squash-merges detected by Stable-Commit-Id), pulls genuinely new teammate commits (id correspondence guarantees your own rewrites are never re-applied over themselves), requeues the whole forest onto its bases, pushes everything back with `--force-with-lease`, and reconciles the PRs of every published queue — opening missing ones, reviving closed ones, refreshing bases, titles and queue maps. `--no-push` stops after the local requeue. It never pushes a branch that would make GitHub mislabel an open child PR as merged."
206 )]
207 Sync {
208 #[arg(long)]
210 no_push: bool,
211 },
212 #[command(visible_alias = "push")]
214 #[command(
215 long_about = "Publishes the current line: pushes every branch (front first, so bases exist), opens or revives its numbered PRs, and rewrites each PR's base, `[k/n]` title and body (queue map + About sections). Adopted PRs keep their hand-written titles (renumbered) and bodies. With the status gate enabled it also posts the red/green merge-order statuses. `--draft` opens new PRs as drafts."
216 )]
217 Submit {
218 #[arg(long)]
220 draft: bool,
221 },
222 #[command(
224 long_about = "Closes every open PR of the current queue without merging — for abandoning or restarting a published queue. Merged PRs, local branches and metadata are all left untouched."
225 )]
226 Yank,
227 #[command(
229 long_about = "Walks through git-queue's optional integrations, asking permission for each step: the git hooks (auto-requeue after plain commits, Stable-Commit-Id stamping), the advisory merge-order gate (red/green commit status per PR), the man page (`man git-queue`), shell completion for your shell, a `git q` alias in your global git config, the Claude Code skill (when Claude Code is detected), and a git-queue section in AGENTS.md (when other agent CLIs are detected — the cross-agent convention read by Codex, Cursor, Copilot and others). --yes accepts the two repo-local steps (hooks, gate) non-interactively; the user-scoped steps (man page, completion, alias, skill) are interactive only. --undo reverses everything."
230 )]
231 Setup {
232 #[arg(long)]
234 yes: bool,
235 #[arg(long)]
237 undo: bool,
238 },
239 #[command(
241 long_about = "Read-only diagnosis of merge-order signalling: whether the status gate is enabled, and whether the GitHub CLI is ready. Changes nothing."
242 )]
243 Doctor,
244 #[command(hide = true, name = "stamp-todo")]
246 StampTodo {
247 file: PathBuf,
249 },
250 #[command(hide = true, name = "add-queue-id")]
252 AddQueueId {
253 file: PathBuf,
255 },
256 #[command(hide = true, name = "reorder-todo")]
258 ReorderTodo {
259 file: PathBuf,
261 },
262 #[command(
264 long_about = "Prints a completion script for the given shell (bash, zsh, fish, elvish, powershell) to stdout. `git queue setup` installs this for you interactively; use this to install it by hand, e.g. `git queue completions zsh > ~/.local/share/zsh/site-functions/_git-queue`."
265 )]
266 Completions {
267 shell: clap_complete::Shell,
269 },
270 #[command(hide = true)]
272 Man {
273 #[arg(long)]
275 dir: Option<PathBuf>,
276 },
277}
278
279pub(crate) fn cli_command() -> clap::Command {
282 Cli::command()
283}
284
285pub(crate) fn man_text() -> anyhow::Result<String> {
291 let cmd = Cli::command();
292 let man = clap_mangen::Man::new(cmd.clone());
293 let mut buffer: Vec<u8> = Vec::new();
294 man.render(&mut buffer)?;
295 let text = String::from_utf8(buffer)?;
296
297 Ok(match text.find(".SH SUBCOMMANDS") {
300 Some(start) => {
301 let rest = &text[start + 4..];
302 let end = rest.find(".SH ").map_or(text.len(), |i| start + 4 + i);
303 format!(
304 "{}{}{}",
305 &text[..start],
306 commands_section(&cmd),
307 &text[end..]
308 )
309 }
310 None => text + &commands_section(&cmd),
311 })
312}
313
314fn generate_man(dir: Option<PathBuf>) -> anyhow::Result<()> {
316 use std::io::Write;
317 let text = man_text()?;
318 match dir {
319 Some(dir) => {
320 std::fs::create_dir_all(&dir)?;
321 let path = dir.join("git-queue.1");
322 std::fs::write(&path, text)?;
323 eprintln!("Wrote {}", path.display());
324 }
325 None => std::io::stdout().write_all(text.as_bytes())?,
326 }
327 Ok(())
328}
329
330fn roff(s: &str) -> String {
331 s.replace('\\', "\\\\").replace('-', "\\-")
332}
333
334fn commands_section(cmd: &clap::Command) -> String {
336 let mut out = String::from(".SH COMMANDS\n");
337 for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
338 let mut synopsis = format!("git queue {}", sub.get_name());
339 for a in sub.get_arguments().filter(|a| a.get_id() != "help") {
340 let piece = if a.is_positional() {
341 let v = a.get_id().to_string().to_uppercase();
342 if a.is_required_set() {
343 format!("<{v}>")
344 } else {
345 format!("[{v}]")
346 }
347 } else {
348 let long = a.get_long().map(|l| format!("--{l}")).unwrap_or_default();
349 let val = if a.get_action().takes_values() {
350 format!(" <{}>", a.get_id().to_string().to_uppercase())
351 } else {
352 String::new()
353 };
354 if a.is_required_set() {
355 format!("{long}{val}")
356 } else {
357 format!("[{long}{val}]")
358 }
359 };
360 synopsis.push(' ');
361 synopsis.push_str(&piece);
362 }
363 out.push_str(&format!(".SS \\fB{}\\fR\n", roff(&synopsis)));
364 let aliases: Vec<String> = sub.get_visible_aliases().map(str::to_string).collect();
365 if !aliases.is_empty() {
366 out.push_str(&format!("Alias: {}\n.PP\n", roff(&aliases.join(", "))));
367 }
368 let about = sub
369 .get_long_about()
370 .or_else(|| sub.get_about())
371 .map(std::string::ToString::to_string)
372 .unwrap_or_default();
373 out.push_str(&format!("{}\n", roff(&about)));
374 for a in sub.get_arguments().filter(|a| a.get_id() != "help") {
375 let name = if a.is_positional() {
376 format!("<{}>", a.get_id().to_string().to_uppercase())
377 } else {
378 let val = if a.get_action().takes_values() {
379 format!(" <{}>", a.get_id().to_string().to_uppercase())
380 } else {
381 String::new()
382 };
383 format!("--{}{val}", a.get_long().unwrap_or_default())
384 };
385 let req = if a.is_required_set() {
386 " (required)"
387 } else {
388 " (optional)"
389 };
390 let help = a
391 .get_long_help()
392 .or_else(|| a.get_help())
393 .map(std::string::ToString::to_string)
394 .unwrap_or_default();
395 out.push_str(&format!(
396 ".TP\n\\fB{}\\fR{}\n{}\n",
397 roff(&name),
398 req,
399 roff(&help)
400 ));
401 }
402 }
403 out
404}
405
406pub fn run() {
409 let cli = Cli::parse();
410 let result = match cli.command {
411 Command::Create { name, base, queue } => {
412 commands::create(&name, base.as_deref(), queue.as_deref())
413 }
414 Command::Status => commands::status(),
415 Command::Ls => commands::ls(),
416 Command::Name { name } => commands::name(name),
417 Command::Log => commands::log(),
418 Command::Checkout { commit } => commands::checkout(&commit),
419 Command::Edit { queue } => commands::edit(queue.as_deref()),
420 Command::Tui => commands::tui(),
421 Command::Describe { message } => commands::describe(message),
422 Command::DescribeBranch { message } => commands::describe_branch(message),
423 Command::Track {
424 parent,
425 stamp_ids,
426 no_stamp_ids,
427 edit,
428 queue,
429 } => commands::track(parent, stamp_ids, no_stamp_ids, edit, queue.as_deref()),
430 Command::Untrack => commands::untrack(),
431 Command::Next => commands::next(),
432 Command::Prev => commands::prev(),
433 Command::Sync { no_push } => commands::sync(no_push),
434 Command::Submit { draft } => commands::submit(draft),
435 Command::Yank => commands::yank(),
436 Command::Doctor => commands::doctor(),
437 Command::Setup { yes, undo } => commands::setup(yes, undo),
438 Command::Commit { message } => commands::commit(message),
439 Command::Amend => commands::amend(),
440 Command::Reword { commit } => commands::reword(commit),
441 Command::Move { commit, new_parent } => commands::move_commits(&commit, &new_parent),
442 Command::StampTodo { file } => commands::stamp_todo(&file),
443 Command::AddQueueId { file } => commands::add_queue_id(&file),
444 Command::ReorderTodo { file } => commands::reorder_todo(&file),
445 Command::Requeue { auto } => commands::requeue(auto),
446 Command::Completions { shell } => commands::completions(shell),
447 Command::Man { dir } => generate_man(dir),
448 };
449 if let Err(e) = result {
450 eprintln!("error: {e:#}");
451 std::process::exit(1);
452 }
453}