mirror: add manual push command for a single ref
Mirroring only ran on push events, leaving no way to re-sync a mirror
that drifted or was added to config after the last push.

Assisted-by: Claude Opus 4.8 via Claude Code
change myxtrylupxqpnpqsotnxuqwwyyyolmmk
commit 8878d36ea16e6b779a29f4e812c15d28751042c1
author Alpha Chen <alpha@kejadlen.dev>
date
parent wwwpqulu
diff --git a/quire-server/src/bin/quire/commands/mirror.rs b/quire-server/src/bin/quire/commands/mirror.rs
new file mode 100644
index 0000000..0e862c2
--- /dev/null
+++ b/quire-server/src/bin/quire/commands/mirror.rs
@@ -0,0 +1,36 @@
+use miette::{Result, bail};
+
+use quire::Quire;
+use quire::mirror;
+
+/// Push a single ref to the repo's configured mirrors, reporting the outcome.
+///
+/// Exits non-zero if any mirror rejects the ref, even when others accept it.
+pub async fn push(quire: &Quire, repo: &str, ref_name: &str) -> Result<()> {
+    let outcome = mirror::push_ref(quire, repo, ref_name)?;
+
+    if outcome.pushed.is_empty() && outcome.failed.is_empty() {
+        println!("no mirrors configured for {repo}");
+        return Ok(());
+    }
+
+    for url in &outcome.pushed {
+        println!("pushed {ref_name} to {url}");
+    }
+
+    if !outcome.failed.is_empty() {
+        for failure in &outcome.failed {
+            eprintln!(
+                "failed to push {ref_name} to {}: {}",
+                failure.url, failure.cause
+            );
+        }
+        let total = outcome.pushed.len() + outcome.failed.len();
+        bail!(
+            "{} of {total} mirror(s) rejected {ref_name}",
+            outcome.failed.len()
+        );
+    }
+
+    Ok(())
+}
diff --git a/quire-server/src/bin/quire/commands/mod.rs b/quire-server/src/bin/quire/commands/mod.rs
index 5219c76..195810d 100644
--- a/quire-server/src/bin/quire/commands/mod.rs
+++ b/quire-server/src/bin/quire/commands/mod.rs
@@ -3,5 +3,6 @@ pub mod ci;
 pub mod dev;
 pub mod exec;
 pub mod hook;
+pub mod mirror;
 pub mod repo;
 pub mod serve;
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 5d29ec0..fecee04 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -68,6 +68,12 @@ enum Commands {
         #[command(subcommand)]
         command: CiCommands,
     },
+
+    /// Mirror operations.
+    Mirror {
+        #[command(subcommand)]
+        command: MirrorCommands,
+    },
 }
 
 #[derive(Subcommand)]
@@ -105,6 +111,17 @@ enum CiCommands {
     },
 }
 
