Extract mirror push from ci into its own module
Mirror push and CI share only the trigger event; make them
sibling modules and let server.rs orchestrate.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change xpozwnqrosptmxytkpksqmwysusrpksz
commit 73e5f5afc9c071c5bf6eefdaca43b84f298d5f5c
author Alpha Chen <alpha@kejadlen.dev>
date
parent yulzkmys
diff --git a/src/ci.rs b/src/ci.rs
index 93618a0..463410b 100644
--- a/src/ci.rs
+++ b/src/ci.rs
@@ -462,8 +462,9 @@ pub fn validate(jobs: &[JobDef]) -> std::result::Result<(), Vec<ValidationError>
     }
 }
 
-/// Dispatch a push event: CI gating and mirror push.
-pub async fn dispatch_push(quire: &crate::Quire, event: &PushEvent) {
+/// Trigger CI for a push event: scan each updated ref for `.quire/ci.fnl`,
+/// create a run, and evaluate + validate it.
+pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
     let repo = match quire.repo(&event.repo) {
         Ok(r) if r.exists() => r,
         Ok(_) => {
@@ -476,19 +477,13 @@ pub async fn dispatch_push(quire: &crate::Quire, event: &PushEvent) {
         }
     };
 
-    dispatch_ci(&repo, event);
-    dispatch_mirror(quire, repo, event).await;
-}
-
-/// Check each updated ref for .quire/ci.fnl, create runs, and eval + validate.
-fn dispatch_ci(repo: &Repo, event: &PushEvent) {
     for push_ref in event.updated_refs() {
-        if let Err(e) = dispatch_ci_ref(repo, &event.pushed_at, push_ref) {
+        if let Err(e) = trigger_ref(&repo, &event.pushed_at, push_ref) {
             tracing::error!(
                 repo = %event.repo,
                 sha = %push_ref.new_sha,
                 %e,
-                "CI dispatch failed"
+                "CI trigger failed"
             );
         }
     }
@@ -497,8 +492,8 @@ fn dispatch_ci(repo: &Repo, event: &PushEvent) {
 /// Create and run CI for a single updated ref.
 ///
 /// Returns `Ok(())` if CI ran (regardless of whether the run succeeded
-/// or failed), or `Err` if dispatch itself failed.
-fn dispatch_ci_ref(repo: &Repo, pushed_at: &str, push_ref: &PushRef) -> Result<()> {
+/// or failed), or `Err` if the trigger itself failed.
+fn trigger_ref(repo: &Repo, pushed_at: &str, push_ref: &PushRef) -> Result<()> {
     if !repo.has_ci_fnl(&push_ref.new_sha) {
         return Ok(());
     }
@@ -549,64 +544,6 @@ fn eval_and_validate(repo: &Repo, sha: &str) -> Result<()> {
     Ok(())
 }
 
-/// Push updated refs to the configured mirror.
-async fn dispatch_mirror(quire: &crate::Quire, repo: Repo, event: &PushEvent) {
-    let config = match repo.config() {
-        Ok(c) => c,
-        Err(e) => {
-            tracing::error!(repo = %event.repo, %e, "failed to load repo config");
-            return;
-        }
-    };
-
-    let Some(mirror) = config.mirror else {
-        tracing::debug!(repo = %event.repo, "no mirror configured, skipping");
-        return;
-    };
-
-    let global_config = match quire.global_config() {
-        Ok(c) => c,
-        Err(e) => {
-            tracing::error!(%e, "failed to load global config for mirror push");
-            return;
-        }
-    };
-
-    let token = match global_config.github.token.reveal() {
-        Ok(t) => t.to_string(),
-        Err(e) => {
-            tracing::error!(%e, "failed to resolve GitHub token");
-            return;
-        }
-    };
-
-    // Only push refs that were actually updated (non-zero new sha).
-    let refs: Vec<String> = event
-        .updated_refs()
-        .iter()
-        .map(|r| r.r#ref.clone())
-        .collect();
-
-    if refs.is_empty() {
-        return;
-    }
-
-    let mirror_url = mirror.url.clone();
-    tracing::info!(url = %mirror.url, refs = ?refs, "pushing to mirror");
-
-    let result = tokio::task::spawn_blocking(move || {
-        let ref_slices: Vec<&str> = refs.iter().map(|s| s.as_str()).collect();
-        repo.push_to_mirror(&mirror, &token, &ref_slices)
-    })
-    .await;
-
-    match result {
-        Ok(Ok(())) => tracing::info!(url = %mirror_url, "mirror push complete"),
-        Ok(Err(e)) => tracing::error!(url = %mirror_url, %e, "mirror push failed"),
-        Err(e) => tracing::error!(url = %mirror_url, %e, "mirror push task panicked"),
-    }
-}
-
 /// Write a serializable value to a YAML file atomically (temp file + rename).
 fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
     let tmp_path = path.with_extension("yml.tmp");
diff --git a/src/lib.rs b/src/lib.rs
index d96db51..2c5bb55 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,6 +2,7 @@ pub mod ci;
 mod error;
 pub mod event;
 pub mod fennel;
+pub mod mirror;
 pub mod quire;
 pub mod secret;
 pub mod server;
diff --git a/src/mirror.rs b/src/mirror.rs
new file mode 100644
index 0000000..f52b7a2
--- /dev/null
+++ b/src/mirror.rs
@@ -0,0 +1,77 @@
+//! Mirror push: replicate ref updates to a configured remote.
+
+use crate::Quire;
+use crate::event::PushEvent;
+
+/// Push updated refs to the configured mirror, if one is set.
+///
+/// Loads repo and global config, resolves the GitHub token, and runs
+/// the libgit2 push on a blocking task. Errors are logged; the function
+/// itself is infallible from the caller's perspective.
+pub async fn push(quire: &Quire, event: &PushEvent) {
+    let repo = match quire.repo(&event.repo) {
+        Ok(r) if r.exists() => r,
+        Ok(_) => {
+            tracing::error!(repo = %event.repo, "repo not found on disk");
+            return;
+        }
+        Err(e) => {
+            tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
+            return;
+        }
+    };
+
+    let config = match repo.config() {
+        Ok(c) => c,
+        Err(e) => {
+            tracing::error!(repo = %event.repo, %e, "failed to load repo config");
+            return;
+        }
+    };
+
+    let Some(mirror) = config.mirror else {
+        tracing::debug!(repo = %event.repo, "no mirror configured, skipping");
+        return;
+    };
+
+    let global_config = match quire.global_config() {
+        Ok(c) => c,
+        Err(e) => {
+            tracing::error!(%e, "failed to load global config for mirror push");
+            return;
+        }
+    };
+
+    let token = match global_config.github.token.reveal() {
+        Ok(t) => t.to_string(),
+        Err(e) => {
+            tracing::error!(%e, "failed to resolve GitHub token");
+            return;
+        }
+    };
+
+    let refs: Vec<String> = event
+        .updated_refs()
+        .iter()
+        .map(|r| r.r#ref.clone())
+        .collect();
+
+    if refs.is_empty() {
+        return;
+    }
+
+    let mirror_url = mirror.url.clone();
+    tracing::info!(url = %mirror.url, refs = ?refs, "pushing to mirror");
+
+    let result = tokio::task::spawn_blocking(move || {
+        let ref_slices: Vec<&str> = refs.iter().map(|s| s.as_str()).collect();
+        repo.push_to_mirror(&mirror, &token, &ref_slices)
+    })
+    .await;
+
+    match result {
+        Ok(Ok(())) => tracing::info!(url = %mirror_url, "mirror push complete"),
+        Ok(Err(e)) => tracing::error!(url = %mirror_url, %e, "mirror push failed"),
+        Err(e) => tracing::error!(url = %mirror_url, %e, "mirror push task panicked"),
+    }
+}
diff --git a/src/server.rs b/src/server.rs
index 3bab935..d307f0a 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -8,6 +8,7 @@ use miette::{Context, IntoDiagnostic, Result};
 use crate::Quire;
 use crate::ci;
 use crate::event::PushEvent;
+use crate::mirror;
 
 async fn health() -> &'static str {
     "ok"
@@ -110,5 +111,6 @@ async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quir
         return;
     }
 
-    ci::dispatch_push(&quire, &event).await;
+    ci::trigger(&quire, &event);
+    mirror::push(&quire, &event).await;
 }