git-queue 0.1.2

Manage queues of dependent branches and their numbered pull requests
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! `git queue` — manage queues of dependent branches and their numbered PRs.

mod commands;
pub mod engine;
mod gh;
mod git;
mod ident;
mod meta;
mod queue;
mod render;
mod requeue;
mod view;

use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser)]
#[command(
    name = "git-queue",
    bin_name = "git queue",
    version,
    about = "Manage queues of dependent branches and their numbered pull requests",
    long_about = "A PR queue is an ordered series of dependent branches: each branch builds on \
                  the one before it, and the PRs merge in FIFO order — front of the queue \
                  first. git-queue tracks that order, keeps the queue rebased on its base \
                  branch, and opens numbered, cross-linked pull requests — one per branch. \
                  Run `git queue setup` after installing to add man pages, shell completion, \
                  and an optional `git q` alias."
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Create a new branch queued after the current one.
    #[command(
        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."
    )]
    Create {
        /// Name of the new branch.
        name: String,
        /// Start the queue on this base branch instead of the current branch.
        #[arg(long)]
        base: Option<String>,
        /// Name for the queue when this starts a new one.
        #[arg(long)]
        queue: Option<String>,
    },
    /// Show the current queue and its PR status.
    #[command(
        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."
    )]
    Status,
    /// List every queue in the repo, most recently touched first.
    #[command(visible_alias = "list")]
    #[command(
        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."
    )]
    Ls,
    /// Show or set the current queue's name.
    #[command(
        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."
    )]
    Name {
        /// New name for the queue (shows the current name when omitted).
        name: Option<String>,
    },
    /// The status tree with each branch's commits (and their Stable-Commit-Ids) shown.
    #[command(
        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`."
    )]
    Log,
    /// Detach HEAD on a queue commit (SHA or Stable-Commit-Id) to edit it in place.
    #[command(
        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."
    )]
    Checkout {
        /// A commit of the current queue, or one of its branches to reattach.
        commit: String,
    },
    /// Edit the whole queue: reassign commits to branches in an editor.
    #[command(visible_alias = "split")]
    #[command(
        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."
    )]
    Edit {
        /// Queue name when editing starts a new queue (untracked branch).
        #[arg(long)]
        queue: Option<String>,
    },
    /// Interactively shape the current queue line in a terminal UI.
    #[command(
        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)."
    )]
    Tui,
    /// Describe the current QUEUE (the "About this queue" section of its PRs).
    #[command(
        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."
    )]
    Describe {
        /// Description text (opens $EDITOR if omitted).
        #[arg(short = 'm', long)]
        message: Option<String>,
    },
    /// Describe the current BRANCH (the "About this branch" section of its PR).
    #[command(name = "describe-branch")]
    #[command(
        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."
    )]
    DescribeBranch {
        /// Description text (opens $EDITOR if omitted).
        #[arg(short = 'm', long)]
        message: Option<String>,
    },
    /// Adopt the current branch into a queue.
    #[command(
        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."
    )]
    Track {
        /// Parent branch (defaults to trunk).
        #[arg(long)]
        parent: Option<String>,
        /// Stamp Stable-Commit-Ids onto existing commits without asking (rewrites them).
        #[arg(long, conflicts_with = "no_stamp_ids")]
        stamp_ids: bool,
        /// Never stamp Stable-Commit-Ids onto existing commits.
        #[arg(long)]
        no_stamp_ids: bool,
        /// After adopting, open the queue editor to divide the commits into
        /// multiple queued branches.
        #[arg(long, alias = "split")]
        edit: bool,
        /// Name for the queue when this adoption starts a new one.
        #[arg(long)]
        queue: Option<String>,
    },
    /// Forget the current branch's queue metadata.
    #[command(
        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."
    )]
    Untrack,
    /// Check out the child branch (toward the back of the queue).
    #[command(visible_alias = "up")]
    #[command(
        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)."
    )]
    Next,
    /// Check out the parent branch (toward the front of the queue).
    #[command(visible_alias = "down")]
    #[command(
        long_about = "Checks out the parent branch — one step toward the front of the queue."
    )]
    Prev,
    /// Make a new commit on the current branch and requeue its descendants.
    #[command(
        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."
    )]
    Commit {
        /// Commit message (opens the editor if omitted).
        #[arg(short = 'm', long)]
        message: Option<String>,
    },
    /// Fold STAGED changes into the current commit and update all descendants.
    #[command(
        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."
    )]
    Amend,
    /// Rewrite a commit message and update all descendants (defaults to HEAD).
    #[command(
        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."
    )]
    Reword {
        /// Commit to reword: a revision or a Stable-Commit-Id (unique prefix ok).
        commit: Option<String>,
    },
    /// Move a commit (or inclusive <first>..<last> range) elsewhere in the queue.
    #[command(
        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."
    )]
    Move {
        /// The commit to move, or an inclusive range `<first>..<last>` of
        /// consecutive commits. Each may be a revision or a Stable-Commit-Id
        /// (unique prefix ok). Must be commits of this queue.
        commit: String,
        /// The queue commit the moved commits should directly follow — a
        /// revision or a Stable-Commit-Id. Pass the base branch's tip commit
        /// to move them to the front of the queue.
        #[arg(long)]
        new_parent: String,
    },
    /// Requeue the current branch's descendants onto its tip.
    #[command(visible_alias = "restack")]
    #[command(
        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."
    )]
    Requeue {
        /// Quiet on no-op / non-queue branches (used by hooks).
        #[arg(long)]
        auto: bool,
    },
    /// Pull remote commits, requeue onto the latest base branch, and push (with lease).
    #[command(
        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."
    )]
    Sync {
        /// Skip pushing the branches back to the remote.
        #[arg(long)]
        no_push: bool,
    },
    /// Push the queue and open/update its numbered PRs.
    #[command(visible_alias = "push")]
    #[command(
        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."
    )]
    Submit {
        /// Open new PRs as drafts.
        #[arg(long)]
        draft: bool,
    },
    /// Close every open (non-merged) PR in the current queue.
    #[command(
        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."
    )]
    Yank,
    /// Interactive setup: hooks, gate, man page, completion, `git q` alias, agent skills.
    #[command(
        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."
    )]
    Setup {
        /// Accept the hooks and gate steps without asking (integrations still ask).
        #[arg(long)]
        yes: bool,
        /// Reverse setup: remove hooks, gate, skill and AGENTS.md section.
        #[arg(long)]
        undo: bool,
    },
    /// Report whether merge-order signalling is set up (read-only).
    #[command(
        long_about = "Read-only diagnosis of merge-order signalling: whether the status gate is enabled, and whether the GitHub CLI is ready. Changes nothing."
    )]
    Doctor,
    /// Internal: `GIT_SEQUENCE_EDITOR` for id stamping (marks picks as reword).
    #[command(hide = true, name = "stamp-todo")]
    StampTodo {
        /// Path to the rebase todo file (passed by git).
        file: PathBuf,
    },
    /// Internal: commit-msg hook adding a `Stable-Commit-Id` trailer on queue branches.
    #[command(hide = true, name = "add-queue-id")]
    AddQueueId {
        /// Path to the commit-message file (passed by git).
        file: PathBuf,
    },
    /// Internal: `GIT_SEQUENCE_EDITOR` for `git queue move` (rewrites a rebase todo).
    #[command(hide = true, name = "reorder-todo")]
    ReorderTodo {
        /// Path to the rebase todo file (passed by git).
        file: PathBuf,
    },
    /// Print a shell completion script to stdout.
    #[command(
        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`."
    )]
    Completions {
        /// The shell to generate completions for.
        shell: clap_complete::Shell,
    },
    /// Generate the roff man page (enables `git queue --help`; installed by `setup`).
    #[command(hide = true)]
    Man {
        /// Directory to write `git-queue.1` into. Prints to stdout if omitted.
        #[arg(long)]
        dir: Option<PathBuf>,
    },
}

