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
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="
);
}