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
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());
+ }
}