/// The clap command tree — the single source used to render both the man page
/// and shell completions.
pub(crate) fn cli_command() -> clap::Command {
    Cli::command()
}

/// Render the man page as roff text. `clap_mangen` produces the top-level page,
/// whose SUBCOMMANDS section references non-existent per-subcommand pages
/// (git-queue-init(1)); we replace it with a fully detailed COMMANDS section —
/// real `git queue <cmd>` names, the long description, and every argument —
/// generated from the same clap definitions.
pub(crate) fn man_text() -> anyhow::Result<String> {
    let cmd = Cli::command();
    let man = clap_mangen::Man::new(cmd.clone());
    let mut buffer: Vec<u8> = Vec::new();
    man.render(&mut buffer)?;
    let text = String::from_utf8(buffer)?;

    // Drop the auto-generated SUBCOMMANDS section (starts at .SH SUBCOMMANDS,
    // ends at the next .SH) and put the detailed section in its place.
    Ok(match text.find(".SH SUBCOMMANDS") {
        Some(start) => {
            let rest = &text[start + 4..];
            let end = rest.find(".SH ").map_or(text.len(), |i| start + 4 + i);
            format!(
                "{}{}{}",
                &text[..start],
                commands_section(&cmd),
                &text[end..]
            )
        }
        None => text + &commands_section(&cmd),
    })
}

/// Write the man page to `dir/git-queue.1`, or to stdout when `dir` is `None`.
fn generate_man(dir: Option<PathBuf>) -> anyhow::Result<()> {
    use std::io::Write;
    let text = man_text()?;
    match dir {
        Some(dir) => {
            std::fs::create_dir_all(&dir)?;
            let path = dir.join("git-queue.1");
            std::fs::write(&path, text)?;
            eprintln!("Wrote {}", path.display());
        }
        None => std::io::stdout().write_all(text.as_bytes())?,
    }
    Ok(())
}

