web: link configured mirrors in the repo sidebar
Mirrors were only discoverable by reading .quire/config.fnl at the
latest push; the sidebar makes the remotes visible where the clone
target already is.

Assisted-by: GLM-5.3 via pi
change nkznkpknotkpmktrzvryxsunrqpmyxkr
commit 09e473c799586864c115b7dfebda559c76716fc4
author Alpha Chen <alpha@kejadlen.dev>
date
parent qtkwwkso
diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md
index 6c15487..c19a820 100644
--- a/docs/STYLE_GUIDE.md
+++ b/docs/STYLE_GUIDE.md
@@ -338,6 +338,21 @@ Mono, 12.5px:
 - Dot: 6×6, `border-radius: 3`. `ok` for pass, `bad` for fail.
 - Change column shows the first 4 chars of the change-id in `accent`.
 
+### Mirror list (sidebar)
+
+Mono, 12.5px, line-height 1.8, one link per configured mirror remote:
+
+```
+[github.com/u/r                              ]
+[gitea.example/u/r                           ]
+```
+
+- Label is the mirror URL with the scheme and trailing `.git` stripped;
+  the full URL is the `href` and hover `title`.
+- `ink` at rest, `accent` on hover, no underline.
+- Long labels ellipsize; the block is hidden entirely when no mirrors
+  are configured.
+
 ### ChangeList row
 
 Grid: `3px 74px 1fr auto auto`, gap 16, padding `6px 56px 6px 53px`.
diff --git a/quire-server/src/quire/web/handlers/git.rs b/quire-server/src/quire/web/handlers/git.rs
index 169cadd..6fc56c5 100644
--- a/quire-server/src/quire/web/handlers/git.rs
+++ b/quire-server/src/quire/web/handlers/git.rs
@@ -1,6 +1,6 @@
 //! Shared git-reading helpers used by multiple handlers.
 
-use super::super::templates::{ChangeRow, CommitId, HeadInfo};
+use super::super::templates::{ChangeRow, CommitId, HeadInfo, MirrorLink};
 use crate::quire::Repo;
 
 pub(super) type GitData = (Option<HeadInfo>, Option<String>, Vec<ChangeRow>);
@@ -31,6 +31,32 @@ impl<'a> RepoView<'a> {
         (self.head_info(), self.readme(), self.recent_changes(repo))
     }
 
