use crate::engine::Engine;
use crate::queue::{Line, Queue};
use crate::render::{self, Entry, PrRef};
use crate::{gh, git, ident, meta, requeue};
use anyhow::{bail, Context, Result};
fn apply_status_gate(entries: &[Entry]) -> Result<()> {
for s in render::gate_plan(entries) {
let sha = git::rev_parse(&s.branch)?;
gh::set_commit_status(
&sha,
render::GATE_CONTEXT,
s.success,
&s.description,
s.target_url.as_deref(),
)?;
}
Ok(())
}
fn confirm(question: &str, default_yes: bool) -> bool {
use std::io::Write;
print!("{question} [{}] ", if default_yes { "Y/n" } else { "y/N" });
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).ok();
match answer.trim().to_lowercase().as_str() {
"" => default_yes,
"y" | "yes" => true,
_ => false,
}
}
pub fn setup(yes: bool, undo: bool) -> Result<()> {
git::ensure_repo()?;
if undo {
return setup_undo();
}
let tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
if !tty && !yes {
bail!("`git queue setup` is interactive; pass --yes to accept the repo-local steps");
}
let ask = |q: &str| -> bool {
if tty {
confirm(q, true)
} else {
yes
}
};
if ask(
"Install the git hooks? (plain `git commit`/`--amend` auto-requeue descendants;
new queue commits get a Stable-Commit-Id)",
) {
hooks_install()?;
}
if ask(
"Enable the merge-order gate? (submit/sync post a red/green commit status per PR
so reviewers see which PR merges next)",
) {
meta::set_gate("status")?;
println!("Merge-order gate enabled.");
}
if tty && confirm("Install the man page? (`man git-queue`, and `git queue --help`)", true) {
if let Err(e) = install_man_pages() {
eprintln!("note: could not install the man page: {e:#}");
}
}
if tty {
match detect_shell() {
Some(shell) => {
if confirm(
&format!("Install {shell} completion for the `git queue` commands?"),
true,
) {
if let Err(e) = install_completions(shell) {
eprintln!("note: could not install completion: {e:#}");
}
}
}
None => println!(
"note: shell not recognised from $SHELL; skipping completion \
(bash / zsh / fish are supported)."
),
}
}
if tty
&& confirm(
"Add a `git q` alias to your global git config (shortcut for `git queue`)?",
true,
)
{
if let Err(e) = install_git_alias() {
eprintln!("note: could not set the alias: {e:#}");
}
}
let home = std::env::var("HOME").unwrap_or_default();
let claude_dir = std::path::Path::new(&home).join(".claude");
if tty
&& claude_dir.exists()
&& confirm(
"Claude Code detected. Install the `using-git-queue` skill so Claude drives
git-queue correctly?",
true,
)
{
let dir = claude_dir.join("skills").join("using-git-queue");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("SKILL.md"), EMBEDDED_SKILL)?;
println!("Installed {}.", dir.join("SKILL.md").display());
}
let others: Vec<&str> = [
("codex", ".codex"),
("cursor", ".cursor"),
("gemini", ".gemini"),
("windsurf", ".windsurf"),
("copilot", ".config/github-copilot"),
]
.iter()
.filter(|(_, d)| std::path::Path::new(&home).join(d).exists())
.map(|(n, _)| *n)
.collect();
if tty
&& !others.is_empty()
&& confirm(
&format!(
"Detected other agents ({}). Add a git-queue section to this repo's AGENTS.md
(read by Codex, Cursor, Copilot and most agent CLIs)?",
others.join(", ")
),
true,
)
{
write_agents_md_section()?;
}
println!(
"
Setup done. `git queue doctor` reports the current state."
);
Ok(())
}
fn setup_undo() -> Result<()> {
hooks_uninstall()?;
let _ = git::ok(&["config", "--local", "--unset", "queue.gate"]);
println!("Merge-order gate disabled.");
remove_man_pages();
remove_completions();
if git::out(&["config", "--global", "--get", "alias.q"])
.ok()
.as_deref()
== Some("queue")
{
let _ = git::ok(&["config", "--global", "--unset", "alias.q"]);
println!("Removed the `git q` alias.");
}
let home = std::env::var("HOME").unwrap_or_default();
let skill = std::path::Path::new(&home).join(".claude/skills/using-git-queue/SKILL.md");
if skill.exists() {
std::fs::remove_file(&skill).ok();
println!("Removed {}.", skill.display());
}
strip_agents_md_section()?;
Ok(())
}
fn home_dir() -> std::path::PathBuf {
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
}
fn man_dirs() -> Vec<std::path::PathBuf> {
vec![
std::path::PathBuf::from("/usr/local/share/man/man1"),
home_dir().join(".local/share/man/man1"),
]
}
fn dir_writable(dir: &std::path::Path) -> bool {
let probe = dir.join(".git-queue-probe");
let ok = std::fs::write(&probe, b"").is_ok();
let _ = std::fs::remove_file(&probe);
ok
}
fn install_man_pages() -> Result<()> {
let text = crate::man_text()?;
let dir = man_dirs()
.into_iter()
.find(|d| std::fs::create_dir_all(d).is_ok() && dir_writable(d))
.ok_or_else(|| {
anyhow::anyhow!("no writable man directory (tried /usr/local and ~/.local)")
})?;
let path = dir.join("git-queue.1");
std::fs::write(&path, &text)?;
println!("Installed man page: {}", path.display());
let user_base = home_dir().join(".local/share/man");
if dir.starts_with(&user_base) && !manpath_contains(&user_base) {
println!(
"note: add {0} to your MANPATH so `man git-queue` resolves, e.g.:\n \
export MANPATH=\"{0}:$(manpath)\"",
user_base.display()
);
}
Ok(())
}
fn remove_man_pages() {
for dir in man_dirs() {
for name in ["git-queue.1", "git-q.1"] {
let p = dir.join(name);
if p.exists() && std::fs::remove_file(&p).is_ok() {
println!("Removed {}.", p.display());
}
}
}
}
fn manpath_contains(dir: &std::path::Path) -> bool {
std::process::Command::new("manpath")
.output()
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split(':')
.any(|p| std::path::Path::new(p.trim()) == dir)
})
.unwrap_or(false)
}
fn detect_shell() -> Option<clap_complete::Shell> {
let shell = std::env::var("SHELL").ok()?;
match std::path::Path::new(&shell)
.file_name()?
.to_string_lossy()
.as_ref()
{
"bash" => Some(clap_complete::Shell::Bash),
"zsh" => Some(clap_complete::Shell::Zsh),
"fish" => Some(clap_complete::Shell::Fish),
_ => None,
}
}
fn completion_target(shell: clap_complete::Shell) -> Option<(std::path::PathBuf, &'static str)> {
use clap_complete::Shell::*;
let home = home_dir();
match shell {
Bash => Some((
home.join(".local/share/bash-completion/completions"),
"git-queue",
)),
Zsh => Some((home.join(".local/share/zsh/site-functions"), "_git-queue")),
Fish => Some((home.join(".config/fish/completions"), "git-queue.fish")),
_ => None,
}
}
fn install_completions(shell: clap_complete::Shell) -> Result<()> {
let (dir, name) = completion_target(shell)
.ok_or_else(|| anyhow::anyhow!("no completion location for {shell}"))?;
std::fs::create_dir_all(&dir)?;
let mut cmd = crate::cli_command();
let mut buf: Vec<u8> = Vec::new();
clap_complete::generate(shell, &mut cmd, "git-queue", &mut buf);
let path = dir.join(name);
std::fs::write(&path, &buf)?;
println!("Installed {shell} completion: {}", path.display());
if let clap_complete::Shell::Zsh = shell {
println!(
"note: ensure {0} is on your $fpath and compinit runs, e.g. in ~/.zshrc:\n \
fpath=({0} $fpath); autoload -Uz compinit && compinit",
dir.display()
);
}
Ok(())
}
fn remove_completions() {
for shell in [
clap_complete::Shell::Bash,
clap_complete::Shell::Zsh,
clap_complete::Shell::Fish,
] {
if let Some((dir, name)) = completion_target(shell) {
let p = dir.join(name);
if p.exists() && std::fs::remove_file(&p).is_ok() {
println!("Removed {}.", p.display());
}
}
}
}
pub fn completions(shell: clap_complete::Shell) -> Result<()> {
let mut cmd = crate::cli_command();
clap_complete::generate(shell, &mut cmd, "git-queue", &mut std::io::stdout());
Ok(())
}
fn install_git_alias() -> Result<()> {
git::run(&["config", "--global", "alias.q", "queue"])?;
println!("Added `git q` alias to your global git config (shortcut for `git queue`).");
Ok(())
}
const EMBEDDED_SKILL: &str = include_str!("../skills/using-git-queue/SKILL.md");
const AGENTS_BEGIN: &str = "<!-- git-queue:agents:begin -->";
const AGENTS_END: &str = "<!-- git-queue:agents:end -->";
fn write_agents_md_section() -> Result<()> {
let section = format!(
"{AGENTS_BEGIN}
## git-queue (PR queues)
This repo uses git-queue for stacked/queued PRs. Rules:
- See `git queue --help` (man page) for every command; `git queue status`/`log` show the queue.
- Never hand-rebase a queue branch: use `git queue commit`/`amend`/`move`/`checkout`.
- After changing history, run `git queue sync` to requeue, push (lease) and refresh PRs.
- PRs merge front-first; never merge a PR whose `git-queue/merge-order` status is red.
- Commits carry `Stable-Commit-Id:` trailers — preserve commit messages when rewriting.
{AGENTS_END}
"
);
let path = std::path::Path::new("AGENTS.md");
let existing = std::fs::read_to_string(path).unwrap_or_default();
let updated = match (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
(Some(a), Some(b)) if b > a => format!(
"{}{}{}",
&existing[..a],
section.trim_end(),
&existing[b + AGENTS_END.len()..]
),
_ if existing.is_empty() => section,
_ => format!(
"{}
{}",
existing.trim_end(),
section
),
};
std::fs::write(path, updated)?;
println!("Wrote the git-queue section of AGENTS.md.");
Ok(())
}
fn strip_agents_md_section() -> Result<()> {
let path = std::path::Path::new("AGENTS.md");
let Ok(existing) = std::fs::read_to_string(path) else {
return Ok(());
};
if let (Some(a), Some(b)) = (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
if b > a {
let rest = format!("{}{}", &existing[..a], &existing[b + AGENTS_END.len()..]);
if rest.trim().is_empty() {
std::fs::remove_file(path).ok();
} else {
std::fs::write(path, rest)?;
}
println!("Removed the git-queue section of AGENTS.md.");
}
}
Ok(())
}
pub fn doctor() -> Result<()> {
git::ensure_repo()?;
println!("git queue doctor — merge-order enforcement\n");
match meta::gate().as_deref() {
Some("status") => {
println!(" \u{2713} gate: enabled (status mode)");
println!(
" `git queue submit` posts a `{}` commit status on every open PR:",
render::GATE_CONTEXT
);
println!(" green \u{2713} on the PR at the front of the queue, red \u{2717} (\u{201c}merge PR #N first\u{201d})");
println!(" on the ones behind it.");
}
Some(other) => {
println!(" ! gate: unknown mode `{other}` \u{2014} run `git queue setup` to (re)enable status mode");
}
None => {
println!(" \u{2717} gate: not enabled \u{2014} run `git queue setup` to turn it on");
}
}
if gh::ready() {
println!(" \u{2713} GitHub CLI: authenticated");
} else {
println!(" ! GitHub CLI: not authenticated (`gh auth login`) \u{2014} needed for `submit` to post merge-order statuses");
}
println!("\nNote: the gate is advisory \u{2014} a red \u{2717} in the checks list warns reviewers off,");
println!("but it does not disable the merge button.");
Ok(())
}
pub fn create(name: &str, base: Option<&str>, queue_flag: Option<&str>) -> Result<()> {
git::ensure_repo()?;
let trunk = meta::trunk()?;
let parent = match base {
Some(b) => {
let b = resolve_branch_arg(b)?;
if !git::branch_exists(&b) {
bail!("base branch `{b}` does not exist");
}
b
}
None => git::current_branch()?,
};
let (qname, namespaced) = if meta::parent(&parent).is_some() {
let q = Queue::load()?;
let line = q.line_through(&parent)?;
match line_queue_name(&line) {
Some(n) => {
let ns = line
.branches
.first()
.map(|b| b.starts_with("queue/"))
.unwrap_or(false);
(n, ns)
}
None => bail!("this queue has no name; run `git queue name <name>` first"),
}
} else {
require_queue_name(queue_flag, name)?
};
let name = if name.contains('/') || !namespaced {
name.to_string()
} else {
format!("queue/{qname}/{name}")
};
let name = name.as_str();
if git::branch_exists(name) {
bail!("branch `{name}` already exists");
}
let parent_sha = git::rev_parse(&parent)?;
git::create_branch(name, &parent)?;
meta::set_parent(name, &parent)?;
meta::set_parent_sha(name, &parent_sha)?;
meta::set_branch_queue(name, &qname)?;
meta::touch_queue(&qname);
git::checkout(name)?;
if parent == trunk {
println!("Created `{name}` on trunk `{trunk}`. It is the front of a new queue.");
} else if meta::parent(&parent).is_none() {
println!("Created `{name}` on `{parent}`. It is the front of a new queue;");
println!("its PR will target `{parent}` (the merge base), not `{trunk}`.");
} else {
println!("Created `{name}` after `{parent}` in the queue.");
}
println!("Make your commits, then `git queue submit` to open PRs.");
Ok(())
}
pub fn edit(queue_flag: Option<&str>) -> Result<()> {
git::ensure_repo()?;
if !git::worktree_clean() {
bail!(
"working tree has uncommitted changes; commit or stash them before editing the queue"
);
}
let queue = Queue::load()?;
let branch = git::current_branch()?;
let (line_branches, base, qname, explicit) = if queue.is_tracked(&branch) {
let line = queue.line_through(&branch)?;
if let Some(f) = &line.fork_at {
eprintln!("warning: `{f}` has multiple children; editing this line only.");
}
let (qname, explicit) = match line_queue_name(&line) {
Some(n) => (n, queue_flag.is_some()),
None => require_queue_name(queue_flag, &branch)?,
};
(line.branches, line.base, qname, explicit)
} else {
let (qname, explicit) = require_queue_name(queue_flag, &branch)?;
(vec![branch.clone()], queue.trunk.clone(), qname, explicit)
};
let mut all: Vec<(String, Option<String>, String)> = Vec::new();
let mut counts: Vec<(String, usize)> = Vec::new();
let mut parent = base.clone();
for b in &line_branches {
let commits = git::commits_between_with_ids(&parent, b)?;
counts.push((b.clone(), commits.len()));
all.extend(commits);
parent = b.clone();
}
if all.is_empty() {
bail!("the queue has no commits to edit");
}
let namespaced = line_branches.iter().any(|b| b.starts_with("queue/")) || explicit;
let display = |b: &str| -> String {
b.strip_prefix(&format!("queue/{qname}/"))
.unwrap_or(b)
.to_string()
};
let resolve = |n: &str| -> String {
if n.contains('/') || line_branches.iter().any(|b| b == n) || !namespaced {
n.to_string()
} else {
format!("queue/{qname}/{n}")
}
};
let assignments = run_queue_editor(&qname, &base, &counts, &all, &display)?;
let segments = fold_segments(&assignments, &all)?;
let segments: Vec<(String, String)> = segments
.into_iter()
.map(|(n, sha)| (resolve(&n), sha))
.collect();
for (name, _) in &segments {
if !line_branches.contains(name) && git::branch_exists(name) {
bail!("branch `{name}` already exists; pick a different name");
}
}
let unchanged = segments.len() == line_branches.len()
&& segments
.iter()
.zip(&line_branches)
.all(|((n, tip), b)| n == b && git::rev_parse(b).map(|s| &s == tip).unwrap_or(false));
if unchanged {
println!("Queue unchanged.");
return Ok(());
}
git::detach_head()?;
let mut parent = base.clone();
for (name, tip_sha) in &segments {
if git::branch_exists(name) {
git::force_ref(name, tip_sha)?;
} else {
git::create_branch(name, tip_sha)?;
}
meta::set_parent(name, &parent)?;
meta::set_parent_sha(name, &git::rev_parse(&parent)?)?;
meta::set_branch_queue(name, &qname)?;
parent = name.clone();
}
meta::touch_queue(&qname);
let removed: Vec<String> = line_branches
.iter()
.filter(|b| !segments.iter().any(|(n, _)| &n == b))
.cloned()
.collect();
for b in &removed {
let pr = meta::pr(b);
meta::untrack(b);
git::run(&["branch", "-q", "-D", b])?;
println!("Deleted `{b}` (its commits are covered by the remaining branches).");
if let Some(n) = pr {
println!(
"note: its PR #{n} is still open on GitHub — close it, or repurpose it manually."
);
}
if git::remote_branch(&meta::remote(), b).is_some() {
println!(
"note: it still exists on the remote; remove it with `git push {} --delete {b}`.",
meta::remote()
);
}
}
let land = if segments.iter().any(|(n, _)| n == &branch) {
branch.clone()
} else {
segments.last().unwrap().0.clone()
};
git::checkout(&land)?;
println!("Queue `{qname}` now has {} branches:", segments.len());
let mut p = base.clone();
for (name, _) in &segments {
println!(" {p} ← {name}");
p = name.clone();
}
println!("Now on `{land}`. Run `git queue sync` to update the PRs.");
Ok(())
}
pub fn tui() -> Result<()> {
git::ensure_repo()?;
if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
bail!(
"`git queue tui` needs an interactive terminal; use `git queue edit` \
for a non-interactive, scriptable queue editor"
);
}
let engine = Engine::load()?;
crate::view::run(engine)
}
fn run_queue_editor(
qname: &str,
base: &str,
counts: &[(String, usize)],
all: &[(String, Option<String>, String)],
display: &dyn Fn(&str) -> String,
) -> Result<Vec<(String, String)>> {
let dir = std::path::PathBuf::from(git::out(&["rev-parse", "--git-dir"])?);
let path = dir.join("QUEUE_EDIT");
let mut body = String::new();
body.push_str(&format!(
"# Queue `{qname}` on `{base}` — assign commits to branches.\n\
#\n\
# A `[branch]` line starts a branch; the commits listed below it belong\n\
# to that branch. The first section is the front of the queue (merges\n\
# first). Move, rename, add, or remove the `[branch]` lines to change\n\
# which branch a commit belongs to — but keep every commit line exactly\n\
# where it is: commits cannot be reordered or deleted here.\n\
# Removing a header dissolves that branch into its neighbours; adding\n\
# one splits a branch in two. Short names are fine — they resolve\n\
# within this queue. Lines starting with `#` are ignored.\n"
));
let mut idx = 0;
for (b, n) in counts {
body.push_str(&format!("\n[{}]\n", display(b)));
for (sha, id, subject) in &all[idx..idx + n] {
let token = match id {
Some(id) => id.chars().take(10).collect::<String>(),
None => sha[..sha.len().min(12)].to_string(),
};
body.push_str(&format!("{token} {subject}\n"));
}
idx += n;
}
std::fs::write(&path, body)?;
let editor = git::out(&["var", "GIT_EDITOR"])?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{editor} \"$1\""))
.arg("sh")
.arg(&path)
.status()
.context("failed to launch editor")?;
if !status.success() {
bail!("editor exited with an error; edit cancelled");
}
let raw = std::fs::read_to_string(&path)?;
let _ = std::fs::remove_file(&path);
let mut out = Vec::new();
let mut headers: Vec<String> = Vec::new();
let mut section: Option<String> = None;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
let name = name.trim();
if name.is_empty() {
bail!("a `[]` header has no branch name");
}
headers.push(name.to_string());
section = Some(name.to_string());
continue;
}
let sha = line.split_whitespace().next().unwrap().to_string();
match §ion {
Some(s) => out.push((s.clone(), sha)),
None => bail!("commit `{sha}` appears before any `[branch]` header"),
}
}
for h in &headers {
if !out.iter().any(|(s, _)| s == h) {
bail!("branch `{h}` has no commits; remove its header or move commits beneath it");
}
}
Ok(out)
}
fn fold_segments(
assignments: &[(String, String)],
all: &[(String, Option<String>, String)],
) -> Result<Vec<(String, String)>> {
if assignments.len() != all.len() {
bail!(
"expected {} commit lines but found {}; commits cannot be added or deleted here",
all.len(),
assignments.len()
);
}
let mut segments: Vec<(String, String)> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for (i, (name, token)) in assignments.iter().enumerate() {
let (full_sha, id, _) = &all[i];
let in_place = token.is_empty()
|| if token.starts_with("q-") {
id.as_deref()
.is_some_and(|id| id.starts_with(token.as_str()))
} else {
full_sha.starts_with(token.as_str())
};
if !in_place {
bail!("commit lines were reordered; commits cannot be reordered here — only assigned to branches");
}
match segments.last_mut() {
Some((last, tip)) if last == name => *tip = full_sha.clone(),
_ => {
if seen.contains(name) {
bail!(
"branch `{name}` appears twice; a branch is one contiguous run of commits"
);
}
seen.push(name.clone());
segments.push((name.clone(), full_sha.clone()));
}
}
}
Ok(segments)
}
pub fn track(
parent: Option<String>,
stamp_ids: bool,
no_stamp_ids: bool,
edit_after: bool,
queue_flag: Option<&str>,
) -> Result<()> {
git::ensure_repo()?;
let trunk = meta::trunk()?;
let branch = git::current_branch()?;
if branch == trunk {
bail!("cannot track the trunk branch itself");
}
if edit_after && !git::worktree_clean() {
bail!("working tree has uncommitted changes; commit or stash them before `track --edit`");
}
let parent = match parent {
Some(p) => resolve_branch_arg(&p)?,
None => trunk.clone(),
};
if !git::branch_exists(&parent) {
bail!("parent branch `{parent}` does not exist");
}
if !git::is_ancestor(&parent, &branch) {
bail!(
"`{parent}` is not an ancestor of `{branch}`; pass the correct --parent or rebase first"
);
}
let base = git::merge_base(&parent, &branch)?;
meta::set_parent(&branch, &parent)?;
meta::set_parent_sha(&branch, &base)?;
let qname = {
let q = Queue::load()?;
let line = q.line_through(&branch)?;
match line_queue_name(&line) {
Some(n) => n,
None => require_queue_name(queue_flag, &branch)?.0,
}
};
meta::set_branch_queue(&branch, &qname)?;
meta::touch_queue(&qname);
println!("Tracking `{branch}` with parent `{parent}` in queue `{qname}`.");
let missing: Vec<String> = git::queue_ids(&format!("{base}..{branch}"))?
.into_iter()
.filter(|(_, id)| id.is_none())
.map(|(sha, _)| sha)
.collect();
if missing.is_empty() {
return edit_if_requested(edit_after, &base, &branch, queue_flag);
}
let n = missing.len();
let stamp = if stamp_ids {
true
} else if no_stamp_ids {
false
} else if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
println!();
println!("`{branch}` has {n} commit(s) without a Stable-Commit-Id (stable change identity");
println!("that survives rebases; used for safe syncing and squash-merge detection).");
println!("Stamping rewrites those commits — their hashes change. If the branch is");
println!("already pushed, the next `git queue sync`/`submit` will force-push it (with");
println!("lease), and anyone who fetched the old hashes will need to reset onto the");
println!("new ones. Any open PR keeps working.");
print!("Stamp them now? [Y/n] ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).ok();
matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes")
} else {
eprintln!(
"note: {n} commit(s) have no Stable-Commit-Id; re-run `git queue track --stamp-ids` to \
stamp them (rewrites their hashes)."
);
false
};
if !stamp {
return edit_if_requested(edit_after, &base, &branch, queue_flag);
}
if !git::worktree_clean() {
eprintln!("note: working tree has uncommitted changes; skipping id stamping. Commit or");
eprintln!("stash, then re-run `git queue track --stamp-ids`.");
return edit_if_requested(edit_after, &base, &branch, queue_flag);
}
git::rebase_stamp_ids(&base, &branch, &missing)?;
println!("Stamped {n} commit(s) with Stable-Commit-Ids (their hashes changed).");
edit_if_requested(edit_after, &base, &branch, queue_flag)
}
fn edit_if_requested(
requested: bool,
base: &str,
branch: &str,
queue_flag: Option<&str>,
) -> Result<()> {
if !requested {
return Ok(());
}
if git::ahead_count(base, branch)? < 2 {
println!("`{branch}` has fewer than 2 commits — nothing to divide.");
return Ok(());
}
edit(queue_flag)
}
pub fn untrack() -> Result<()> {
git::ensure_repo()?;
let branch = git::current_branch()?;
meta::untrack(&branch);
println!("Stopped tracking `{branch}`.");
Ok(())
}
pub fn describe(message: Option<String>) -> Result<()> {
git::ensure_repo()?;
let queue = Queue::load()?;
let branch = git::current_branch()?;
if !queue.is_tracked(&branch) {
bail!("`{branch}` is not a queue branch; `git queue create`/`track` it first");
}
let line = queue.line_through(&branch)?;
let Some(qname) = line_queue_name(&line) else {
bail!("this queue has no name yet; run `git queue name <name>` first");
};
let text = match message {
Some(m) => m,
None => edit_description(
&format!("queue `{qname}`"),
meta::queue_description(&qname).as_deref(),
"the whole queue (the \"About this queue\" section of every PR in it)",
)?,
};
meta::set_queue_description(&qname, &text)?;
meta::touch_queue(&qname);
if text.trim().is_empty() {
println!("Cleared the description of queue `{qname}`.");
} else {
println!("Saved the description of queue `{qname}`. Every PR in the queue shows it");
println!("under \"About this queue\" on the next `git queue submit`/`sync`.");
}
Ok(())
}
pub fn describe_branch(message: Option<String>) -> Result<()> {
git::ensure_repo()?;
let queue = Queue::load()?;
let branch = git::current_branch()?;
if !queue.is_tracked(&branch) {
bail!("`{branch}` is not a queue branch; `git queue create`/`track` it first");
}
let text = match message {
Some(m) => m,
None => edit_description(
&format!("`{branch}`"),
meta::description(&branch).as_deref(),
"this branch (the \"About this branch\" section of its PR)",
)?,
};
meta::set_description(&branch, &text)?;
if text.trim().is_empty() {
println!("Cleared the description for `{branch}`.");
} else {
println!(
"Saved the description for `{branch}`. It will appear in the PR on `git queue submit`."
);
}
Ok(())
}
pub fn name(new_name: Option<String>) -> Result<()> {
git::ensure_repo()?;
let queue = Queue::load()?;
let branch = git::current_branch()?;
if !queue.is_tracked(&branch) {
bail!("`{branch}` is not a queue branch");
}
let line = queue.line_through(&branch)?;
match new_name {
None => match line_queue_name(&line) {
Some(n) => println!("{n}"),
None => println!("(this queue has no name; set one with `git queue name <name>`)"),
},
Some(n) => {
meta::validate_queue_name(&n)?;
for b in &line.branches {
meta::set_branch_queue(b, &n)?;
}
meta::touch_queue(&n);
println!("Named this queue `{n}` ({} branches).", line.branches.len());
}
}
Ok(())
}
pub fn ls() -> Result<()> {
git::ensure_repo()?;
let queue = Queue::load()?;
let current = git::current_branch().ok();
let mut queues: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
let mut unnamed: Vec<String> = Vec::new();
for leaf in queue.leaves() {
let Ok(line) = queue.line_through(&leaf) else {
continue;
};
match line_queue_name(&line) {
Some(n) => {
let e = queues.entry(n).or_default();
for b in &line.branches {
if !e.contains(b) {
e.push(b.clone());
}
}
}
None => unnamed.push(line.branches.first().cloned().unwrap_or(leaf)),
}
}
for n in meta::all_queue_names() {
queues.entry(n).or_default();
}
if queues.is_empty() && unnamed.is_empty() {
println!("No queues yet. Create one with `git queue create <name>`.");
return Ok(());
}
let mut ordered: Vec<(String, Vec<String>)> = queues.into_iter().collect();
ordered.sort_by_key(|(n, _)| std::cmp::Reverse(meta::queue_touched_at(n)));
for (n, branches) in &ordered {
let here = current
.as_deref()
.map(|c| branches.iter().any(|b| b == c))
.unwrap_or(false);
let marker = if here { " ← current" } else { "" };
let desc = meta::queue_description(n)
.map(|d| {
let first = d.lines().next().unwrap_or("").trim().to_string();
format!(" — {first}")
})
.unwrap_or_default();
println!(
"{n} ({} branch{}){desc}{marker}",
branches.len(),
if branches.len() == 1 { "" } else { "es" }
);
for b in branches {
println!(" {b}");
}
}
for front in unnamed {
println!("(unnamed queue starting at `{front}` — run `git queue name <name>` from it)");
}
Ok(())
}
fn edit_description(what: &str, existing: Option<&str>, becomes: &str) -> Result<String> {
let dir = std::path::PathBuf::from(git::out(&["rev-parse", "--git-dir"])?);
let path = dir.join("QUEUE_DESCRIBE");
let template = format!(
"{}\n\n# Describe {what}. This becomes the PR text for {becomes}.\n\
# Lines starting with '#' are ignored.\n",
existing.unwrap_or("")
);
std::fs::write(&path, template)?;
let editor = git::out(&["var", "GIT_EDITOR"])?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{editor} \"$1\""))
.arg("sh") .arg(&path) .status()
.context("failed to launch editor")?;
if !status.success() {
bail!("editor exited with an error; description unchanged");
}
let raw = std::fs::read_to_string(&path)?;
let _ = std::fs::remove_file(&path);
let cleaned: Vec<&str> = raw
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect();
Ok(cleaned.join("\n").trim().to_string())
}
pub fn status() -> Result<()> {
show_tree(false)
}
pub fn log() -> Result<()> {
show_tree(true)
}
fn show_tree(with_commits: bool) -> Result<()> {
git::ensure_repo()?;
let queue = Queue::load()?;
let current = git::current_branch()?;
let anchor = if queue.is_tracked(¤t) {
current.clone()
} else if let Some(child) = queue.children(¤t).into_iter().next() {
child
} else if current == queue.trunk {
match queue.roots().into_iter().next() {
Some(r) => r,
None => {
println!("No queues yet. Create one with `git queue create <name>`.");
return Ok(());
}
}
} else {
println!("Branch `{current}` is not tracked. Adopt it with `git queue track`.");
return Ok(());
};
let chain = queue.chain_to_base(&anchor)?;
let root = chain[0].clone();
let base = queue
.parent_of(&root)
.expect("root is tracked, so it has a parent")
.to_string();
fn topdown(
queue: &Queue,
branch: &str,
indent: usize,
chain: &[String],
out: &mut Vec<(String, usize)>,
) {
let kids = queue.children(branch);
let main = kids
.iter()
.find(|k| chain.contains(k))
.or_else(|| kids.first())
.cloned();
if let Some(m) = &main {
topdown(queue, m, indent, chain, out);
}
for k in kids.iter().filter(|k| Some(*k) != main.as_ref()) {
topdown(queue, k, indent + 1, chain, out);
}
out.push((branch.to_string(), indent));
}
let mut ordered: Vec<(String, usize)> = Vec::new();
topdown(&queue, &root, 0, &chain, &mut ordered);
let branches: Vec<String> = ordered.iter().map(|(b, _)| b.clone()).collect();
let mut entries = build_entries(&branches)?;
for (e, (_, indent)) in entries.iter_mut().zip(&ordered) {
e.indent = *indent;
let parent = queue
.parent_of(&e.branch)
.expect("tracked branch has a parent")
.to_string();
if e.conflicted {
e.conflicts = git::conflict_files(&e.branch);
}
if with_commits {
if let Ok(commits) = git::commits_with_ids(&format!("{parent}..{}", e.branch)) {
e.commits = commits;
}
}
}
let tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
let color = tty && std::env::var_os("NO_COLOR").is_none();
let repo_url = if tty && terminal_renders_hyperlinks() {
git::github_repo_url(&meta::remote())
} else {
None
};
print!(
"{}",
render::status_tree(
&entries,
¤t,
&base,
base == queue.trunk,
color,
repo_url.as_deref()
)
);
Ok(())
}
fn terminal_renders_hyperlinks() -> bool {
let var = |k: &str| std::env::var(k).unwrap_or_default();
let term_program = var("TERM_PROGRAM");
if matches!(
term_program.as_str(),
"iTerm.app" | "WezTerm" | "ghostty" | "Hyper" | "vscode" | "Tabby"
) {
return true;
}
if !var("KITTY_WINDOW_ID").is_empty() || !var("WT_SESSION").is_empty() {
return true;
}
if var("VTE_VERSION")
.parse::<u32>()
.map(|v| v >= 5000)
.unwrap_or(false)
{
return true;
}
if var("KONSOLE_VERSION")
.parse::<u32>()
.map(|v| v >= 201100)
.unwrap_or(false)
{
return true;
}
let term = var("TERM");
term.contains("kitty") || term.contains("wezterm") || term.contains("foot")
}
pub fn prev() -> Result<()> {
git::ensure_repo()?;
let branch = git::current_branch()?;
match meta::parent(&branch) {
Some(p) => {
git::checkout(&p)?;
Ok(())
}
None => bail!("`{branch}` has no tracked parent"),
}
}
pub fn next() -> Result<()> {
git::ensure_repo()?;
let queue = Queue::load()?;
let branch = git::current_branch()?;
let kids = queue.children(&branch);
match kids.len() {
0 => bail!("`{branch}` is at the top of its queue (no children)"),
1 => git::checkout(&kids[0]),
_ => {
let list = kids.join("\n ");
bail!("`{branch}` has multiple children; check one out directly:\n {list}")
}
}
}
pub fn sync(no_push: bool) -> Result<()> {
git::ensure_repo()?;
if git::rebase_in_progress() {
bail!("a rebase is already in progress; finish it (`git rebase --continue`/`--abort`) then re-run `git queue sync`");
}
std::env::set_var(git::GUARD_ENV, "1"); let mut queue = Queue::load()?;
let remote = meta::remote();
let original = git::current_branch()?;
let started_clean = git::worktree_clean();
println!("Fetching `{remote}`...");
if let Err(e) = git::fetch(&remote) {
eprintln!("warning: fetch failed; syncing against local refs only: {e}");
}
for base in queue.bases() {
if let Some(remote_base) = git::remote_trunk(&remote, &base) {
let tip = git::rev_parse(&remote_base)?;
if original == base {
let _ = git::merge_ff_only(&remote_base);
} else {
let _ = git::force_ref(&base, &tip);
}
}
}
queue = heal_dangling_parents(queue)?;
queue = prune_landed_by_id(queue)?;
if gh::ready() {
queue = prune_merged(queue)?;
}
for leaf in queue.leaves() {
let Ok(line) = queue.line_through(&leaf) else {
continue;
};
let top = line.branches.last().unwrap().clone();
let Ok(ids) = git::queue_ids(&format!("{}..{top}", line.base)) else {
continue;
};
let missing: Vec<String> = ids
.into_iter()
.filter(|(_, id)| id.is_none())
.map(|(sha, _)| sha)
.collect();
if missing.is_empty() {
continue;
}
let n = missing.len();
git::rebase_stamp_ids(&line.base, &top, &missing)?;
for (i, br) in line.branches.iter().enumerate() {
let parent = if i == 0 {
line.base.clone()
} else {
line.branches[i - 1].clone()
};
meta::set_parent_sha(br, &git::rev_parse(&parent)?)?;
}
println!("Stamped {n} commit(s) with Stable-Commit-Ids (queue ending at `{top}`).");
}
git::checkout_quiet(&original)?;
for branch in queue.topo_order() {
match incorporate_remote(&branch, &remote, &original)? {
Some(RemoteAction::FastForwarded) => {
println!("Pulled remote commits into `{branch}` (fast-forward).")
}
Some(RemoteAction::Pulled(n)) => {
println!("Pulled {n} teammate commit(s) into `{branch}`.")
}
None => {}
}
}
git::detach_head()?;
let report = requeue::requeue_forest(&queue)?;
git::checkout_quiet(&original)?;
if started_clean {
git::reset_hard_head()?;
} else {
eprintln!(
"note: the worktree had local changes when sync started; if `{original}` was \
rebased, run `git reset --hard` once your changes are safe."
);
}
let mut push_failures = 0usize;
if !no_push {
for branch in queue.topo_order() {
if push_would_mislabel_child(&queue, &branch) {
push_failures += 1;
continue;
}
println!("Pushing `{branch}`...");
if let Err(e) = git::push(&remote, &branch) {
eprintln!("warning: push of `{branch}` failed: {e:#}");
push_failures += 1;
}
}
if push_failures > 0 {
eprintln!(
"note: {push_failures} push(es) failed. If a PR merged or its branch was \
deleted on the remote mid-sync, re-run `git queue sync` — it will prune \
merged branches and settle the rest."
);
}
}
if !no_push && gh::ready() {
for leaf in queue.leaves() {
let line = match queue.line_through(&leaf) {
Ok(l) => l,
Err(_) => continue,
};
let published = line
.branches
.iter()
.any(|b| gh::find(b).ok().flatten().is_some());
if !published {
continue;
}
if line_queue_name(&line).is_none() {
eprintln!(
"warning: skipping PR reconciliation for the queue ending at `{leaf}` — it \
has no name. Run `git queue name <name>` from one of its branches."
);
continue;
}
let outcome = reconcile_line_prs(&line, false, None, false)?;
report_line(&line, &outcome, "Reconciled")?;
}
}
if !report.conflicted.is_empty() {
requeue::warn_conflicts(&report.conflicted);
} else {
let bases = queue.bases();
match bases.as_slice() {
[] => println!("Nothing to sync yet."),
[one] => println!("Queue is in sync with `{one}`."),
many => println!(
"Queues are in sync with their bases: {}.",
many.iter()
.map(|b| format!("`{b}`"))
.collect::<Vec<_>>()
.join(", ")
),
}
}
Ok(())
}
fn heal_dangling_parents(queue: Queue) -> Result<Queue> {
let mut changed = false;
for b in queue.topo_order() {
let parent = match queue.parent_of(&b) {
Some(p) => p.to_string(),
None => continue,
};
if queue.is_tracked(&parent) || git::branch_exists(&parent) {
continue;
}
meta::set_parent(&b, &queue.trunk)?;
eprintln!(
"note: `{b}`'s parent `{parent}` is gone (merged?); reparented onto `{}`.",
queue.trunk
);
changed = true;
}
if changed {
Queue::load()
} else {
Ok(queue)
}
}
fn push_would_mislabel_child(queue: &Queue, branch: &str) -> bool {
for child in queue.children(branch) {
let (Ok(ct), Ok(bt)) = (git::rev_parse(&child), git::rev_parse(branch)) else {
continue;
};
if !git::is_ancestor(&ct, &bt) {
continue; }
let open_pr = gh::find(&child)
.ok()
.flatten()
.map(|pr| pr.state == "OPEN")
.unwrap_or(false);
if open_pr {
eprintln!(
"warning: not pushing `{branch}` — its tip contains the head of `{child}`'s \
OPEN PR, and GitHub would permanently mark that PR as merged. The queue \
looks collapsed (or `{child}` has no commits of its own); untangle it with \
`git queue move`, or close the child PR first."
);
return true;
}
}
false
}
fn prune_merged(queue: Queue) -> Result<Queue> {
use std::collections::HashSet;
let mut merged: HashSet<String> = HashSet::new();
for b in queue.topo_order() {
if let Some(pr) = gh::find(&b).ok().flatten() {
if pr.state == "MERGED" {
merged.insert(b);
}
}
}
drop_from_queue(queue, &merged, "has merged")
}
fn prune_landed_by_id(queue: Queue) -> Result<Queue> {
use std::collections::HashSet;
let mut landed: HashSet<String> = HashSet::new();
for b in queue.topo_order() {
let parent = queue.parent_of(&b).unwrap().to_string();
let (Ok(pb), Ok(tb)) = (
git::merge_base(&parent, &b),
git::merge_base(&queue.trunk, &b),
) else {
continue;
};
let Ok(ids) = git::queue_ids(&format!("{pb}..{b}")) else {
continue;
};
if ids.is_empty() || ids.iter().any(|(_, id)| id.is_none()) {
continue;
}
let Ok(trunk_text) = git::log_messages(&format!("{tb}..{}", queue.trunk)) else {
continue;
};
if ids
.iter()
.all(|(_, id)| trunk_text.contains(id.as_deref().unwrap()))
{
landed.insert(b);
}
}
drop_from_queue(
queue,
&landed,
"has landed on trunk (Stable-Commit-Ids found)",
)
}
fn drop_from_queue(
queue: Queue,
gone: &std::collections::HashSet<String>,
why: &str,
) -> Result<Queue> {
if gone.is_empty() {
return Ok(queue);
}
for b in queue.topo_order() {
if gone.contains(&b) {
continue;
}
let current_parent = queue.parent_of(&b).unwrap().to_string();
let mut new_parent = current_parent.clone();
while gone.contains(&new_parent) {
new_parent = queue
.parent_of(&new_parent)
.map(|s| s.to_string())
.unwrap_or_else(|| queue.trunk.clone());
}
if new_parent != current_parent {
meta::set_parent(&b, &new_parent)?;
}
}
for b in gone {
meta::untrack(b);
println!("`{b}` {why} — dropped from the queue, children reparented.");
}
Queue::load()
}
enum RemoteAction {
FastForwarded,
Pulled(usize),
}
fn incorporate_remote(branch: &str, remote: &str, current: &str) -> Result<Option<RemoteAction>> {
let remote_sha = match git::remote_branch(remote, branch) {
Some(sha) => sha,
None => return Ok(None), };
let local_sha = git::rev_parse(branch)?;
if local_sha == remote_sha {
return Ok(None);
}
if git::was_previous_position(branch, &remote_sha) {
return Ok(None);
}
if git::is_ancestor(&remote_sha, &local_sha) {
return Ok(None); }
if git::is_ancestor(&local_sha, &remote_sha) {
if branch == current {
git::merge_ff_only(&format!("{remote}/{branch}"))?;
} else {
git::force_ref(branch, &remote_sha)?;
}
return Ok(Some(RemoteAction::FastForwarded));
}
let mb = git::merge_base(&format!("{remote}/{branch}"), branch)?;
let local_ids: std::collections::HashSet<String> = git::queue_ids(&format!("{mb}..{branch}"))?
.into_iter()
.filter_map(|(_, id)| id)
.collect();
let patch_fresh: std::collections::HashSet<String> = git::cherry_fresh(branch, &remote_sha)?
.into_iter()
.collect();
let fresh: Vec<String> = git::queue_ids(&format!("{mb}..{remote_sha}"))?
.into_iter()
.filter(|(sha, id)| match id {
Some(id) => !local_ids.contains(id),
None => patch_fresh.contains(sha),
})
.map(|(sha, _)| sha)
.collect();
if fresh.is_empty() {
return Ok(None); }
let n = fresh.len();
git::cherry_pick_persist(branch, &fresh)?;
Ok(Some(RemoteAction::Pulled(n)))
}
fn line_queue_name(line: &Line) -> Option<String> {
for b in &line.branches {
if let Some(n) = meta::branch_queue(b) {
return Some(n);
}
}
for b in &line.branches {
if let Some(rest) = b.strip_prefix("queue/") {
if let Some((n, _)) = rest.split_once('/') {
return Some(n.to_string());
}
}
}
None
}
fn require_queue_name(flag: Option<&str>, fallback: &str) -> Result<(String, bool)> {
if let Some(n) = flag {
meta::validate_queue_name(n)?;
return Ok((n.to_string(), true));
}
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
print!("Name this queue: ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).ok();
let answer = answer.trim().to_string();
if !answer.is_empty() {
meta::validate_queue_name(&answer)?;
return Ok((answer, true));
}
}
let fallback = fallback.replace('/', "-");
meta::validate_queue_name(&fallback)?;
eprintln!("note: queue named `{fallback}` (rename any time with `git queue name <name>`).");
Ok((fallback, false))
}
fn resolve_branch_arg(arg: &str) -> Result<String> {
if git::branch_exists(arg) {
return Ok(arg.to_string());
}
let suffix = format!("/{arg}");
let matches: Vec<String> = meta::tracked_branches()
.into_iter()
.filter(|b| b.ends_with(&suffix))
.collect();
match matches.as_slice() {
[one] => Ok(one.clone()),
[] => Ok(arg.to_string()), many => bail!("`{arg}` is ambiguous: {}", many.join(", ")),
}
}
struct LinePrs {
entries: Vec<Entry>,
prs: Vec<Option<PrRef>>,
}
fn reconcile_line_prs(
line: &Line,
draft: bool,
push: Option<&str>,
strict: bool,
) -> Result<LinePrs> {
let Some(queue_name) = line_queue_name(line) else {
bail!("this queue has no name (needed for its PRs); run `git queue name <name>` first");
};
let queue_description = meta::queue_description(&queue_name).unwrap_or_default();
meta::touch_queue(&queue_name);
let branches = &line.branches;
let total = branches.len();
let base_of = |i: usize| -> String {
if i == 0 {
line.base.clone()
} else {
branches[i - 1].clone()
}
};
let existing: Vec<Option<gh::Pr>> = branches
.iter()
.map(|b| gh::find(b))
.collect::<Result<_>>()?;
let frozen: Vec<bool> = existing
.iter()
.map(|p| p.as_ref().map(|pr| pr.state.as_str()) == Some("MERGED"))
.collect();
let mut empty = vec![false; total];
for (i, b) in branches.iter().enumerate() {
if frozen[i] {
continue;
}
if git::ahead_count(&base_of(i), b)? == 0 {
if strict {
bail!(
"`{b}` has no commits beyond `{}`; add a commit before submitting",
base_of(i)
);
}
if existing[i].is_none() {
eprintln!(
"note: `{b}` has no commits beyond `{}`; not opening a PR for it.",
base_of(i)
);
empty[i] = true;
}
}
}
for (i, b) in branches.iter().enumerate() {
if frozen[i] || empty[i] {
continue;
}
let base = base_of(i);
if let Some(remote) = push {
let collapsed_child = branches.get(i + 1).is_some_and(|child| {
existing[i + 1]
.as_ref()
.map(|pr| pr.state == "OPEN")
.unwrap_or(false)
&& match (git::rev_parse(child), git::rev_parse(b)) {
(Ok(ct), Ok(bt)) => git::is_ancestor(&ct, &bt),
_ => false,
}
});
if collapsed_child {
eprintln!(
"warning: not pushing `{b}` — its tip contains the head of the next \
PR in the queue, and GitHub would permanently mark that PR as merged. \
Untangle with `git queue move`, or close the child PR first."
);
} else {
println!("Pushing `{b}`...");
git::push(remote, b)?;
}
}
let subject = git::tip_subject(b)?;
let title = render::numbered_title(&subject, i, total);
let number = match &existing[i] {
Some(pr) if pr.state == "OPEN" => pr.number,
Some(pr) => match gh::reopen(pr.number) {
Ok(()) => pr.number,
Err(_) => gh::create(b, &base, &title, "Opening…", draft)?,
},
None => {
let n = gh::create(b, &base, &title, "Opening…", draft)?;
println!("Opened PR #{n} for `{b}` (base `{base}`).");
n
}
};
meta::set_pr(b, number)?;
}
let mut full: Vec<Option<gh::Pr>> = vec![None; total];
let mut prs: Vec<Option<PrRef>> = vec![None; total];
for (i, b) in branches.iter().enumerate() {
if frozen[i] {
prs[i] = existing[i].as_ref().map(pr_ref); continue;
}
if let Some(pr) = gh::find(b)? {
prs[i] = Some(pr_ref(&pr));
full[i] = Some(pr);
}
}
let entries: Vec<Entry> = branches
.iter()
.enumerate()
.map(|(i, b)| Entry {
branch: b.clone(),
pr: prs[i].clone(),
conflicted: git::has_conflict_markers(b),
conflicts: Vec::new(),
commits: Vec::new(),
indent: 0,
})
.collect();
for (i, b) in branches.iter().enumerate() {
if frozen[i] {
continue;
}
let number = match &prs[i] {
Some(p) => p.number,
None => continue,
};
let subject = match &full[i] {
Some(pr) if !pr.title.trim().is_empty() => pr.title.clone(),
_ => git::tip_subject(b)?,
};
let title = render::numbered_title(&subject, i, total);
let nav = render::nav_block(&entries, b, &line.base, &queue_name);
let description = match meta::description(b).filter(|d| !d.trim().is_empty()) {
Some(d) => d,
None => {
let adopted = full[i]
.as_ref()
.map(|pr| render::strip_block(&pr.body).trim().to_string())
.unwrap_or_default();
let adopted = if adopted == "Opening…" {
String::new()
} else {
adopted
};
if !adopted.is_empty() {
meta::set_description(b, &adopted)?;
}
adopted
}
};
let body = render::compose_body(&queue_description, &description, &nav);
gh::edit(number, &base_of(i), &title, &body)?;
}
Ok(LinePrs { entries, prs })
}
fn report_line(line: &Line, outcome: &LinePrs, heading: &str) -> Result<()> {
let gate = meta::gate();
let gated = gate.as_deref() == Some("status");
if let Some(other) = gate.as_deref().filter(|g| *g != "status") {
eprintln!("warning: unknown queue.gate mode `{other}` — no merge gate applied; run `git queue setup` to enable status mode");
}
if gated {
apply_status_gate(&outcome.entries)?;
}
let total = line.branches.len();
println!("\n{heading} {total} PR(s):");
for (i, b) in line.branches.iter().enumerate() {
if let Some(p) = &outcome.prs[i] {
println!(" [{}/{}] {} {}", i + 1, total, b, p.url);
}
}
if gated {
println!("Merge gate active: the front PR's `{}` status is \u{2713}; the rest are \u{2717} until it lands.", render::GATE_CONTEXT);
}
Ok(())
}
pub fn submit(draft: bool) -> Result<()> {
git::ensure_repo()?;
if !gh::ready() {
bail!("`gh` is not installed or not authenticated; run `gh auth login`");
}
let queue = Queue::load()?;
let current = git::current_branch()?;
if !queue.is_tracked(¤t) {
bail!("`{current}` is not tracked; run `git queue create` or `git queue track` first");
}
let line = queue.line_through(¤t)?;
if let Some(fork) = &line.fork_at {
eprintln!("warning: `{fork}` has multiple children; submitting only this line.");
}
let remote = meta::remote();
let outcome = reconcile_line_prs(&line, draft, Some(&remote), true)?;
report_line(&line, &outcome, "Submitted")
}
fn pr_ref(pr: &gh::Pr) -> PrRef {
PrRef {
number: pr.number,
url: pr.url.clone(),
state: pr.state.clone(),
review: pr.review_decision.clone(),
}
}
fn resolve_queue_rev(line: &Line, arg: &str) -> Result<String> {
let arg = arg.trim();
if arg.starts_with("q-") {
let top = line.branches.last().unwrap();
let ids = git::queue_ids(&format!("{}..{top}", line.base))?;
let matches: Vec<&(String, Option<String>)> = ids
.iter()
.filter(|(_, id)| {
id.as_deref()
.map(|i| i == arg || i.starts_with(arg))
.unwrap_or(false)
})
.collect();
match matches.as_slice() {
[(sha, _)] => return Ok(sha.clone()),
[] => {
if let Ok(sha) = git::rev_parse(arg) {
return Ok(sha);
}
bail!("no commit in this queue has Stable-Commit-Id `{arg}`");
}
many => bail!(
"Stable-Commit-Id prefix `{arg}` is ambiguous ({} matches); use more characters",
many.len()
),
}
}
git::rev_parse(arg).with_context(|| format!("`{arg}` is not a commit"))
}
pub fn move_commits(spec: &str, new_parent: &str) -> Result<()> {
git::ensure_repo()?;
if git::rebase_in_progress() {
bail!("a rebase is already in progress; finish it (`git rebase --continue`/`--abort`) then re-run `git queue move`");
}
if !git::worktree_clean() {
bail!("working tree has uncommitted changes; commit or stash them before moving commits");
}
std::env::set_var(git::GUARD_ENV, "1");
let queue = Queue::load()?;
let original = git::current_branch()?;
if !queue.is_tracked(&original) {
bail!("`{original}` is not a queue branch");
}
let line = queue.line_through(&original)?;
if let Some(fork) = &line.fork_at {
eprintln!("warning: `{fork}` has multiple children; moving within this line only (other lines requeue on the next sync).");
}
let top = line.branches.last().unwrap().clone();
let commits = git::commits_between(&line.base, &top)?;
let pos: std::collections::HashMap<&str, usize> = commits
.iter()
.enumerate()
.map(|(i, (sha, _))| (sha.as_str(), i))
.collect();
let resolve = |rev: &str| -> Result<String> { resolve_queue_rev(&line, rev) };
let (first, last) = match spec.split_once("..") {
Some((a, b)) => (resolve(a)?, resolve(b)?),
None => {
let one = resolve(spec)?;
(one.clone(), one)
}
};
let in_line = |sha: &String, what: &str| -> Result<usize> {
pos.get(sha.as_str()).copied().ok_or_else(|| {
anyhow::anyhow!(
"{what} `{}` is not part of this queue (`{}`..`{top}`)",
&sha[..8],
line.base
)
})
};
let (mut ia, mut ib) = (in_line(&first, "commit")?, in_line(&last, "commit")?);
if ia > ib {
std::mem::swap(&mut ia, &mut ib);
}
let p = resolve(new_parent)?;
let after = if p == git::rev_parse(&line.base)? {
None
} else {
let ip = in_line(&p, "--new-parent")?;
if (ia..=ib).contains(&ip) {
bail!("--new-parent is inside the range being moved");
}
Some(ip)
};
let already = match after {
None => ia == 0,
Some(ip) => ip + 1 == ia,
};
if already {
println!("Nothing to move: the commits already follow `{new_parent}`.");
return Ok(());
}
let move_shas: Vec<String> = commits[ia..=ib].iter().map(|(s, _)| s.clone()).collect();
let after_sha = after.map(|ip| commits[ip].0.clone());
let tip_before = git::rev_parse(&top)?;
git::rebase_reorder_persist(&line.base, &top, &move_shas, after_sha.as_deref())?;
if git::rev_parse(&top)? == tip_before {
bail!("the move did not apply (the rebase was aborted); the queue is unchanged");
}
for (i, br) in line.branches.iter().enumerate() {
let parent = if i == 0 {
line.base.clone()
} else {
line.branches[i - 1].clone()
};
meta::set_parent_sha(br, &git::rev_parse(&parent)?)?;
}
git::checkout_quiet(&original)?;
git::reset_hard_head()?;
let dest = match &after {
None => format!("the front of the queue (directly on `{}`)", line.base),
Some(ip) => {
let (sha, subject) = &commits[*ip];
format!("directly after {} ({subject})", &sha[..8])
}
};
println!("Moved {} commit(s) to {dest}.", move_shas.len());
let mut conflicted = Vec::new();
for (i, br) in line.branches.iter().enumerate() {
if git::has_conflict_markers(br) {
conflicted.push(br.clone());
}
let parent = if i == 0 {
line.base.clone()
} else {
line.branches[i - 1].clone()
};
if git::rev_parse(br)? == git::rev_parse(&parent)? {
eprintln!("note: `{br}` no longer has any commits of its own.");
}
}
if conflicted.is_empty() {
println!(
"Run `git queue sync` (or `submit`) to push the rewritten queue and refresh its PRs."
);
} else {
requeue::warn_conflicts(&conflicted);
}
Ok(())
}
pub fn stamp_todo(path: &std::path::Path) -> Result<()> {
let shas: Vec<String> = std::env::var("GIT_QUEUE_REWORD_SHAS")
.context("GIT_QUEUE_REWORD_SHAS is not set")?
.split_whitespace()
.map(str::to_string)
.collect();
let todo = std::fs::read_to_string(path)?;
let mut rewritten = Vec::new();
let mut marked = 0usize;
for line in todo.lines() {
let mut it = line.split_whitespace();
let is_target = it.next() == Some("pick")
&& it
.next()
.map(|abbrev| shas.iter().any(|f| f.starts_with(abbrev)))
.unwrap_or(false);
if is_target {
marked += 1;
rewritten.push(format!("reword{}", &line[4..]));
} else {
rewritten.push(line.to_string());
}
}
if marked != shas.len() {
bail!(
"todo mismatch: expected {} pick(s) to reword, found {marked}",
shas.len()
);
}
std::fs::write(path, rewritten.join("\n") + "\n")?;
Ok(())
}
pub fn reorder_todo(path: &std::path::Path) -> Result<()> {
let shas: Vec<String> = std::env::var("GIT_QUEUE_MOVE_SHAS")
.context("GIT_QUEUE_MOVE_SHAS is not set")?
.split_whitespace()
.map(str::to_string)
.collect();
let after = std::env::var("GIT_QUEUE_MOVE_AFTER").unwrap_or_default();
let todo = std::fs::read_to_string(path)?;
let pick_sha = |line: &str| -> Option<String> {
let mut it = line.split_whitespace();
(it.next() == Some("pick")).then(|| it.next().unwrap_or("").to_string())
};
let matches = |abbrev: &str, full: &str| !abbrev.is_empty() && full.starts_with(abbrev);
let mut moved = Vec::new();
let mut rest = Vec::new();
for line in todo.lines() {
let is_moved = pick_sha(line)
.map(|a| shas.iter().any(|f| matches(&a, f)))
.unwrap_or(false);
if is_moved {
moved.push(line.to_string());
} else {
rest.push(line.to_string());
}
}
if moved.len() != shas.len() {
bail!(
"todo mismatch: expected {} pick(s) to move, found {}",
shas.len(),
moved.len()
);
}
let idx = if after.is_empty() {
0
} else {
match rest
.iter()
.position(|l| pick_sha(l).map(|a| matches(&a, &after)).unwrap_or(false))
{
Some(i) => i + 1,
None => bail!("todo mismatch: --new-parent pick not found"),
}
};
rest.splice(idx..idx, moved);
std::fs::write(path, rest.join("\n") + "\n")?;
Ok(())
}
pub fn add_queue_id(path: &std::path::Path) -> Result<()> {
if git::ensure_repo().is_err() {
return Ok(());
}
let stamping = std::env::var("GIT_QUEUE_STAMP_ALL").is_ok();
if !stamping {
match git::current_branch() {
Ok(branch) => {
if meta::parent(&branch).is_none() {
return Ok(());
}
}
Err(_) => {
if meta::detached_state().is_none() {
return Ok(());
}
}
}
}
let msg = std::fs::read_to_string(path).unwrap_or_default();
let has_content = msg
.lines()
.any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'));
if !has_content {
return Ok(());
}
git::add_trailer_to_file(path, &ident::new_id())
}
pub fn checkout(arg: &str) -> Result<()> {
git::ensure_repo()?;
std::env::set_var(git::GUARD_ENV, "1");
let queue = Queue::load()?;
let line = if let Ok(current) = git::current_branch() {
if !queue.is_tracked(¤t) {
bail!("`{current}` is not a queue branch");
}
queue.line_through(¤t)?
} else if let Some((_, top)) = meta::detached_state() {
queue.line_through(&top)?
} else {
bail!("HEAD is detached outside a queue-editing session; check out a queue branch first");
};
let top = line.branches.last().unwrap().clone();
let reattach = line
.branches
.iter()
.find(|b| *b == arg || b.ends_with(&format!("/{arg}")))
.cloned()
.or_else(|| (arg == line.base).then(|| line.base.clone()));
if let Some(target) = reattach {
let arg = target.as_str();
if !git::tracked_clean() {
bail!("stage or tracked files have changes; commit or stash them first");
}
git::checkout_quiet(arg)?;
meta::clear_detached_state();
println!("Back on `{arg}`.");
return Ok(());
}
let sha = resolve_queue_rev(&line, arg)?;
let commits = git::commits_between(&line.base, &top)?;
if !commits.iter().any(|(s, _)| s == &sha) {
bail!(
"`{arg}` is not a commit of this queue (`{}`..`{top}`)",
line.base
);
}
if !git::tracked_clean() {
bail!(
"stage or tracked files have changes; commit or stash them before `git queue checkout`"
);
}
git::run(&["checkout", "-q", "--detach", &sha])?;
meta::set_detached_state(&sha, &top)?;
let subject = git::tip_subject("HEAD")?;
println!("Detached at {} ({subject}).", &sha[..8]);
println!("Edit away — `git add` then:");
println!(" git commit inserts a NEW commit right after this one");
println!(" git commit --amend revises this commit (its Stable-Commit-Id is kept)");
println!("The rest of the queue rebases on top automatically (with hooks installed);");
println!("otherwise run `git queue requeue`. Return with `git queue checkout {top}`.");
Ok(())
}
fn reintegrate_detached(auto: bool, original: &str, top: &str) -> Result<()> {
let head = git::rev_parse("HEAD")?;
if head == original {
if !auto {
println!("Nothing to reintegrate: HEAD is still the checked-out commit.");
}
return Ok(());
}
git::rebase_persist(&head, original, top)?;
git::run(&["checkout", "-q", "--detach", &head])?;
meta::set_detached_state(&head, top)?;
let queue = Queue::load()?;
let line = queue.line_through(top)?;
let mut conflicted = Vec::new();
for (i, br) in line.branches.iter().enumerate() {
let parent = if i == 0 {
line.base.clone()
} else {
line.branches[i - 1].clone()
};
meta::set_parent_sha(br, &git::rev_parse(&parent)?)?;
if git::has_conflict_markers(br) {
conflicted.push(br.clone());
}
}
if let Some(qname) = line_queue_name(&line) {
meta::touch_queue(&qname);
}
println!(
"Reintegrated: the rest of the queue is rebased onto {} — still detached here.",
&head[..8]
);
if conflicted.is_empty() {
println!(
"When you're done editing: `git queue checkout {top}` to reattach, then \
`git queue sync` to push and refresh PRs."
);
} else {
requeue::warn_conflicts(&conflicted);
}
Ok(())
}
pub fn yank() -> Result<()> {
git::ensure_repo()?;
if !gh::ready() {
bail!("`gh` is not installed or not authenticated; run `gh auth login`");
}
let queue = Queue::load()?;
let current = git::current_branch()?;
if !queue.is_tracked(¤t) {
bail!("`{current}` is not a queue branch");
}
let line = queue.line_through(¤t)?;
let mut closed = 0;
for b in &line.branches {
if let Some(pr) = gh::find(b)? {
if pr.state == "OPEN" {
gh::close(pr.number)?;
println!("Closed #{} ({b})", pr.number);
closed += 1;
}
}
}
match closed {
0 => println!("No open PRs in this queue to close."),
n => println!("Closed {n} open PR(s). Merged PRs and local branches are untouched."),
}
Ok(())
}
pub fn commit(message: Option<String>) -> Result<()> {
git::ensure_repo()?;
std::env::set_var(git::GUARD_ENV, "1");
let queue = Queue::load()?;
let current = git::current_branch()?;
git::commit(message.as_deref())?;
if !queue.is_tracked(¤t) {
return Ok(()); }
if git::queue_id_of("HEAD").is_none() {
git::amend_head_add_queue_id(&ident::new_id())?;
}
let report = requeue::propagate(&queue, ¤t)?;
finish_requeue(&report, ¤t);
Ok(())
}
pub fn amend() -> Result<()> {
git::ensure_repo()?;
std::env::set_var(git::GUARD_ENV, "1");
let current = git::current_branch()?;
if !git::staged_changes() {
bail!("nothing staged — `git add` the changes you want to fold into `{current}` first");
}
let before = git::rev_parse(¤t)?;
let reported_conflict = git::history_fixup("HEAD")?;
let after = git::rev_parse(¤t)?;
if reported_conflict || before == after {
bail!(
"amend could not fold your changes into `{current}`: doing so would conflict with a \
descendant branch, so nothing was changed (your staged changes are intact).\n\
Resolve the conflict on the descendant first, or use `git queue commit` to add a \
separate commit instead."
);
}
refresh_descendant_anchors(¤t)?;
println!("Amended the tip commit of `{current}` and updated all descendants.");
Ok(())
}
pub fn reword(commit: Option<String>) -> Result<()> {
git::ensure_repo()?;
std::env::set_var(git::GUARD_ENV, "1");
let current = git::current_branch()?;
let target = match commit {
Some(c) if c.starts_with("q-") => {
let queue = Queue::load()?;
if !queue.is_tracked(¤t) {
bail!("`{current}` is not a queue branch; ids can only name queued commits");
}
let line = queue.line_through(¤t)?;
resolve_queue_rev(&line, &c)?
}
Some(c) => c,
None => "HEAD".to_string(),
};
if git::history_reword(&target)? {
bail!("reword aborted (rewriting `{target}` would conflict with a descendant); nothing changed");
}
refresh_descendant_anchors(¤t)?;
println!("Reworded `{target}` and updated descendants of `{current}`.");
Ok(())
}
pub fn requeue(auto: bool) -> Result<()> {
git::ensure_repo()?;
std::env::set_var(git::GUARD_ENV, "1");
if git::current_branch().is_err() {
if let Some((original, top)) = meta::detached_state() {
return reintegrate_detached(auto, &original, &top);
}
if auto {
return Ok(());
}
bail!("HEAD is detached; check out a branch first");
}
let queue = Queue::load()?;
let current = git::current_branch()?;
meta::clear_detached_state(); if !queue.is_tracked(¤t) && current != queue.trunk {
if auto {
return Ok(());
}
bail!("`{current}` is not part of a queue");
}
let report = requeue::propagate(&queue, ¤t)?;
if auto && report.is_empty() && report.conflicted.is_empty() {
return Ok(());
}
finish_requeue(&report, ¤t);
Ok(())
}
fn finish_requeue(report: &requeue::Report, branch: &str) {
if !report.conflicted.is_empty() {
requeue::warn_conflicts(&report.conflicted);
} else if !report.is_empty() {
println!(
"Requeueed {} descendant branch(es) of `{branch}`.",
report.requeued.len()
);
}
}
fn refresh_descendant_anchors(branch: &str) -> Result<()> {
let queue = Queue::load()?;
for b in queue.descendants_topo(branch) {
if let Some(parent) = queue.parent_of(&b) {
let ptip = git::rev_parse(parent)?;
meta::set_parent_sha(&b, &ptip)?;
}
}
Ok(())
}
const HOOK_BEGIN: &str = "# >>> git-queue >>>";
const HOOK_END: &str = "# <<< git-queue <<<";
fn hook_snippet(only_amend: bool) -> String {
let gate = if only_amend {
"[ \"$1\" = \"amend\" ] || exit 0\n"
} else {
""
};
format!(
"{HOOK_BEGIN}\n\
[ -n \"$GIT_QUEUE_IN_REQUEUE\" ] && exit 0\n\
{gate}command -v git-queue >/dev/null 2>&1 && git-queue requeue --auto || true\n\
{HOOK_END}\n"
)
}
fn id_hook_snippet() -> String {
format!(
"{HOOK_BEGIN}\n\
command -v git-queue >/dev/null 2>&1 && git-queue add-queue-id \"$1\" || true\n\
{HOOK_END}\n"
)
}
fn hooks_install() -> Result<()> {
git::ensure_repo()?;
let dir = std::path::PathBuf::from(git::out(&["rev-parse", "--git-path", "hooks"])?);
std::fs::create_dir_all(&dir)?;
install_hook(&dir.join("post-commit"), &hook_snippet(false))?;
install_hook(&dir.join("post-rewrite"), &hook_snippet(true))?;
install_hook(&dir.join("commit-msg"), &id_hook_snippet())?;
println!("Installed git-queue hooks. Plain `git commit` and `git commit --amend` on a");
println!("queue branch will now auto-requeue descendants.");
Ok(())
}
fn hooks_uninstall() -> Result<()> {
git::ensure_repo()?;
let dir = std::path::PathBuf::from(git::out(&["rev-parse", "--git-path", "hooks"])?);
for name in ["post-commit", "post-rewrite", "commit-msg"] {
remove_hook(&dir.join(name))?;
}
println!("Removed git-queue hooks.");
Ok(())
}
fn install_hook(path: &std::path::Path, snippet: &str) -> Result<()> {
let contents = std::fs::read_to_string(path).unwrap_or_default();
if contents.contains(HOOK_BEGIN) {
return Ok(()); }
let new = if contents.trim().is_empty() {
format!("#!/bin/sh\n{snippet}")
} else {
format!("{}\n{snippet}", contents.trim_end())
};
std::fs::write(path, new)?;
make_executable(path)?;
Ok(())
}
fn remove_hook(path: &std::path::Path) -> Result<()> {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let (Some(start), Some(end)) = (contents.find(HOOK_BEGIN), contents.find(HOOK_END)) else {
return Ok(());
};
let after = end + HOOK_END.len();
let mut stripped = String::new();
stripped.push_str(&contents[..start]);
stripped.push_str(contents[after..].trim_start_matches('\n'));
if stripped
.lines()
.all(|l| l.trim().is_empty() || l.starts_with("#!"))
{
std::fs::remove_file(path)?;
} else {
std::fs::write(path, stripped)?;
}
Ok(())
}
#[cfg(unix)]
fn make_executable(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &std::path::Path) -> Result<()> {
Ok(())
}
fn build_entries(branches: &[String]) -> Result<Vec<Entry>> {
let mut entries = Vec::with_capacity(branches.len());
for b in branches {
let pr = meta::pr(b).map(|number| PrRef {
number,
url: String::new(),
state: "?".to_string(),
review: None,
});
entries.push(Entry {
branch: b.clone(),
pr,
conflicted: git::has_conflict_markers(b),
conflicts: Vec::new(),
commits: Vec::new(),
indent: 0,
});
}
Ok(entries)
}