Repository navigation
stack push/list/sync/checkout fail with HTTP 400 behind a proxy that rejects the trailing slash on git/matching-refs #1899
Description
Summary
Stack discovery starts with this GitHub REST request (added in #1862):
GET /repos/{owner}/{repo}/git/matching-refs/heads/<stack-prefix>/
The path ends in /. GitHub accepts it. An HTTP proxy that normalizes request
paths can reject it. Claude Code cloud sessions send all GitHub REST calls
through such a proxy, and the proxy returns HTTP 400. Every stack command that
discovers a stack then stops, before it does any work:
stack push(also with--dry-run)stack liststack syncstack checkout
The fix is one line: request the path without the slash. The client already
filters the response on refs/heads/<prefix>/, so the result does not change.
Environment
| mergify-cli | 2026.10.1 and 2026.10.5 (latest release on 2026-10-09) |
| Where | Claude Code cloud session (Linux). GitHub REST calls go through the session's HTTPS proxy. |
| Code | crates/mergify-stack/src/remote_changes.rs, live_stack_branches, line 159 at tag 2026.10.3 (the last public source) |
Steps to reproduce
- Open a Claude Code cloud session on any GitHub repository. Any proxy that
normalizes paths and rejects a trailing/gives the same result. - Install mergify-cli 2026.10.5:
curl -fsSL https://raw.githubusercontent.com/Mergifyio/mergify-cli/main/install.sh \ | MERGIFY_VERSION=2026.10.5 sh - Make one commit on a new branch, then run:
mergify stack push --dry-run --draft --no-github-native --trunk origin/main
Before (2026.10.5)
mergify: HTTP 400 Bad Request: {"message":"Request path could not be canonicalized.", ...}
url: https://api.github.com/repos/<owner>/<repo>/git/matching-refs/heads/stack/<user>/<branch>/
The same request through the same proxy, sent with gh api:
| Request path | HTTP status |
|---|---|
repos/<owner>/<repo>/git/matching-refs/heads/stack/<user>/<branch>/ |
400 Request path could not be canonicalized. |
repos/<owner>/<repo>/git/matching-refs/heads/stack/<user>/<branch> |
200 |
repos/<owner>/<repo>/git/matching-refs/heads/<prefix>/ |
400 |
repos/<owner>/<repo>/git/matching-refs/heads/<prefix> |
200 |
After (2026.10.3 + patch, same session)
✓ read 0 pull request(s)
Stacked pull request plan:
* [to create] '<sha> - <commit 1 title> (draft) <stack/<user>/<branch>/<slug-1>--<id>>
* [to create] '<sha> - <commit 2 title> (draft) <stack/<user>/<branch>/<slug-2>--<id>>
Finished (dry-run mode).
Then a real stack push opened the 2 draft PRs. On the next push, discovery
found the same 2 PRs (✓ read 2 pull request(s)) and updated them.
Root cause
live_stack_branches adds the slash on purpose:
// The trailing slash matters: `matching-refs` is a plain string
// prefix match, so without it `stack/foo` would also list
// `stack/foobar/...`.
let path = format!("/repos/{user}/{repo}/git/matching-refs/heads/{stack_prefix}/");
let refs: Vec<GitRef> = client.get(&path).await?;
let ref_prefix = format!("refs/heads/{stack_prefix}/");The next lines already keep only the refs that start with ref_prefix
(refs/heads/<prefix>/). So the slash in the URL only repeats that filter on
the server, and it costs compatibility with proxies.
Proposed fix
Request matching-refs/heads/<prefix> without the slash, and keep the client
filter as it is. The response can then also contain sibling branches such as
<prefix>-foo/.... They are few, and the filter removes them.
I checked on a live repository that the filtered result without the slash is
the same as the result from git, which does not use the REST proxy:
| Prefix | Refs returned without slash | Refs kept by the refs/heads/<prefix>/ filter |
git ls-remote --heads origin 'refs/heads/<prefix>/*' |
|---|---|---|---|
| a prefix with 104 branches | 104 | 104 | 104 |
| a prefix with 3 branches | 3 | 3 | 3 |
a prefix with 1 sibling branch (<prefix>-map-…) and 0 children |
1 | 0 | 0 |
Tests
Results with the patch, on tag 2026.10.3:
| Check | Result |
|---|---|
cargo test -p mergify-stack |
368 passed, 0 failed |
cargo test -p mergify-cli --test stack_list --test stack_sync --test stack_push_github_native |
all passed |
cargo clippy -p mergify-stack -p mergify-cli --all-targets |
0 warnings |
The patch changes the 5 test mocks that pin the slash path. Without those
changes, 7 commands::checkout tests fail with a mock 404 on the path without
the slash.
Patch
Against tag 2026.10.3, the last public source. The CLI source is now private,
so please port it there.
remote_changes.rs: build the path without the slash, and correct the comment.- Test mocks:
remote_changes.rs,commands/checkout.rs,
tests/stack_list.rs,tests/stack_sync.rs,tests/stack_push_github_native.rs.
Related: the trailing slash is the first problem in a Claude Code cloud session.
After this fix, the second push of a stack fails on refs/notes/mergify/stack
(see #1900).
0001-matching-refs-no-trailing-slash.patch
diff --git a/crates/mergify-cli/tests/stack_list.rs b/crates/mergify-cli/tests/stack_list.rs
index 3b797cf..c5dcd7b 100644
--- a/crates/mergify-cli/tests/stack_list.rs
+++ b/crates/mergify-cli/tests/stack_list.rs
@@ -102,7 +102,7 @@ async fn list_reads_mergeable_from_the_pull_request_itself() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
- "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature/",
+ "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature",
))
.respond_with(
ResponseTemplate::new(200)
diff --git a/crates/mergify-cli/tests/stack_push_github_native.rs b/crates/mergify-cli/tests/stack_push_github_native.rs
index 2f0feff..7608372 100644
--- a/crates/mergify-cli/tests/stack_push_github_native.rs
+++ b/crates/mergify-cli/tests/stack_push_github_native.rs
@@ -133,7 +133,7 @@ async fn mount_discovery(server: &MockServer, pulls: &[serde_json::Value]) {
.collect();
Mock::given(method("GET"))
.and(wm_path(
- "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature/",
+ "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature",
))
.respond_with(ResponseTemplate::new(200).set_body_json(refs))
.mount(server)
diff --git a/crates/mergify-cli/tests/stack_sync.rs b/crates/mergify-cli/tests/stack_sync.rs
index a83781c..b28ffb2 100644
--- a/crates/mergify-cli/tests/stack_sync.rs
+++ b/crates/mergify-cli/tests/stack_sync.rs
@@ -120,7 +120,7 @@ async fn mount_discovery(server: &wiremock::MockServer, merged: Option<serde_jso
Mock::given(method("GET"))
.and(path(
- "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature/",
+ "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(server)
diff --git a/crates/mergify-stack/src/commands/checkout.rs b/crates/mergify-stack/src/commands/checkout.rs
index 0e1f3c8..c1f680f 100644
--- a/crates/mergify-stack/src/commands/checkout.rs
+++ b/crates/mergify-stack/src/commands/checkout.rs
@@ -798,7 +798,7 @@ mod tests {
.collect();
Mock::given(method("GET"))
.and(path(format!(
- "/repos/user/repo/git/matching-refs/heads/{stack_branch}/"
+ "/repos/user/repo/git/matching-refs/heads/{stack_branch}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(refs))
.expect(1)
diff --git a/crates/mergify-stack/src/remote_changes.rs b/crates/mergify-stack/src/remote_changes.rs
index bfe935e..d4649ab 100644
--- a/crates/mergify-stack/src/remote_changes.rs
+++ b/crates/mergify-stack/src/remote_changes.rs
@@ -153,10 +153,11 @@ async fn live_stack_branches(
repo: &str,
stack_prefix: &str,
) -> Result<Vec<String>, CliError> {
- // The trailing slash matters: `matching-refs` is a plain string
- // prefix match, so without it `stack/foo` would also list
- // `stack/foobar/...`.
- let path = format!("/repos/{user}/{repo}/git/matching-refs/heads/{stack_prefix}/");
+ // No trailing slash: some proxies reject a path that ends in `/`.
+ // `matching-refs` is a plain string prefix match, so `stack/foo`
+ // also lists `stack/foobar/...`; the `ref_prefix` filter below
+ // drops those.
+ let path = format!("/repos/{user}/{repo}/git/matching-refs/heads/{stack_prefix}");
let refs: Vec<GitRef> = client.get(&path).await?;
let ref_prefix = format!("refs/heads/{stack_prefix}/");
Ok(refs
@@ -479,7 +480,7 @@ mod tests {
async fn mount_refs(server: &MockServer, prefix: &str, names: &[&str]) {
Mock::given(method("GET"))
.and(path(format!(
- "/repos/user/repo/git/matching-refs/heads/{prefix}/"
+ "/repos/user/repo/git/matching-refs/heads/{prefix}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(refs(names)))
.expect(1)
Activity