Skip to content

stack push/list/sync/checkout fail with HTTP 400 behind a proxy that rejects the trailing slash on git/matching-refs #1899

Closed

Description

@xen0l

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 list
  • stack sync
  • stack 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

  1. Open a Claude Code cloud session on any GitHub repository. Any proxy that
    normalizes paths and rejects a trailing / gives the same result.
  2. Install mergify-cli 2026.10.5:
    curl -fsSL https://raw.githubusercontent.com/Mergifyio/mergify-cli/main/install.sh \
      | MERGIFY_VERSION=2026.10.5 sh
  3. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions