Use HTTP Basic auth for mirror push to GitHub
GitHub's git smart HTTP endpoint rejects `Authorization: Bearer <PAT>`
even though the REST API accepts it. The 401 made git fall back to
prompting for a username, which fails in a post-receive hook (no TTY).

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change mpxxpklkzvmulnzmsztuxtpnqvkxympk
commit 16ef4697ec540f44e7809cf9cacd2494077be655
author Alpha Chen <alpha@kejadlen.dev>
date
parent mlyopqsn
diff --git a/Cargo.lock b/Cargo.lock
index a5dd126..e6513d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1855,6 +1855,7 @@ version = "0.1.0"
 dependencies = [
  "assert_cmd",
  "axum",
+ "base64",
  "clap",
  "clap_complete",
  "fs-err",
diff --git a/Cargo.toml b/Cargo.toml
index 9f18de7..ae0fbd6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ path = "src/bin/quire/main.rs"
 
 [dependencies]
 axum = "*"
+base64 = "*"
 mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
 regex = "*"
 serde = { version = "*", features = ["derive"] }
diff --git a/src/quire.rs b/src/quire.rs
index 0a19669..c1c363a 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -159,10 +159,7 @@ impl Repo {
             .git(&args)
             .env("GIT_CONFIG_COUNT", "1")
             .env("GIT_CONFIG_KEY_0", "http.extraHeader")
-            .env(
-                "GIT_CONFIG_VALUE_0",
-                format!("Authorization: Bearer {token}"),
-            )
+            .env("GIT_CONFIG_VALUE_0", github_auth_header(token))
             .stdout(std::process::Stdio::null())
             .status()
             .map_err(crate::Error::Io)?;
@@ -219,6 +216,21 @@ impl Repo {
     }
 }
 
+/// Build the `Authorization` header value used to authenticate `git push`
+/// to GitHub over HTTPS.
+///
+/// GitHub's git smart HTTP endpoint (`/info/refs`, `git-receive-pack`)
+/// rejects `Authorization: Bearer <PAT>` with 401, even though the REST
+/// API accepts the same token via Bearer. Git then falls back to
+/// prompting for a username, which fails inside a post-receive hook
+/// because there's no TTY. HTTP Basic with any non-empty username and
+/// the token as the password is the documented form for git push.
+fn github_auth_header(token: &str) -> String {
+    use base64::{Engine, engine::general_purpose::STANDARD};
+    let encoded = STANDARD.encode(format!("x-access-token:{token}"));
+    format!("Authorization: Basic {encoded}")
+}
+
 /// Application runtime context.
 ///
 /// Carries configuration and provides resolved paths to repositories.
@@ -717,6 +729,24 @@ mod tests {
             .to_string()
     }
 
+    #[test]
+    fn github_auth_header_is_basic_with_x_access_token_username() {
+        use base64::{Engine, engine::general_purpose::STANDARD};
+
+        let header = super::github_auth_header("ghp_test");
+        let encoded = header
+            .strip_prefix("Authorization: Basic ")
+            .unwrap_or_else(|| panic!("missing Basic prefix: {header}"));
+        // libcurl rejects header values containing newlines, so the encoded
+        // form must not wrap.
+        assert!(
+            !encoded.contains('\n'),
+            "encoded header value must not wrap: {encoded:?}"
+        );
+        let decoded = STANDARD.decode(encoded).expect("valid base64");
+        assert_eq!(decoded, b"x-access-token:ghp_test");
+    }
+
     #[test]
     fn push_to_mirror_pushes_main_to_file_mirror() {
         let dir = tempfile::tempdir().expect("tempdir");