+#[derive(Subcommand)]
+enum MirrorCommands {
+    /// Push a single ref to the repo's configured mirrors.
+    Push {
+        /// Repository name (e.g. foo.git or work/foo.git).
+        repo: String,
+        /// Full ref name to mirror (e.g. refs/heads/main).
+        r#ref: String,
+    },
+}
+
 #[tokio::main]
 async fn main() -> Result<()> {
     let cli = Cli::parse();
@@ -175,6 +192,11 @@ async fn main() -> Result<()> {
             CiCommands::Validate { sha } => commands::ci::validate(sha.as_deref()).await?,
             CiCommands::Run { sha } => commands::ci::run(sha.as_deref()).await?,
         },
+        Commands::Mirror { command } => match command {
+            MirrorCommands::Push { repo, r#ref } => {
+                commands::mirror::push(&quire, &repo, &r#ref).await?
+            }
+        },
     }
 
     Ok(())
diff --git a/quire-server/src/error.rs b/quire-server/src/error.rs
index c5fbbf2..08e8173 100644
--- a/quire-server/src/error.rs
+++ b/quire-server/src/error.rs
@@ -15,6 +15,9 @@ pub enum Error {
     #[error("repository not found: {0}")]
     RepoNotFound(String),
 
+    #[error("ref not found in {repo}: {ref_name}")]
+    RefNotFound { repo: String, ref_name: String },
+
     #[error(transparent)]
     #[diagnostic(transparent)]
     Fennel(#[from] Box<FennelError>),
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index 27f6a53..f9b37eb 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -25,9 +25,21 @@ pub enum PushError {
 }
 
 /// One remote a ref failed to push to.
-struct PushFailure {
-    url: String,
-    cause: PushError,
+#[derive(Debug)]
+pub struct PushFailure {
+    pub url: String,
+    pub cause: PushError,
+}
+
+/// Outcome of a manual mirror push for one ref: which remotes took it and
+/// which rejected it. Unlike [`trigger`], failures are returned to the
+/// caller rather than logged, so an operator running the command sees them.
+#[derive(Debug)]
+pub struct PushOutcome {
+    /// Mirror URLs that accepted the ref.
+    pub pushed: Vec<String>,
+    /// Mirror URLs that rejected the ref, each with its cause.
+    pub failed: Vec<PushFailure>,
 }
 
 /// Mirror updated refs to every remote configured for the repo.
@@ -79,6 +91,72 @@ pub fn trigger(quire: &Quire, event: &PushEvent) {
     }
 }
 
+/// Mirror a single named ref to every remote configured for the repo.
+///
+/// Resolves `ref_name` (a full ref, e.g. `refs/heads/main`) to its current
+/// commit, reads the `:mirrors` table from `.quire/config.fnl` at that SHA,
+/// and pushes the ref non-force to each target — the same machinery as the
+/// push-event path in [`trigger`], driven by hand instead of a push.
+///
+/// The ref must exist; a missing ref is an error rather than a no-op, since
+/// an operator naming a ref expects it to be there. A repo with no mirrors
+/// configured yields an empty [`PushOutcome`].
+pub fn push_ref(
+    quire: &Quire,
+    repo_name: &str,
+    ref_name: &str,
+) -> Result<PushOutcome, crate::Error> {
+    let repo = quire.repo(repo_name)?;
+
+    let sha = resolve_ref(&repo, ref_name)?.ok_or_else(|| crate::Error::RefNotFound {
+        repo: repo_name.to_owned(),
+        ref_name: ref_name.to_owned(),
+    })?;
+
+    let push_ref = PushRef {
+        ref_name: ref_name.to_owned(),
+        old_sha: String::new(),
+        new_sha: sha,
+    };
+
+    let mirror = Mirror::new(quire, &repo, &push_ref)?;
+
+    match mirror.push_all() {
+        Ok(()) => Ok(PushOutcome {
+            pushed: mirror.mirrors.keys().cloned().collect(),
+            failed: Vec::new(),
+        }),
+        Err(failed) => {
+            let pushed = mirror
+                .mirrors
+                .keys()
+                .filter(|url| !failed.iter().any(|f| &f.url == *url))
+                .cloned()
+                .collect();
+            Ok(PushOutcome { pushed, failed })
+        }
+    }
+}
+
+/// Resolve a ref to its current commit SHA, or `None` if it doesn't exist.
+///
+/// `Err` is reserved for a failure to run git at all; a ref that simply
+/// isn't present is `Ok(None)`.
+fn resolve_ref(repo: &Repo, ref_name: &str) -> Result<Option<String>, std::io::Error> {
+    let out = repo
+        .git(&["rev-parse", "--verify", "--quiet", ref_name])
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::null())
+        .output()?;
+
+    if !out.status.success() {
+        return Ok(None);
+    }
+
+    let sha = String::from_utf8_lossy(&out.stdout).trim().to_owned();
+    Ok((!sha.is_empty()).then_some(sha))
+}
+
 /// One updated ref's mirroring plan: the remotes to push it to, plus the repo
 /// and secrets needed to authenticate.
 struct Mirror<'a> {
@@ -171,6 +249,8 @@ impl<'a> Mirror<'a> {
 
 #[cfg(test)]
 mod tests {
+    use std::path::Path;
+
     use super::*;
 
     #[test]
@@ -181,4 +261,71 @@ mod tests {
             "Authorization: Basic dG9rOngtb2F1dGgtYmFzaWM="
         );
     }
+
+    /// Run a git subcommand in `cwd` with hermetic env, panicking on failure.
+    fn git_in(cwd: &Path, args: &[&str]) {
+        let output = std::process::Command::new("git")
+            .args(args)
+            .current_dir(cwd)
+            .env("GIT_AUTHOR_NAME", "test")
+            .env("GIT_AUTHOR_EMAIL", "test@test")
+            .env("GIT_COMMITTER_NAME", "test")
+            .env("GIT_COMMITTER_EMAIL", "test@test")
+            .env("GIT_CONFIG_GLOBAL", "/dev/null")
+            .env("GIT_CONFIG_SYSTEM", "/dev/null")
+            .output()
+            .expect("git command");
+        assert!(output.status.success(), "git {args:?} failed");
+    }
+
+    /// Build a Quire whose `foo.git` bare repo has one commit on `main`,
+    /// optionally carrying a `.quire/config.fnl`. Returns the tempdir (kept
+    /// alive for the test's duration), the Quire, and the repo name.
+    fn quire_with_repo(config: Option<&str>) -> (tempfile::TempDir, Quire, String) {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let quire = Quire::load(dir.path().to_path_buf()).expect("load");
+        let name = "foo.git";
+        let bare = quire.repos_dir().join(name);
+        fs_err::create_dir_all(bare.parent().expect("parent")).expect("mkdir repos");
+        git_in(
+            dir.path(),
+            &["init", "--bare", "-b", "main", &bare.to_string_lossy()],
+        );
+
+        let work = tempfile::tempdir().expect("workdir");
+        git_in(work.path(), &["init", "-q", "-b", "main"]);
+        if let Some(cfg) = config {
+            let quire_dir = work.path().join(".quire");
+            fs_err::create_dir_all(&quire_dir).expect("mkdir .quire");
+            fs_err::write(quire_dir.join("config.fnl"), cfg).expect("write config");
+        } else {
+            fs_err::write(work.path().join("README"), "hi").expect("write readme");
+        }
+        git_in(work.path(), &["add", "."]);
+        git_in(work.path(), &["commit", "-q", "-m", "init"]);
+        git_in(
+            work.path(),
+            &["push", "-q", &bare.to_string_lossy(), "main"],
+        );
+
+        (dir, quire, name.to_string())
+    }
+
+    #[test]
+    fn push_ref_errors_when_ref_missing() {
+        let (_dir, quire, repo) = quire_with_repo(None);
+        let err = push_ref(&quire, &repo, "refs/heads/nope").expect_err("should error");
+        assert!(
+            matches!(err, crate::Error::RefNotFound { .. }),
+            "expected RefNotFound, got {err:?}"
+        );
+    }
+
+    #[test]
+    fn push_ref_is_empty_outcome_without_mirrors() {
+        let (_dir, quire, repo) = quire_with_repo(None);
+        let outcome = push_ref(&quire, &repo, "refs/heads/main").expect("should succeed");
+        assert!(outcome.pushed.is_empty());
+        assert!(outcome.failed.is_empty());
+    }
 }