Stop force-pushing mirrored refs
Prefer leaving a diverged mirror untouched over silently overwriting
it; a rewritten ref now stalls until reconciled by hand.

Assisted-by: Claude Opus 4.8 via Claude Code
change vkuukurlvsklnoosqnmkywzvxswlxwtl
commit 47e979a2240e487b2ea66f7771d2afe1af35b63f
author Alpha Chen <alpha@kejadlen.dev>
date
parent zwrmuput
diff --git a/docs/config.md b/docs/config.md
index fc643c6..036f578 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -54,7 +54,7 @@ tree:
 
 | Key             | Type  | Required | Purpose                                                        |
 |-----------------|-------|----------|----------------------------------------------------------------|
-| `:mirrors`      | table | no       | Remotes to force-push every updated ref to, keyed by HTTPS URL. Each value names the global `:secrets` entry holding that remote's push token. Empty or absent disables mirroring. |
+| `:mirrors`      | table | no       | Remotes to push every updated ref to, keyed by HTTPS URL. Each value names the global `:secrets` entry holding that remote's push token. Non-fast-forward updates are rejected, not forced. Empty or absent disables mirroring. |
 
 Each remote authenticates with HTTP Basic `token:x-oauth-basic`, which
 GitHub and Gitea both accept for a personal access token. A remote whose
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index f60d951..27f6a53 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -65,7 +65,7 @@ pub fn trigger(quire: &Quire, event: &PushEvent) {
                 continue;
             }
         };
-        if let Err(failures) = mirror.push() {
+        if let Err(failures) = mirror.push_all() {
             for failure in failures {
                 tracing::error!(
                     repo = %event.repo,
@@ -104,10 +104,10 @@ impl<'a> Mirror<'a> {
 
     /// 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>> {
+    fn push_all(&self) -> Result<(), Vec<PushFailure>> {
         let mut failures = Vec::new();
         for (url, secret) in &self.mirrors {
-            if let Err(cause) = self.force_push(url, secret) {
+            if let Err(cause) = self.push_remote(url, secret) {
                 failures.push(PushFailure {
                     url: url.clone(),
                     cause,
@@ -121,17 +121,18 @@ impl<'a> Mirror<'a> {
         }
     }
 
-    /// Force-push the ref to one remote, reporting why the push failed.
-    fn force_push(&self, url: &str, secret: &str) -> Result<(), PushError> {
+    /// Push the ref to one remote, reporting why the push failed.
+    fn push_remote(&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);
+        // Plain (non-force) push: a non-fast-forward update — e.g. after the
+        // source branch was rewritten — is rejected rather than overwriting the
+        // mirror's ref. The mirror then stays put until reconciled by hand.
+        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