fn roff(s: &str) -> String {
    s.replace('\\', "\\\\").replace('-', "\\-")
}

/// One detailed roff section per visible subcommand.
fn commands_section(cmd: &clap::Command) -> String {
    let mut out = String::from(".SH COMMANDS\n");
    for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
        let mut synopsis = format!("git queue {}", sub.get_name());
        for a in sub.get_arguments().filter(|a| a.get_id() != "help") {
            let piece = if a.is_positional() {
                let v = a.get_id().to_string().to_uppercase();
                if a.is_required_set() {
                    format!("<{v}>")
                } else {
                    format!("[{v}]")
                }
            } else {
                let long = a.get_long().map(|l| format!("--{l}")).unwrap_or_default();
                let val = if a.get_action().takes_values() {
                    format!(" <{}>", a.get_id().to_string().to_uppercase())
                } else {
                    String::new()
                };
                if a.is_required_set() {
                    format!("{long}{val}")
                } else {
                    format!("[{long}{val}]")
                }
            };
            synopsis.push(' ');
            synopsis.push_str(&piece);
        }
        out.push_str(&format!(".SS \\fB{}\\fR\n", roff(&synopsis)));
        let aliases: Vec<String> = sub.get_visible_aliases().map(str::to_string).collect();
        if !aliases.is_empty() {
            out.push_str(&format!("Alias: {}\n.PP\n", roff(&aliases.join(", "))));
        }
        let about = sub
            .get_long_about()
            .or_else(|| sub.get_about())
            .map(std::string::ToString::to_string)
            .unwrap_or_default();
        out.push_str(&format!("{}\n", roff(&about)));
        for a in sub.get_arguments().filter(|a| a.get_id() != "help") {
            let name = if a.is_positional() {
                format!("<{}>", a.get_id().to_string().to_uppercase())
            } else {
                let val = if a.get_action().takes_values() {
                    format!(" <{}>", a.get_id().to_string().to_uppercase())
                } else {
                    String::new()
                };
                format!("--{}{val}", a.get_long().unwrap_or_default())
            };
            let req = if a.is_required_set() {
                " (required)"
            } else {
                " (optional)"
            };
            let help = a
                .get_long_help()
                .or_else(|| a.get_help())
                .map(std::string::ToString::to_string)
                .unwrap_or_default();
            out.push_str(&format!(
                ".TP\n\\fB{}\\fR{}\n{}\n",
                roff(&name),
                req,
                roff(&help)
            ));
        }
    }
    out
}

/// Parse the CLI and run the selected subcommand. Exits the process on error.
/// Called by the `git-queue` binary.
pub fn run() {
    let cli = Cli::parse();
    let result = match cli.command {
        Command::Create { name, base, queue } => {
            commands::create(&name, base.as_deref(), queue.as_deref())
        }
        Command::Status => commands::status(),
        Command::Ls => commands::ls(),
        Command::Name { name } => commands::name(name),
        Command::Log => commands::log(),
        Command::Checkout { commit } => commands::checkout(&commit),
        Command::Edit { queue } => commands::edit(queue.as_deref()),
        Command::Tui => commands::tui(),
        Command::Describe { message } => commands::describe(message),
        Command::DescribeBranch { message } => commands::describe_branch(message),
        Command::Track {
            parent,
            stamp_ids,
            no_stamp_ids,
            edit,
            queue,
        } => commands::track(parent, stamp_ids, no_stamp_ids, edit, queue.as_deref()),
        Command::Untrack => commands::untrack(),
        Command::Next => commands::next(),
        Command::Prev => commands::prev(),
        Command::Sync { no_push } => commands::sync(no_push),
        Command::Submit { draft } => commands::submit(draft),
        Command::Yank => commands::yank(),
        Command::Doctor => commands::doctor(),
        Command::Setup { yes, undo } => commands::setup(yes, undo),
        Command::Commit { message } => commands::commit(message),
        Command::Amend => commands::amend(),
        Command::Reword { commit } => commands::reword(commit),
        Command::Move { commit, new_parent } => commands::move_commits(&commit, &new_parent),
        Command::StampTodo { file } => commands::stamp_todo(&file),
        Command::AddQueueId { file } => commands::add_queue_id(&file),
        Command::ReorderTodo { file } => commands::reorder_todo(&file),
        Command::Requeue { auto } => commands::requeue(auto),
        Command::Completions { shell } => commands::completions(shell),
        Command::Man { dir } => generate_man(dir),
    };
    if let Err(e) = result {
        eprintln!("error: {e:#}");
        std::process::exit(1);
    }
}