Skip to content

Commit dbe5a78

Browse files
committed
feat: git queue setup — interactive onboarding, subsuming protect and hooks
One command walks through every optional integration, asking permission per step: (1) the git hooks (auto-requeue + Stable-Commit-Id stamping), (2) the advisory merge-order gate, (3) the Claude Code skill — embedded in the binary and written to ~/.claude/skills when Claude Code is detected — and (4) a marker-delimited git-queue section in the repo's AGENTS.md when other agent CLIs (Codex, Cursor, Gemini, Windsurf, Copilot) are detected, AGENTS.md being the cross-agent convention they read. --yes accepts the repo-local steps non-interactively (agent integrations always ask); --undo reverses all four. protect and hooks are removed as commands (their machinery lives on inside setup).
1 parent ed5e03d commit dbe5a78

6 files changed

Lines changed: 233 additions & 76 deletions

File tree

README.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ To have a plain `git commit`/`git commit --amend` on a queue branch
6060
automatically requeue its descendants, install the git hooks (per repository):
6161

6262
```sh
63-
git queue hooks install # writes post-commit / post-rewrite hooks
64-
git queue hooks uninstall # remove them
63+
git queue setup # interactive: hooks, gate, agent skills
64+
git queue setup --undo # reverse it all
6565
```
6666

6767
Without the hooks, use `git queue commit` / `git queue amend` explicitly.
@@ -148,16 +148,15 @@ git queue create fix-a --base release-1.2 # base named explicitly
148148
| `git queue reword [<commit>]` | Rewrite a commit message and update descendants (defaults to HEAD). |
149149
| `git queue move <c>[..<c>] --new-parent <c>` | Move a commit (or an inclusive range) elsewhere in the queue — within one PR or across PRs. Commits can be named by revision or `Stable-Commit-Id` (unique prefix ok). Everything after the removal and insertion points is requeued; conflicts persist as markers. |
150150
| `git queue requeue` (`restack`) | Requeue the current branch's descendants onto its tip. |
151-
| `git queue hooks install` / `uninstall` | Make plain `git commit`/amend auto-requeue descendants and stamp `Stable-Commit-Id` trailers on new queue commits. |
152151
| `git queue sync [--no-push]` | Pull remote commits, drop branches whose PRs have merged (reparenting their children), requeue onto the latest base, push back with `--force-with-lease`, and reconcile the PRs of every published queue (open missing ones, revive closed ones, fix bases/titles/queue maps). |
153152
| `git queue submit [--draft]` (`push`) | Push the current queue line and open/update its numbered PRs (revives a child PR GitHub closed when its base was deleted). |
154153
| `git queue yank` | Close every open (non-merged) PR in the current queue. |
155-
| `git queue protect` | Enable merge-order signalling (a red/green commit status per PR) for this repo. |
154+
| `git queue setup [--yes] [--undo]` | Interactive, per-step setup: git hooks, merge-order gate, the Claude Code skill, and an `AGENTS.md` section for other agents. `--undo` reverses everything. |
156155
| `git queue doctor` | Report whether merge-order signalling is enabled (read-only). |
157156

158157
### Signalling merge order
159158

