Unify commit ID rendering behind a CommitId type
Show jj change IDs in every commit badge — log, tree, file, repo home,
and parents — not just the commit detail page, by replacing the
scattered sha/change-id fields and helpers with one shared type.

Assisted-by: Claude Opus 4.8 via Claude Code
change ptukqsuynmtyknqrowmnnlronozvxvom
commit c8c1f0a315be68131f37c9780eac0b73e69d9cf3
author Alpha Chen <alpha@kejadlen.dev>
date
parent 259e133a
diff --git a/quire-server/src/quire/web/handlers/commit.rs b/quire-server/src/quire/web/handlers/commit.rs
index 3193af6..d1c8d45 100644
--- a/quire-server/src/quire/web/handlers/commit.rs
+++ b/quire-server/src/quire/web/handlers/commit.rs
@@ -5,7 +5,7 @@ use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
 
 use super::super::error::WebError;
-use super::super::templates::{CommitParent, CommitTemplate, nav_sections};
+use super::super::templates::{CommitId, CommitParent, CommitTemplate, nav_sections};
 use super::git::RepoView;
 use super::render;
 use crate::Quire;
@@ -21,6 +21,7 @@ pub async fn commit_view(
     let git_repo = quire.repo(&repo_name)?;
 
     let sha_clone = sha.clone();
+    let repo_d = repo_display.clone();
     let result = tokio::task::spawn_blocking(move || {
         let reader = RepoView::new(&git_repo);
 
@@ -58,18 +59,21 @@ pub async fn commit_view(
 
         let timestamp_ms: i64 = timestamp_str.parse().ok().map(|secs: i64| secs * 1000)?;
 
-        let parent_shars: Vec<String> = parents_str
-            .split_whitespace()
-            .filter(|s| !s.is_empty())
-            .map(|p| p.to_string())
-            .collect();
-
         let diff = reader
             .run(&["log", "-1", "--patch", "--format=", &full_sha])
             .unwrap_or_default();
 
         let change_id = reader.change_id(&full_sha).unwrap_or_default();
 
+        let parents: Vec<CommitParent> = parents_str
+            .split_whitespace()
+            .filter(|s| !s.is_empty())
+            .map(|p| CommitParent {
+                commit_url: format!("/{repo_d}/commits/{p}"),
+                id: CommitId::new(p.to_string(), reader.change_id(p)),
+            })
+            .collect();
+
         Some((
             sha,
             author,
@@ -77,45 +81,37 @@ pub async fn commit_view(
             timestamp_ms,
             subject,
             body,
-            parent_shars,
+            parents,
             diff,
             change_id,
         ))
     })
     .await?;
 
-    let Some((sha, author, email, timestamp_ms, subject, body, parent_shas, diff, change_id)) =
-        result
+    let Some((sha, author, email, timestamp_ms, subject, body, parents, diff, change_id)) = result
     else {
         return Ok(StatusCode::NOT_FOUND.into_response());
     };
 
-    let parents: Vec<CommitParent> = parent_shas
-        .into_iter()
-        .map(|p| CommitParent {
-            commit_url: format!("/{repo_display}/commits/{p}"),
-            sha: p,
-        })
-        .collect();
-
     let sha_short = if sha.len() >= 8 {
         sha[..8].to_string()
     } else {
         sha.clone()
     };
 
+    let nav_id = if change_id.is_empty() {
+        sha.as_str()
+    } else {
+        change_id.as_str()
+    };
     let tmpl = CommitTemplate {
         sections: nav_sections(&repo_display, "log", auth.is_authenticated()),
         repo: repo_display,
         crumbs: None,
         sha: sha.clone(),
         sha_short,
-        sha_head: sha[..sha.len().min(4)].to_string(),
-        sha_tail: if sha.len() > 4 {
-            sha[4..sha.len().min(8)].to_string()
-        } else {
-            String::new()
-        },
+        sha_head: nav_id[..nav_id.len().min(4)].to_string(),
+        sha_tail: nav_id[nav_id.len().min(4)..nav_id.len().min(8)].to_string(),
         author,
         email,
         date_relative: super::super::format::format_timestamp_relative(timestamp_ms),
diff --git a/quire-server/src/quire/web/handlers/git.rs b/quire-server/src/quire/web/handlers/git.rs
index 6f00382..169cadd 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, HeadInfo};
+use super::super::templates::{ChangeRow, CommitId, HeadInfo};
 use crate::quire::Repo;
 
 pub(super) type GitData = (Option<HeadInfo>, Option<String>, Vec<ChangeRow>);
@@ -41,8 +41,9 @@ impl<'a> RepoView<'a> {
         let sha = lines.next()?.to_string();
         let description = lines.next().unwrap_or("").to_string();
         let age = lines.next().unwrap_or("").to_string();
+        let change_id = self.change_id(&sha);
         Some(HeadInfo {
-            sha,
+            id: CommitId::new(sha, change_id),
             description,
             age,
             bookmark,
@@ -77,9 +78,10 @@ impl<'a> RepoView<'a> {
             .filter_map(|line| {
                 let mut parts = line.splitn(3, '|');
                 let sha = parts.next()?.to_string();
+                let change_id = self.change_id(&sha);
                 Some(ChangeRow {
                     commit_url: format!("/{repo}/commits/{sha}"),
-                    sha,
+                    id: CommitId::new(sha, change_id),
                     description: parts.next().unwrap_or("").to_string(),
                     age: parts.next().unwrap_or("").to_string(),
                 })
diff --git a/quire-server/src/quire/web/handlers/log_view.rs b/quire-server/src/quire/web/handlers/log_view.rs
index 09f2d7d..e8e11c2 100644
--- a/quire-server/src/quire/web/handlers/log_view.rs
+++ b/quire-server/src/quire/web/handlers/log_view.rs
@@ -4,7 +4,7 @@ use axum::extract::State;
 use axum::response::Response;
 
 use super::super::error::WebError;
-use super::super::templates::{LogTemplate, nav_sections};
+use super::super::templates::{CommitId, LogTemplate, nav_sections};
 use super::git::RepoView;
 use super::render;
 use crate::Quire;
@@ -20,16 +20,15 @@ pub async fn log_view(
     let git_repo = quire.repo(&repo_name)?;
 
     let repo_d = repo_display.clone();
-    let (changes, bookmark, sha_short) = tokio::task::spawn_blocking(move || {
+    let (changes, bookmark, head) = tokio::task::spawn_blocking(move || {
         let reader = RepoView::new(&git_repo);
         let changes = reader.recent_changes(&repo_d);
         let bookmark = reader
             .run(&["symbolic-ref", "--short", "HEAD"])
             .unwrap_or_else(|| "main".to_string());
-        let sha_short = reader
-            .run(&["rev-parse", "--short", "HEAD"])
-            .unwrap_or_else(|| "unknown".to_string());
-        (changes, bookmark, sha_short)
+        let head_sha = reader.run(&["rev-parse", "HEAD"]).unwrap_or_default();
+        let head_change_id = reader.change_id(&head_sha);
+        (changes, bookmark, CommitId::new(head_sha, head_change_id))
     })
     .await?;
 
@@ -39,7 +38,7 @@ pub async fn log_view(
         crumbs: None,
         changes,
         bookmark,
-        sha_short,
+        head,
     };
     Ok(render(&tmpl))
 }
diff --git a/quire-server/src/quire/web/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
index 055d587..a7127f7 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -8,7 +8,7 @@ use super::super::auth::Auth;
 use super::super::db;
 use super::super::error::WebError;
 use super::super::templates::{
-    Crumb, FileViewTemplate, TreeEntry, TreeEntryKind, TreeTemplate, nav_sections,
+    CommitId, Crumb, FileViewTemplate, TreeEntry, TreeEntryKind, TreeTemplate, nav_sections,
 };
 use super::git::RepoView;
 use super::render;
@@ -70,7 +70,7 @@ async fn tree_or_file_at_path(
                 crumbs: Some(crumbs),
                 path,
                 bookmark: tree_data.bookmark,
-                sha_short: tree_data.sha_short,
+                head: tree_data.head,
                 entries: tree_data.entries,
                 recent_changes,
             };
@@ -85,12 +85,12 @@ async fn tree_or_file_at_path(
                 crumbs: Some(crumbs),
                 path,
                 bookmark: file_data.bookmark,
-                sha_short: file_data.sha_short.clone(),
-                sha_head: file_data.sha_short[..file_data.sha_short.len().min(4)].to_string(),
-                sha_tail: file_data.sha_short[file_data.sha_short.len().min(4)..].to_string(),
-                last_change_sha: file_data.last_change_sha,
-                last_change_head: file_data.last_change_head,
-                last_change_tail: file_data.last_change_tail,
+                sha_short: file_data.head.sha_short().to_string(),
+                sha_head: file_data.head.head().to_string(),
+                sha_tail: file_data.head.tail().to_string(),
+                last_change_sha: file_data.last_change.sha.clone(),
+                last_change_head: file_data.last_change.head().to_string(),
+                last_change_tail: file_data.last_change.tail().to_string(),
                 last_change_msg: file_data.last_change_msg,
                 last_change_author: file_data.last_change_author,
                 last_change_age: file_data.last_change_age,
@@ -112,7 +112,7 @@ async fn tree_or_file_at_path(
 
 struct TreeData {
     bookmark: String,
-    sha_short: String,
+    head: CommitId,
     entries: Vec<TreeEntry>,
 }
 
@@ -134,9 +134,8 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
         .run(&["symbolic-ref", "--short", "HEAD"])
         .unwrap_or_else(|| "main".to_string());
 
-    let sha_short = reader
-        .run(&["rev-parse", "--short", "HEAD"])
-        .unwrap_or_else(|| "unknown".to_string());
+    let head_sha = reader.run(&["rev-parse", "HEAD"]).unwrap_or_default();
+    let head = CommitId::new(head_sha.clone(), reader.change_id(&head_sha));
 
     let ls_target = if path.is_empty() {
         "HEAD".to_string()
@@ -204,7 +203,7 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
 
     Some(TreeData {
         bookmark,
-        sha_short,
+        head,
         entries,
     })
 }
@@ -213,10 +212,8 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
 
 struct FileData {
     bookmark: String,
-    sha_short: String,
-    last_change_sha: String,
-    last_change_head: String,
-    last_change_tail: String,
+    head: CommitId,
+    last_change: CommitId,
     last_change_msg: String,
     last_change_author: String,
     last_change_age: String,
@@ -247,9 +244,8 @@ fn read_file_data(reader: &RepoView<'_>, path: &str) -> Option<FileData> {
         .run(&["symbolic-ref", "--short", "HEAD"])
         .unwrap_or_else(|| "main".to_string());
 
-    let sha_short = reader
-        .run(&["rev-parse", "--short", "HEAD"])
-        .unwrap_or_else(|| "unknown".to_string());
+    let head_sha = reader.run(&["rev-parse", "HEAD"]).unwrap_or_default();
+    let head = CommitId::new(head_sha.clone(), reader.change_id(&head_sha));
 
     let blob = reader.run(&["show", &format!("HEAD:{path}")])?;
 
@@ -259,10 +255,8 @@ fn read_file_data(reader: &RepoView<'_>, path: &str) -> Option<FileData> {
 
     let log = reader.run(&["log", "-1", "--format=%H|%s|%an|%cr", "HEAD", "--", path])?;
     let mut log_parts = log.splitn(4, '|');
-    let last_change_sha = log_parts.next()?.to_string();
-    let last_change_head = last_change_sha[..last_change_sha.len().min(4)].to_string();
-    let last_change_tail =
-        last_change_sha[last_change_sha.len().min(4)..last_change_sha.len().min(8)].to_string();
+    let last_sha = log_parts.next()?.to_string();
+    let last_change = CommitId::new(last_sha.clone(), reader.change_id(&last_sha));
     let last_change_msg = log_parts.next().unwrap_or("").to_string();
     let last_change_author = log_parts.next().unwrap_or("").to_string();
     let last_change_age = log_parts.next().unwrap_or("").to_string();
@@ -283,10 +277,8 @@ fn read_file_data(reader: &RepoView<'_>, path: &str) -> Option<FileData> {
 
     Some(FileData {
         bookmark,
-        sha_short,
-        last_change_sha,
-        last_change_head,
-        last_change_tail,
+        head,
+        last_change,
         last_change_msg,
         last_change_author,
         last_change_age,
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index 5ca2906..d193e9f 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -9,6 +9,49 @@ fn pkg_version() -> &'static str {
     env!("QUIRE_VERSION")
 }
 
+// ── Commit ID ─────────────────────────────────────────────────────────
+
+/// A git commit SHA paired with an optional jj change ID.
+///
+/// Display methods prefer the change ID when present, falling back to the
+/// git SHA. Used wherever a commit badge or reference is shown in templates.
+pub struct CommitId {
+    pub sha: String,
+    pub change_id: Option<String>,
+}
+
+impl CommitId {
+    pub fn new(sha: String, change_id: Option<String>) -> Self {
+        Self { sha, change_id }
+    }
+
+    fn display(&self) -> &str {
+        self.change_id.as_deref().unwrap_or(self.sha.as_str())
+    }
+
+    /// First 4 chars of the display ID (bold prefix in commit badges).
+    pub fn head(&self) -> &str {
+        let s = self.display();
+        &s[..s.len().min(4)]
+    }
+
+    /// Chars 4–8 of the display ID (dimmed suffix in commit badges).
+    pub fn tail(&self) -> &str {
+        let s = self.display();
+        let start = s.len().min(4);
+        &s[start..s.len().min(8)]
+    }
+
+    /// First 8 chars of the git SHA, used for tooltips and secondary display.
+    pub fn sha_short(&self) -> &str {
+        &self.sha[..self.sha.len().min(8)]
+    }
+
+    pub fn sha_full(&self) -> &str {
+        &self.sha
+    }
+}
+
 /// A section nav link in the repo tab bar.
 pub struct SectionLink {
     pub label: &'static str,
@@ -322,53 +365,19 @@ impl RepoHomeTemplate {
 }
 
 pub struct HeadInfo {
-    pub sha: String,
+    pub id: CommitId,
     pub description: String,
     pub age: String,
     pub bookmark: String,
 }
 
-impl HeadInfo {
-    pub fn change_head(&self) -> &str {
-        let end = self.sha.len().min(4);
-        &self.sha[..end]
-    }
-
-    pub fn change_tail(&self) -> &str {
-        let start = self.sha.len().min(4);
-        let end = self.sha.len().min(8);
-        &self.sha[start..end]
-    }
-
-    pub fn sha_short(&self) -> &str {
-        &self.sha[..self.sha.len().min(8)]
-    }
-}
-
 pub struct ChangeRow {
-    pub sha: String,
+    pub id: CommitId,
     pub description: String,
     pub age: String,
     pub commit_url: String,
 }
 
-impl ChangeRow {
-    pub fn change_head(&self) -> &str {
-        let end = self.sha.len().min(4);
-        &self.sha[..end]
-    }
-
-    pub fn change_tail(&self) -> &str {
-        let start = self.sha.len().min(4);
-        let end = self.sha.len().min(8);
-        &self.sha[start..end]
-    }
-
-    pub fn sha_full(&self) -> &str {
-        &self.sha
-    }
-}
-
 // ── Config ─────────────────────────────────────────────────────────
 
 #[derive(Template)]
@@ -402,8 +411,7 @@ pub struct TreeTemplate {
     pub path: String,
     /// Active bookmark name (e.g. "main").
     pub bookmark: String,
-    /// Short commit hash for HEAD.
-    pub sha_short: String,
+    pub head: CommitId,
     pub entries: Vec<TreeEntry>,
     pub recent_changes: Vec<ChangeRow>,
 }
@@ -442,15 +450,6 @@ impl TreeTemplate {
     pub fn file_count(&self) -> usize {
         self.entries.iter().filter(|e| e.is_file()).count()
     }
-
-    pub fn sha_head(&self) -> &str {
-        &self.sha_short[..self.sha_short.len().min(4)]
-    }
-
-    pub fn sha_tail(&self) -> &str {
-        let start = self.sha_short.len().min(4);
-        &self.sha_short[start..]
-    }
 }
 
 pub struct TreeEntry {
@@ -527,25 +526,10 @@ impl CommitTemplate {
 }
 
 pub struct CommitParent {
-    pub sha: String,
+    pub id: CommitId,
     pub commit_url: String,
 }
 
-impl CommitParent {
-    pub fn sha_full(&self) -> &str {
-        &self.sha
-    }
-
-    pub fn sha_head(&self) -> &str {
-        &self.sha[..self.sha.len().min(4)]
-    }
-
-    pub fn sha_tail(&self) -> &str {
-        let start = self.sha.len().min(4);
-        &self.sha[start..self.sha.len().min(8)]
-    }
-}
-
 // ── Commit log ────────────────────────────────────────────────────
 
 #[derive(Template)]
@@ -556,22 +540,13 @@ pub struct LogTemplate {
     pub sections: Vec<SectionLink>,
     pub changes: Vec<ChangeRow>,
     pub bookmark: String,
-    pub sha_short: String,
+    pub head: CommitId,
 }
 
 impl LogTemplate {
     pub fn version(&self) -> &'static str {
         pkg_version()
     }
-
-    pub fn sha_head(&self) -> &str {
-        &self.sha_short[..self.sha_short.len().min(4)]
-    }
-
-    pub fn sha_tail(&self) -> &str {
-        let start = self.sha_short.len().min(4);
-        &self.sha_short[start..]
-    }
 }
 
 // ── Error ──────────────────────────────────────────────────────────
diff --git a/quire-server/templates/commit.html b/quire-server/templates/commit.html
index ba03c68..21e613c 100644
--- a/quire-server/templates/commit.html
+++ b/quire-server/templates/commit.html
@@ -55,8 +55,8 @@
         <span class="commit-meta-key">parent{% if parents.len() > 1 %}s{% endif %}</span>
         <span class="commit-meta-val">
           {% for p in parents %}
-          <a class="change-id" href="{{ p.commit_url }}" title="commit {{ p.sha_full() }}">
-            <span class="change-head">{{ p.sha_head() }}</span><span class="change-tail">{{ p.sha_tail() }}</span>
+          <a class="change-id" href="{{ p.commit_url }}" title="commit {{ p.id.sha_full() }}">
+            <span class="change-head">{{ p.id.head() }}</span><span class="change-tail">{{ p.id.tail() }}</span>
           </a>
           {% endfor %}
         </span>
diff --git a/quire-server/templates/log.html b/quire-server/templates/log.html
index 62f9867..cb3c864 100644
--- a/quire-server/templates/log.html
+++ b/quire-server/templates/log.html
@@ -14,8 +14,8 @@
     <span class="bookmark-glyph">※</span>
     <span class="bookmark-name">{{ bookmark }}</span>
     <span class="repo-meta-sep">→</span>
-    <span class="change-id" title="{{ sha_short }}">
-      <span class="change-head">{{ self.sha_head() }}</span><span class="change-tail">{{ self.sha_tail() }}</span>
+    <span class="change-id" title="{{ head.sha_short() }}">
+      <span class="change-head">{{ head.head() }}</span><span class="change-tail">{{ head.tail() }}</span>
     </span>
   </span>
 </nav>
@@ -23,8 +23,8 @@
 <div class="log-body">
   {% for change in changes %}
   <div class="log-row">
-    <a class="log-sha" href="{{ change.commit_url }}" title="commit {{ change.sha_full() }}">
-      <span class="change-head">{{ change.change_head() }}</span><span class="change-tail">{{ change.change_tail() }}</span>
+    <a class="log-sha" href="{{ change.commit_url }}" title="commit {{ change.id.sha_full() }}">
+      <span class="change-head">{{ change.id.head() }}</span><span class="change-tail">{{ change.id.tail() }}</span>
     </a>
     <a class="log-subject" href="{{ change.commit_url }}">
       {% if change.description.is_empty() %}<span class="no-desc">(no description set)</span>{% else %}{{ change.description }}{% endif %}
diff --git a/quire-server/templates/repo_home.html b/quire-server/templates/repo_home.html
index a0bda89..c82cfcc 100644
--- a/quire-server/templates/repo_home.html
+++ b/quire-server/templates/repo_home.html
@@ -16,10 +16,10 @@
     <span class="bookmark-glyph">※</span>
     <span class="bookmark-name">{{ h.bookmark }}</span>
     <span class="repo-meta-sep">→</span>
-    <a class="change-id" href="/{{ repo }}/commits/{{ h.sha }}" title="commit {{ h.sha_short() }}">
-      <span class="change-head">{{ h.change_head() }}</span><span class="change-tail">{{ h.change_tail() }}</span>
+    <a class="change-id" href="/{{ repo }}/commits/{{ h.id.sha }}" title="commit {{ h.id.sha_short() }}">
+      <span class="change-head">{{ h.id.head() }}</span><span class="change-tail">{{ h.id.tail() }}</span>
     </a>
-    <span class="commit-id-secondary">{{ h.sha_short() }}</span>
+    <span class="commit-id-secondary">{{ h.id.sha_short() }}</span>
     <span class="repo-meta-dot">·</span>
     <span class="repo-position-age">{{ h.age }}</span>
     {% if !recent_runs.is_empty() %}
@@ -78,8 +78,8 @@
         {% for ch in recent_changes %}
         <div class="change-mini-row">
           <div class="change-mini-header">
-            <a class="change-id" href="{{ ch.commit_url }}" title="commit {{ ch.sha_full() }}">
-              <span class="change-head">{{ ch.change_head() }}</span><span class="change-tail">{{ ch.change_tail() }}</span>
+            <a class="change-id" href="{{ ch.commit_url }}" title="commit {{ ch.id.sha_full() }}">
+              <span class="change-head">{{ ch.id.head() }}</span><span class="change-tail">{{ ch.id.tail() }}</span>
             </a>
             <span class="change-mini-age">{{ ch.age }}</span>
           </div>
diff --git a/quire-server/templates/tree.html b/quire-server/templates/tree.html
index e0cce17..599aaa8 100644
--- a/quire-server/templates/tree.html
+++ b/quire-server/templates/tree.html
@@ -15,8 +15,8 @@
     <span class="bookmark-glyph">※</span>
     <span class="bookmark-name">{{ bookmark }}</span>
     <span class="repo-meta-sep">→</span>
-    <span class="change-id" title="{{ sha_short }}">
-      <span class="change-head">{{ self.sha_head() }}</span><span class="change-tail">{{ self.sha_tail() }}</span>
+    <span class="change-id" title="{{ head.sha_short() }}">
+      <span class="change-head">{{ head.head() }}</span><span class="change-tail">{{ head.tail() }}</span>
     </span>
 
   </span>
@@ -72,7 +72,7 @@
     <a class="tree-log-item" href="{{ change.commit_url }}">
       <div class="tree-log-subject">{{ change.description }}</div>
       <div class="tree-log-meta">
-        <span class="tree-log-sha">{{ change.change_head() }}{{ change.change_tail() }}</span>
+        <span class="tree-log-sha">{{ change.id.head() }}{{ change.id.tail() }}</span>
         <span class="tree-log-age">{{ change.age }}</span>
       </div>
     </a>