+    /// Mirror remotes configured at HEAD, as sidebar links.
+    ///
+    /// An unreadable or unparsable config yields no links — a broken
+    /// config.fnl must not take the repo page down with it — but is logged
+    /// so operators see it.
+    pub(super) fn mirrors(&self, head: Option<&HeadInfo>) -> Vec<MirrorLink> {
+        let Some(head) = head else {
+            return Vec::new();
+        };
+        match self.repo.repo_config(&head.id.sha) {
+            Ok(config) => {
+                let mut links: Vec<MirrorLink> =
+                    config.mirrors.into_keys().map(MirrorLink::new).collect();
+                links.sort_by(|a, b| a.label.cmp(&b.label));
+                links
+            }
+            Err(error) => {
+                tracing::warn!(
+                    error = &error as &(dyn std::error::Error + 'static),
+                    "reading repo config for mirrors failed",
+                );
+                Vec::new()
+            }
+        }
+    }
+
     pub(super) fn head_info(&self) -> Option<HeadInfo> {
         let bookmark = self
             .run(&["symbolic-ref", "--short", "HEAD"])
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index 3f71675..4f5e25a 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -105,7 +105,11 @@ mod tests {
             git_dir(&["init", "-b", "main"]);
             git_dir(&["config", "user.email", "test@example.com"]);
             git_dir(&["config", "user.name", "Test"]);
-            fs_err::write(workdir.join(name), content).expect("write file");
+            let file = workdir.join(name);
+            if let Some(parent) = file.parent() {
+                fs_err::create_dir_all(parent).expect("mkdir parent");
+            }
+            fs_err::write(&file, content).expect("write file");
             git_dir(&["add", "."]);
             git_dir(&["commit", "-m", "seed"]);
             git_dir(&["push", bare.to_str().expect("utf-8 path"), "main"]);
@@ -222,6 +226,65 @@ mod tests {
         assert_eq!(resp.headers()[axum::http::header::LOCATION], "/example");
     }
 
+    #[tokio::test]
+    async fn repo_home_lists_configured_mirrors() {
+        let env = TestEnv::new();
+        env.populate_repo(
+            ".quire/config.fnl",
+            r#"{:mirrors {"https://github.com/u/r.git" :github-mirror
+                       "https://gitea.example/u/r.git" :gitea-mirror}}"#,
+        );
+        let resp = env
+            .app()
+            .oneshot(
+                Request::builder()
+                    .uri("/example")
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
+            .await
+            .expect("read body");
+        let html = String::from_utf8_lossy(&body);
+        assert!(
+            html.contains(r#"href="https://github.com/u/r.git""#),
+            "missing github mirror link"
+        );
+        assert!(
+            html.contains(">github.com/u/r</a>"),
+            "missing trimmed github mirror label"
+        );
+        assert!(
+            html.contains(">gitea.example/u/r</a>"),
+            "missing gitea mirror label"
+        );
+    }
+
+    #[tokio::test]
+    async fn repo_home_hides_mirrors_block_without_config() {
+        let env = TestEnv::new();
+        env.populate_repo("README.md", "# example\n");
+        let resp = env
+            .app()
+            .oneshot(
+                Request::builder()
+                    .uri("/example")
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
+            .await
+            .expect("read body");
+        let html = String::from_utf8_lossy(&body);
+        assert!(!html.contains(">Mirrors<"), "mirrors block rendered");
+    }
+
     #[tokio::test]
     async fn run_list_returns_ok_for_known_repo() {
         let env = TestEnv::new();
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index 6bfe078..6ba7092 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -51,8 +51,13 @@ pub async fn repo_home(
     };
 
     let rd = repo_display.clone();
-    let (head, readme_html, recent_changes) =
-        tokio::task::spawn_blocking(move || RepoView::new(&git_repo).read_all(&rd)).await?;
+    let (head, readme_html, recent_changes, mirrors) = tokio::task::spawn_blocking(move || {
+        let view = RepoView::new(&git_repo);
+        let (head, readme_html, recent_changes) = view.read_all(&rd);
+        let mirrors = view.mirrors(head.as_ref());
+        (head, readme_html, recent_changes, mirrors)
+    })
+    .await?;
 
     let tmpl = RepoHomeTemplate {
         sections: nav_sections(&repo_display, "overview", is_authed),
@@ -63,6 +68,7 @@ pub async fn repo_home(
         recent_runs,
         recent_changes,
         clone_host: quire.config.host.clone(),
+        mirrors,
     };
     Ok(render(&tmpl))
 }
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index e3b9aea..fc58143 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -343,6 +343,7 @@ pub struct RepoHomeTemplate {
     pub recent_changes: Vec<ChangeRow>,
     pub sections: Vec<SectionLink>,
     pub clone_host: String,
+    pub mirrors: Vec<MirrorLink>,
 }
 
 impl RepoHomeTemplate {
@@ -383,6 +384,28 @@ pub struct ChangeRow {
     pub commit_url: String,
 }
 
+/// A mirror remote linked from the repo sidebar.
+pub struct MirrorLink {
+    /// The configured push URL, used as the link target.
+    pub url: String,
+    /// Display form: scheme and trailing `.git` stripped.
+    pub label: String,
+}
+
+impl MirrorLink {
+    pub fn new(url: String) -> Self {
+        let stripped = url
+            .strip_prefix("https://")
+            .or_else(|| url.strip_prefix("http://"))
+            .unwrap_or(&url);
+        let label = stripped
+            .trim_end_matches(".git")
+            .trim_end_matches('/')
+            .to_owned();
+        Self { url, label }
+    }
+}
+
 // ── Config ─────────────────────────────────────────────────────────
 
 #[derive(Template)]
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index 8ce9285..536f916 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -487,6 +487,20 @@ html.dark {
   margin-bottom: 4px;
 }
 
+/* Mirror block */
+.mirror-list { line-height: 1.8; }
+
+.mirror-link {
+  display: block;
+  color: var(--ink);
+  text-decoration: none;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.mirror-link:hover { color: var(--accent); }
+
 /* Bookmark list */
 .bookmark-list { line-height: 1.8; }
 
diff --git a/quire-server/templates/repo_home.html b/quire-server/templates/repo_home.html
index 9fc8c2c..5ee40ce 100644
--- a/quire-server/templates/repo_home.html
+++ b/quire-server/templates/repo_home.html
@@ -53,6 +53,17 @@
       <div class="clone-url">{{ clone_url() }}</div>
     </div>
 
+    {% if !mirrors.is_empty() %}
+    <div class="side-block">
+      <div class="side-block-title">Mirrors</div>
+      <div class="mirror-list">
+        {% for m in mirrors %}
+        <a class="mirror-link" href="{{ m.url }}" title="{{ m.url }}">{{ m.label }}</a>
+        {% endfor %}
+      </div>
+    </div>
+    {% endif %}
+
     {% if !recent_runs.is_empty() %}
     <div class="side-block">
       <div class="side-block-title">CI</div>