160-
Run `git queue protect` once to warn reviewers off merging PRs out of order. It
159+
Enable the gate via `git queue setup` to warn reviewers off merging PRs out of order. It
161160
sets a local flag (`queue.gate = status`) — no GitHub setup, workflow, ruleset,
162161
or admin rights required. With it on, `git queue submit` posts a
163162
`git-queue/merge-order` commit status on every open PR in the queue: green ✓
@@ -195,7 +194,7 @@ stable identity: a `Stable-Commit-Id:` trailer in the commit message (the same i
195194
Gerrit's `Change-Id`), minted once and carried by git itself through every
196195
rebase, cherry-pick, replay and amend.
197196

198-
- `git queue commit` stamps one automatically, and `git queue hooks install`
197+
- `git queue commit` stamps one automatically, and `git queue setup`
199198
adds a `commit-msg` hook so plain `git commit` on a queue branch does too.
200199
Commits off the queue are never touched.
201200
- `git queue track` offers to stamp the branch's existing commits (with a
@@ -260,7 +259,7 @@ what you're doing:
260259
Branches left holding persisted conflict markers are shown with `⚠ conflict
261260
markers` in `git queue status`. Search for `<<<<<<<`, resolve, and commit.
262261

263-
Prefer plain `git commit`? Run `git queue hooks install` and a post-commit /
262+
Prefer plain `git commit`? Run `git queue setup` and a post-commit /
264263
post-rewrite hook will call `git queue requeue` for you (the hooks are guarded
265264
against recursion and no-op off a queue).
266265

@@ -280,7 +279,7 @@ State lives in the repository's own git config (nothing outside git):
280279
| `branch.<n>.queueParentSha` | Parent tip when `<n>` was last based — the rebase anchor used by `sync`. |
281280
| `branch.<n>.queuePr` | Cached PR number. |
282281
| `branch.<n>.queueDescription` | PR body text set by `git queue describe`. |
283-
| `queue.gate` | `status` once `git queue protect` enables merge-order signalling. |
282+
| `queue.gate` | `status` once `git queue setup` enables merge-order signalling. |
284283

285284
(Conflict-marker state is **not** stored — `status` detects `<<<<<<<` in each
286285
branch's tip live, so it can never report a stale warning.)

index.html

Lines changed: 5 additions & 5 deletions
Large diffs are not rendered by default.

skills/using-git-queue/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ below own that propagation.
5252
| One big branch → a queue | `git queue split` | Editor assigns commits to branches |
5353
| Adopt an existing branch AND divide it | `git queue track --split` (add `--stamp-ids` to skip the prompt) | track + stamp + split editor in one step |
5454
| Abandon a queue's open PRs | `git queue yank` | Closes every open (non-merged) PR in the queue |
55-
| Stop PRs merging out of order | `git queue protect` | Enables merge-order statuses on each PR (one-time) |
55+
| Stop PRs merging out of order | `git queue setup` (enable the gate step) | Red/green merge-order status per PR (advisory) |
5656
| Check enforcement is on | `git queue doctor` | Read-only report of the gate status |
57-
| Give commits stable identity across rewrites | `git queue hooks install`, `git queue commit`, or `git queue track --stamp-ids` for existing commits | Stamps a `Stable-Commit-Id:` trailer; powers safe sync (no self-conflicts) and squash-merge detection |
57+
| Give commits stable identity across rewrites | `git queue setup`, `git queue commit`, or `git queue track --stamp-ids` for existing commits | Stamps a `Stable-Commit-Id:` trailer; powers safe sync (no self-conflicts) and squash-merge detection |
5858
| Move around the queue | `git queue up` / `down`, `git queue status` | Navigate / view |
5959
| See every commit in the queue with its Stable-Commit-Id | `git queue log` | Status tree + indented per-branch commits, newest first |
6060
| Address a commit by its id | `git queue move q-3zz02424 --new-parent <rev>`, `git queue reword q-…` | Any commit argument accepts a `Stable-Commit-Id` (unique prefix ok), as shown by `git queue log` |
@@ -91,7 +91,7 @@ markers.
9191

9292
## Enforcing merge order
9393

94-
To warn reviewers off merging PRs out of order, run `git queue protect` **once**
94+
To warn reviewers off merging PRs out of order, run `git queue setup` **once**
9595
per repo. It enables *status-based* gating (`queue.gate = status`) — no GitHub
9696
workflow, ruleset, or admin rights needed. Then `git queue submit` posts a
9797
`git-queue/merge-order` commit status on every open PR: green ✓ on the

src/commands.rs

Lines changed: 178 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,179 @@ fn apply_status_gate(entries: &[Entry]) -> Result<()> {
2424
Ok(())
2525
}
2626

27+
/// Ask a yes/no question on the TTY; `default_yes` decides bare Enter.
28+
fn confirm(question: &str, default_yes: bool) -> bool {
29+
use std::io::Write;
30+
print!("{question} [{}] ", if default_yes { "Y/n" } else { "y/N" });
31+
std::io::stdout().flush().ok();
32+
let mut answer = String::new();
33+
std::io::stdin().read_line(&mut answer).ok();
34+
match answer.trim().to_lowercase().as_str() {
35+
"" => default_yes,
36+
"y" | "yes" => true,
37+
_ => false,
38+
}
39+
}
40+
41+
/// `git queue setup [--yes] [--undo]` — interactive, per-step opt-in setup:
42+
/// the git hooks, the merge-order gate, the Claude Code skill, and (when
43+
/// other agents are detected) an AGENTS.md section. `--yes` accepts the two
44+
/// repo-local steps non-interactively; the integrations always ask.
45+
pub fn setup(yes: bool, undo: bool) -> Result<()> {
46+
git::ensure_repo()?;
47+
if undo {
48+
return setup_undo();
49+
}
50+
let tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
51+
if !tty && !yes {
52+
bail!("`git queue setup` is interactive; pass --yes to accept the repo-local steps");
53+
}
54+
let ask = |q: &str| -> bool {
55+
if tty {
56+
confirm(q, true)
57+
} else {
58+
yes
59+
}
60+
};
61+
62+
// 1. Hooks.
63+
if ask(
64+
"Install the git hooks? (plain `git commit`/`--amend` auto-requeue descendants;
65+
new queue commits get a Stable-Commit-Id)",
66+
) {
67+
hooks_install()?;
68+
}
69+
// 2. Merge-order gate.
70+
if ask(
71+
"Enable the merge-order gate? (submit/sync post a red/green commit status per PR
72+
so reviewers see which PR merges next)",
73+
) {
74+
meta::set_gate("status")?;
75+
println!("Merge-order gate enabled.");
76+
}
77+
// 3. Claude Code skill (only when Claude Code is present; interactive only).
78+
let home = std::env::var("HOME").unwrap_or_default();
79+
let claude_dir = std::path::Path::new(&home).join(".claude");
80+
if tty
81+
&& claude_dir.exists()
82+
&& confirm(
83+
"Claude Code detected. Install the `using-git-queue` skill so Claude drives
84+
git-queue correctly?",
85+
true,
86+
)
87+
{
88+
let dir = claude_dir.join("skills").join("using-git-queue");
89+
std::fs::create_dir_all(&dir)?;
90+
std::fs::write(dir.join("SKILL.md"), EMBEDDED_SKILL)?;
91+
println!("Installed {}.", dir.join("SKILL.md").display());
92+
}
93+
// 4. Other agents -> AGENTS.md (the cross-agent convention).
94+
let others: Vec<&str> = [
95+
("codex", ".codex"),
96+
("cursor", ".cursor"),
97+
("gemini", ".gemini"),
98+
("windsurf", ".windsurf"),
99+
("copilot", ".config/github-copilot"),
100+
]
101+
.iter()
102+
.filter(|(_, d)| std::path::Path::new(&home).join(d).exists())
103+
.map(|(n, _)| *n)
104+
.collect();
105+
if tty
106+
&& !others.is_empty()
107+
&& confirm(
108+
&format!(
109+
"Detected other agents ({}). Add a git-queue section to this repo's AGENTS.md
110+
(read by Codex, Cursor, Copilot and most agent CLIs)?",
111+
others.join(", ")
112+
),
113+
true,
114+
)
115+
{
116+
write_agents_md_section()?;
117+
}
118+
println!(
119+
"
120+
Setup done. `git queue doctor` reports the current state."
121+
);
122+
Ok(())
123+
}
124+
125+
fn setup_undo() -> Result<()> {
126+
hooks_uninstall()?;
127+
let _ = git::ok(&["config", "--local", "--unset", "queue.gate"]);
128+
println!("Merge-order gate disabled.");
129+
let home = std::env::var("HOME").unwrap_or_default();
130+
let skill = std::path::Path::new(&home).join(".claude/skills/using-git-queue/SKILL.md");
131+
if skill.exists() {
132+
std::fs::remove_file(&skill).ok();
133+
println!("Removed {}.", skill.display());
134+
}
135+
strip_agents_md_section()?;
136+
Ok(())
137+
}
138+
139+
const EMBEDDED_SKILL: &str = include_str!("../skills/using-git-queue/SKILL.md");
140+
const AGENTS_BEGIN: &str = "<!-- git-queue:agents:begin -->";
141+
const AGENTS_END: &str = "<!-- git-queue:agents:end -->";
142+
143+
/// Idempotently write a marker-delimited git-queue section into AGENTS.md.
144+
fn write_agents_md_section() -> Result<()> {
145+
let section = format!(
146+
"{AGENTS_BEGIN}
147+
## git-queue (PR queues)
148+
149+
This repo uses git-queue for stacked/queued PRs. Rules:
150+
151+
- See `git queue --help` (man page) for every command; `git queue status`/`log` show the queue.
152+
- Never hand-rebase a queue branch: use `git queue commit`/`amend`/`move`/`checkout`.
153+
- After changing history, run `git queue sync` to requeue, push (lease) and refresh PRs.
154+
- PRs merge front-first; never merge a PR whose `git-queue/merge-order` status is red.
155+
- Commits carry `Stable-Commit-Id:` trailers — preserve commit messages when rewriting.
156+
{AGENTS_END}
157+
"
158+
);
159+
let path = std::path::Path::new("AGENTS.md");
160+
let existing = std::fs::read_to_string(path).unwrap_or_default();
161+
let updated = match (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
162+
(Some(a), Some(b)) if b > a => format!(
163+
"{}{}{}",
164+
&existing[..a],
165+
section.trim_end(),
166+
&existing[b + AGENTS_END.len()..]
167+
),
168+
_ if existing.is_empty() => section,
169+
_ => format!(
170+
"{}
171+
{}",
172+
existing.trim_end(),
173+
section
174+
),
175+
};
176+
std::fs::write(path, updated)?;
177+
println!("Wrote the git-queue section of AGENTS.md.");
178+
Ok(())
179+
}
180+
181+
fn strip_agents_md_section() -> Result<()> {
182+
let path = std::path::Path::new("AGENTS.md");
183+
let Ok(existing) = std::fs::read_to_string(path) else {
184+
return Ok(());
185+
};
186+
if let (Some(a), Some(b)) = (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
187+
if b > a {
188+
let rest = format!("{}{}", &existing[..a], &existing[b + AGENTS_END.len()..]);
189+
if rest.trim().is_empty() {
190+
std::fs::remove_file(path).ok();
191+
} else {
192+
std::fs::write(path, rest)?;
193+
}
194+
println!("Removed the git-queue section of AGENTS.md.");
195+
}
196+
}
197+
Ok(())
198+
}
199+
27200
/// `git queue doctor` — report-only diagnostics for merge-order enforcement.
28201
pub fn doctor() -> Result<()> {
29202
git::ensure_repo()?;
@@ -40,10 +213,10 @@ pub fn doctor() -> Result<()> {
40213
println!(" on the ones behind it.");
41214
}
42215
Some(other) => {
43-
println!(" ! gate: unknown mode `{other}` \u{2014} run `git queue protect` to (re)enable status mode");
216+
println!(" ! gate: unknown mode `{other}` \u{2014} run `git queue setup` to (re)enable status mode");
44217
}
45218
None => {
46-
println!(" \u{2717} gate: not enabled \u{2014} run `git queue protect` to turn it on");
219+
println!(" \u{2717} gate: not enabled \u{2014} run `git queue setup` to turn it on");
47220
}
48221
}
49222

