Skip to main content

git_queue/
lib.rs

1//! `git queue` — manage queues of dependent branches and their numbered PRs.
2
3mod 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    /// Create a new branch queued after the current one.
38    #[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 of the new branch.
43        name: String,
44        /// Start the queue on this base branch instead of the current branch.
45        #[arg(long)]
46        base: Option<String>,
47        /// Name for the queue when this starts a new one.
48        #[arg(long)]
49        queue: Option<String>,
50    },
51    /// Show the current queue and its PR status.
52    #[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    /// List every queue in the repo, most recently touched first.
57    #[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    /// Show or set the current queue's name.
63    #[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        /// New name for the queue (shows the current name when omitted).
68        name: Option<String>,
69    },
70    /// The status tree with each branch's commits (and their Stable-Commit-Ids) shown.
71    #[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    /// Detach HEAD on a queue commit (SHA or Stable-Commit-Id) to edit it in place.
76    #[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        /// A commit of the current queue, or one of its branches to reattach.
81        commit: String,
82    },
83    /// Edit the whole queue: reassign commits to branches in an editor.
84    #[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        /// Queue name when editing starts a new queue (untracked branch).
90        #[arg(long)]
91        queue: Option<String>,
92    },
93    /// Interactively shape the current queue line in a terminal UI.
94    #[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    /// Describe the current QUEUE (the "About this queue" section of its PRs).
99    #[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        /// Description text (opens $EDITOR if omitted).
104        #[arg(short = 'm', long)]
105        message: Option<String>,
106    },
107    /// Describe the current BRANCH (the "About this branch" section of its PR).
108    #[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        /// Description text (opens $EDITOR if omitted).
114        #[arg(short = 'm', long)]
115        message: Option<String>,
116    },
117    /// Adopt the current branch into a queue.
118    #[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        /// Parent branch (defaults to trunk).
123        #[arg(long)]
124        parent: Option<String>,
125        /// Stamp Stable-Commit-Ids onto existing commits without asking (rewrites them).
126        #[arg(long, conflicts_with = "no_stamp_ids")]
127        stamp_ids: bool,
128        /// Never stamp Stable-Commit-Ids onto existing commits.
129        #[arg(long)]
130        no_stamp_ids: bool,
131        /// After adopting, open the queue editor to divide the commits into
132        /// multiple queued branches.
133        #[arg(long, alias = "split")]
134        edit: bool,
135        /// Name for the queue when this adoption starts a new one.
136        #[arg(long)]
137        queue: Option<String>,
138    },
139    /// Forget the current branch's queue metadata.
140    #[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    /// Check out the child branch (toward the back of the queue).
145    #[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    /// Check out the parent branch (toward the front of the queue).
151    #[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    /// Make a new commit on the current branch and requeue its descendants.
157    #[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        /// Commit message (opens the editor if omitted).
162        #[arg(short = 'm', long)]
163        message: Option<String>,
164    },
165    /// Fold STAGED changes into the current commit and update all descendants.
166    #[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    /// Rewrite a commit message and update all descendants (defaults to HEAD).
171    #[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 to reword: a revision or a Stable-Commit-Id (unique prefix ok).
176        commit: Option<String>,
177    },
178    /// Move a commit (or inclusive <first>..<last> range) elsewhere in the queue.
179    #[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        /// The commit to move, or an inclusive range `<first>..<last>` of
184        /// consecutive commits. Each may be a revision or a Stable-Commit-Id
185        /// (unique prefix ok). Must be commits of this queue.
186        commit: String,
187        /// The queue commit the moved commits should directly follow — a
188        /// revision or a Stable-Commit-Id. Pass the base branch's tip commit
189        /// to move them to the front of the queue.
190        #[arg(long)]
191        new_parent: String,
192    },
193    /// Requeue the current branch's descendants onto its tip.
194    #[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        /// Quiet on no-op / non-queue branches (used by hooks).
200        #[arg(long)]
201        auto: bool,
202    },
203    /// Pull remote commits, requeue onto the latest base branch, and push (with lease).
204    #[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        /// Skip pushing the branches back to the remote.
209        #[arg(long)]
210        no_push: bool,
211    },
212    /// Push the queue and open/update its numbered PRs.
213    #[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        /// Open new PRs as drafts.
219        #[arg(long)]
220        draft: bool,
221    },
222    /// Close every open (non-merged) PR in the current queue.
223    #[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    /// Interactive setup: hooks, gate, man page, completion, `git q` alias, agent skills.
228    #[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        /// Accept the hooks and gate steps without asking (integrations still ask).
233        #[arg(long)]
234        yes: bool,
235        /// Reverse setup: remove hooks, gate, skill and AGENTS.md section.
236        #[arg(long)]
237        undo: bool,
238    },
239    /// Report whether merge-order signalling is set up (read-only).
240    #[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    /// Internal: `GIT_SEQUENCE_EDITOR` for id stamping (marks picks as reword).
245    #[command(hide = true, name = "stamp-todo")]
246    StampTodo {
247        /// Path to the rebase todo file (passed by git).
248        file: PathBuf,
249    },
250    /// Internal: commit-msg hook adding a `Stable-Commit-Id` trailer on queue branches.
251    #[command(hide = true, name = "add-queue-id")]
252    AddQueueId {
253        /// Path to the commit-message file (passed by git).
254        file: PathBuf,
255    },
256    /// Internal: `GIT_SEQUENCE_EDITOR` for `git queue move` (rewrites a rebase todo).
257    #[command(hide = true, name = "reorder-todo")]
258    ReorderTodo {
259        /// Path to the rebase todo file (passed by git).
260        file: PathBuf,
261    },
262    /// Print a shell completion script to stdout.
263    #[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        /// The shell to generate completions for.
268        shell: clap_complete::Shell,
269    },
270    /// Generate the roff man page (enables `git queue --help`; installed by `setup`).
271    #[command(hide = true)]
272    Man {
273        /// Directory to write `git-queue.1` into. Prints to stdout if omitted.
274        #[arg(long)]
275        dir: Option<PathBuf>,
276    },
277}
278
279/// The clap command tree — the single source used to render both the man page
280/// and shell completions.
281pub(crate) fn cli_command() -> clap::Command {
282    Cli::command()
283}
284
285/// Render the man page as roff text. `clap_mangen` produces the top-level page,
286/// whose SUBCOMMANDS section references non-existent per-subcommand pages
287/// (git-queue-init(1)); we replace it with a fully detailed COMMANDS section —
288/// real `git queue <cmd>` names, the long description, and every argument —
289/// generated from the same clap definitions.
290pub(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    // Drop the auto-generated SUBCOMMANDS section (starts at .SH SUBCOMMANDS,
298    // ends at the next .SH) and put the detailed section in its place.
299    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
314/// Write the man page to `dir/git-queue.1`, or to stdout when `dir` is `None`.
315fn 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
334/// One detailed roff section per visible subcommand.
335fn 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
406/// Parse the CLI and run the selected subcommand. Exits the process on error.
407/// Called by the `git-queue` binary.
408pub 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}