Model per-ref mirroring as a Mirror struct
trigger drove mirroring through a free mirror_ref returning a MirrorError
enum that folded config-read and push failures together. Mirror::new now
performs the config read (Result<Mirror, _>) and Mirror::push attempts every
remote, returning the per-remote failures; the two failure modes are method
boundaries rather than enum variants. force_push and auth_header move onto
Mirror, and all logging stays in trigger.

Assisted-by: Claude Opus 4.8 via Claude Code
change zwrmuputnrunypsspzsrkuzsmonwoxrl
commit 820b84e574d9104ae3a4db45ed10676f91e4b0a8
author Alpha Chen <alpha@kejadlen.dev>
date
parent tqmtvrwq
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index f89ba6f..f60d951 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -24,13 +24,6 @@ pub enum PushError {
     Spawn(#[from] std::io::Error),
 }
 
-/// Why mirroring one ref failed: either its config couldn't be read (so no
-/// remotes were attempted), or one or more of its remotes rejected the push.
-enum MirrorError {
-    Config(crate::Error),
-    Pushes(Vec<PushFailure>),
-}
-
 /// One remote a ref failed to push to.
 struct PushFailure {
     url: String,
@@ -59,105 +52,120 @@ pub fn trigger(quire: &Quire, event: &PushEvent) {
             return;
         }
     };
-    let secrets = &quire.config.secrets;
     for push_ref in event.updated_refs() {
-        if let Err(error) = mirror_ref(&repo, secrets, push_ref) {
-            match error {
-                MirrorError::Config(source) => tracing::error!(
+        let mirror = match Mirror::new(quire, &repo, push_ref) {
+            Ok(mirror) => mirror,
+            Err(error) => {
+                tracing::error!(
                     repo = %event.repo,
                     ref_name = %push_ref.ref_name,
-                    error = &source as &(dyn std::error::Error + 'static),
+                    error = &error as &(dyn std::error::Error + 'static),
                     "mirror: reading config failed",
-                ),
-                MirrorError::Pushes(failures) => {
-                    for failure in failures {
-                        tracing::error!(
-                            repo = %event.repo,
-                            ref_name = %push_ref.ref_name,
-                            mirror_url = %failure.url,
-                            error = &failure.cause as &(dyn std::error::Error + 'static),
-                            "mirror: push failed",
-                        );
-                    }
-                }
+                );
+                continue;
+            }
+        };
+        if let Err(failures) = mirror.push() {
+            for failure in failures {
+                tracing::error!(
+                    repo = %event.repo,
+                    ref_name = %push_ref.ref_name,
+                    mirror_url = %failure.url,
+                    error = &failure.cause as &(dyn std::error::Error + 'static),
+                    "mirror: push failed",
+                );
             }
         }
     }
 }
 
-/// Mirror one updated ref to every remote configured at its new SHA. Attempts
-/// every remote, returning the config-read failure (nothing attempted) or the
-/// failures from each remote that rejected the push. `Ok` if all succeeded; a
-/// ref with no mirrors is a no-op.
-fn mirror_ref(
-    repo: &Repo,
-    secrets: &HashMap<String, SecretString>,
-    push_ref: &PushRef,
-) -> Result<(), MirrorError> {
-    let config = repo
-        .repo_config(&push_ref.new_sha)
-        .map_err(MirrorError::Config)?;
-    let mut failures = Vec::new();
-    for (url, secret) in config.mirrors {
-        if let Err(cause) = mirror_push(repo, secrets, &push_ref.ref_name, &url, &secret) {
-            failures.push(PushFailure { url, cause });
-        }
-    }
-    if failures.is_empty() {
-        Ok(())
-    } else {
-        Err(MirrorError::Pushes(failures))
-    }
+/// One updated ref's mirroring plan: the remotes to push it to, plus the repo
+/// and secrets needed to authenticate.
+struct Mirror<'a> {
+    repo: &'a Repo,
+    secrets: &'a HashMap<String, SecretString>,
+    ref_name: &'a str,
+    mirrors: HashMap<String, String>,
 }
 
-/// Push one ref to one mirror remote, reporting why the push failed.
-fn mirror_push(
-    repo: &Repo,
-    secrets: &HashMap<String, SecretString>,
-    ref_name: &str,
-    url: &str,
-    secret: &str,
-) -> Result<(), PushError> {
-    let token = secrets
-        .get(secret)
-        .ok_or_else(|| quire_core::secret::Error::UnknownSecret(secret.to_owned()))?
-        .reveal()?;
-
-    // The `+` prefix lets the remote accept rewrites: if the source branch
-    // was rewritten locally before the mirror ran, the mirror still applies.
-    let refspec = format!("+{r}:{r}", r = ref_name);
-
-    // Pass the auth token via git config env vars so it never appears in argv.
-    let out = repo
-        .git(&["push", "--porcelain", url, &refspec])
-        .env("GIT_CONFIG_COUNT", "1")
-        .env("GIT_CONFIG_KEY_0", "http.extraHeader")
-        .env("GIT_CONFIG_VALUE_0", auth_header(token))
-        .stdout(std::process::Stdio::piped())
-        .stderr(std::process::Stdio::piped())
-        .output()?;
-
-    if !out.status.success() {
-        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
-        return Err(PushError::Rejected(stderr));
+impl<'a> Mirror<'a> {
+    /// Read the ref's config and build its mirroring plan, failing if the
+    /// config can't be read.
+    fn new(quire: &'a Quire, repo: &'a Repo, push_ref: &'a PushRef) -> Result<Self, crate::Error> {
+        let secrets = &quire.config.secrets;
+        let repo_config = repo.repo_config(&push_ref.new_sha)?;
+        Ok(Self {
+            repo,
+            secrets,
+            ref_name: &push_ref.ref_name,
+            mirrors: repo_config.mirrors,
+        })
     }
 
-    tracing::info!(ref_name = %ref_name, mirror_url = %url, "mirror: push succeeded");
-    Ok(())
-}
+    /// Push the ref to every configured remote, collecting one failure per
+    /// remote that rejected it. `Ok` only if every push succeeded.
+    fn push(&self) -> Result<(), Vec<PushFailure>> {
+        let mut failures = Vec::new();
+        for (url, secret) in &self.mirrors {
+            if let Err(cause) = self.force_push(url, secret) {
+                failures.push(PushFailure {
+                    url: url.clone(),
+                    cause,
+                });
+            }
+        }
+        if failures.is_empty() {
+            Ok(())
+        } else {
+            Err(failures)
+        }
+    }
 
-/// Build the HTTP Basic `Authorization` header line for a push token.
-///
-/// Uses the `token:x-oauth-basic` form, which GitHub and Gitea both accept
-/// for git-over-HTTPS push with a personal access token.
-fn auth_header(token: &str) -> String {
-    format!(
-        "Authorization: Basic {}",
-        base64::Engine::encode(
-            &base64::engine::general_purpose::STANDARD,
-            format!("{token}:x-oauth-basic"),
+    /// Force-push the ref to one remote, reporting why the push failed.
+    fn force_push(&self, url: &str, secret: &str) -> Result<(), PushError> {
+        let token = self
+            .secrets
+            .get(secret)
+            .ok_or_else(|| quire_core::secret::Error::UnknownSecret(secret.to_owned()))?
+            .reveal()?;
+
+        // The `+` prefix lets the remote accept rewrites: if the source branch
+        // was rewritten locally before the mirror ran, the mirror still applies.
+        let refspec = format!("+{r}:{r}", r = self.ref_name);
+
+        // Pass the auth token via git config env vars so it never appears in argv.
+        let out = self
+            .repo
+            .git(&["push", "--porcelain", url, &refspec])
+            .env("GIT_CONFIG_COUNT", "1")
+            .env("GIT_CONFIG_KEY_0", "http.extraHeader")
+            .env("GIT_CONFIG_VALUE_0", Self::auth_header(token))
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .output()?;
+
+        if !out.status.success() {
+            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
+            return Err(PushError::Rejected(stderr));
+        }
+
+        tracing::info!(ref_name = %self.ref_name, mirror_url = %url, "mirror: push succeeded");
+        Ok(())
+    }
+
+    /// Build the HTTP Basic `Authorization` header line for a push token.
+    ///
+    /// Uses the `token:x-oauth-basic` form, which GitHub and Gitea both accept
+    /// for git-over-HTTPS push with a personal access token.
+    fn auth_header(token: &str) -> String {
+        format!(
+            "Authorization: Basic {}",
+            base64::Engine::encode(
+                &base64::engine::general_purpose::STANDARD,
+                format!("{token}:x-oauth-basic"),
+            )
         )
-    )
+    }
 }
 
 #[cfg(test)]
@@ -168,7 +176,7 @@ mod tests {
     fn auth_header_encodes_token_as_oauth_basic() {
         // base64("tok:x-oauth-basic") == "dG9rOngtb2F1dGgtYmFzaWM=".
         assert_eq!(
-            auth_header("tok"),
+            Mirror::auth_header("tok"),
             "Authorization: Basic dG9rOngtb2F1dGgtYmFzaWM="
         );
     }