@@ -58,32 +231,6 @@ pub fn doctor() -> Result<()> {
58231
Ok(())
59232
}
60233

61-
/// `git queue protect` — enable status-based merge-order signalling.
62-
///
63-
/// A commit status is the one advisory GitHub mechanism that composes with
64-
/// base-chaining: it shows up in every PR's checks UI (red ✗ with "merge PR #N
65-
/// first") while leaving the PRs as normal, reviewable, non-draft PRs and
66-
/// never blocking pushes. Anything that actually disables the merge button
67-
/// requires base-branch rules, which also gate pushes to the queue branches.
68-
pub fn protect() -> Result<()> {
69-
git::ensure_repo()?;
70-
meta::set_gate("status")?;
71-
println!("Enabled status-based merge-order signalling for this repository.\n");
72-
println!(
73-
"`git queue submit` now posts a `{}` commit status on every open",
74-
render::GATE_CONTEXT
75-
);
76-
println!("PR in the queue: green \u{2713} on the front (mergeable) PR, red \u{2717} \u{201c}merge PR #N");
77-
println!(
78-
"first\u{201d} on every PR above it. As PRs land, `git queue sync` + `git queue submit`"
79-
);
80-
println!("promote the PR now at the front to green.\n");
81-
println!("No GitHub setup or admin rights needed, and PRs stay normal (no drafts). The gate");
82-
println!("is advisory: the red \u{2717} warns reviewers, but the merge button still works.");
83-
println!("Run `git queue submit` now to apply it.");
84-
Ok(())
85-
}
86-
87234
/// `git queue create <name> [--base <branch>]` — new branch queued after the
88235
/// current one (or on an explicit `--base` branch).
89236
pub fn create(name: &str, base: Option<&str>, queue_flag: Option<&str>) -> Result<()> {
@@ -1433,7 +1580,7 @@ fn report_line(line: &Line, outcome: &LinePrs, heading: &str) -> Result<()> {
14331580
let gate = meta::gate();
14341581
let gated = gate.as_deref() == Some("status");
14351582
if let Some(other) = gate.as_deref().filter(|g| *g != "status") {
1436-
eprintln!("warning: unknown queue.gate mode `{other}` — no merge gate applied; run `git queue protect` to enable status mode");
1583+
eprintln!("warning: unknown queue.gate mode `{other}` — no merge gate applied; run `git queue setup` to enable status mode");
14371584
}
14381585
if gated {
14391586
apply_status_gate(&outcome.entries)?;
@@ -2089,7 +2236,7 @@ fn id_hook_snippet() -> String {
20892236
}
20902237

20912238
/// `git queue hooks install` — make plain `git commit`/amend auto-requeue.
2092-
pub fn hooks_install() -> Result<()> {
2239+
fn hooks_install() -> Result<()> {
20932240
git::ensure_repo()?;
20942241
let dir = std::path::PathBuf::from(git::out(&["rev-parse", "--git-path", "hooks"])?);
20952242
std::fs::create_dir_all(&dir)?;
@@ -2102,7 +2249,7 @@ pub fn hooks_install() -> Result<()> {
21022249
}
21032250

21042251
/// `git queue hooks uninstall` — remove the git-queue hook blocks.
2105-
pub fn hooks_uninstall() -> Result<()> {
2252+
fn hooks_uninstall() -> Result<()> {
21062253
git::ensure_repo()?;
21072254
let dir = std::path::PathBuf::from(git::out(&["rev-parse", "--git-path", "hooks"])?);
21082255
for name in ["post-commit", "post-rewrite", "commit-msg"] {

0 commit comments

Comments
 (0)