Skip to main content

max / makenotwork

server: mailmap, commit trailers, and annotated tag bodies P7a of the git meta-operations program. Three pieces of metadata git already stores and no forge renders. .mailmap now rewrites the author on the log, the commit page and blame. A bare repo has no worktree, so gitoxide reads HEAD:.mailmap, which is the only place a forge could find one. Loaded once per page rather than once per commit: an empty snapshot resolves every signature to itself, so a repo without one pays a failed tree lookup and nothing more. Commit trailers come out of the message body and render as a metadata list. gitoxide applies git's own rules for whether a last paragraph is a trailer block at all, so a message that merely ends in a colon keeps its line. full_message stays the message as committed; message_body is the prose the page shows. Annotated tag bodies get a Tags tab. The message on git tag -a is a release note the author already wrote, and peeling straight to the commit is how every forge loses it. Lightweight tags are listed and marked; a tag on a blob or tree is listed without a target link rather than dropped.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 23:38 UTC
Commit: bba538fecad4f706c0d602c3beeb43c05a00d4b2
Parent: c0306e3
13 files changed, +556 insertions, -44 deletions
@@ -3276,6 +3276,7 @@
3276 3276 "gix-ignore",
3277 3277 "gix-index",
3278 3278 "gix-lock",
3279 + "gix-mailmap",
3279 3280 "gix-object",
3280 3281 "gix-odb",
3281 3282 "gix-pack",
@@ -3635,6 +3636,18 @@
3635 3636 "thiserror 2.0.18",
3636 3637 ]
3637 3638
3639 + [[package]]
3640 + name = "gix-mailmap"
3641 + version = "0.33.2"
3642 + source = "registry+https://github.com/rust-lang/crates.io-index"
3643 + checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48"
3644 + dependencies = [
3645 + "bstr",
3646 + "gix-actor",
3647 + "gix-date",
3648 + "gix-error",
3649 + ]
3650 +
3638 3651 [[package]]
3639 3652 name = "gix-object"
3640 3653 version = "0.63.0"
@@ -10584,14 +10597,6 @@
10584 10597 "pkg-config",
10585 10598 ]
10586 10599
10587 - [[patch.unused]]
10588 - name = "synckit-client"
10589 - version = "0.8.0"
10590 -
10591 - [[patch.unused]]
10592 - name = "synckit-config"
10593 - version = "0.2.0"
10594 -
10595 10600 [[patch.unused]]
10596 10601 name = "kberg"
10597 10602 version = "0.1.0"
@@ -10599,3 +10604,11 @@
10599 10604 [[patch.unused]]
10600 10605 name = "painhours"
10601 10606 version = "0.1.0"
10607 +
10608 + [[patch.unused]]
10609 + name = "synckit-client"
10610 + version = "0.8.0"
10611 +
10612 + [[patch.unused]]
10613 + name = "synckit-config"
10614 + version = "0.2.0"
@@ -145,7 +145,7 @@
145 145 makeover = "2.4.1"
146 146
147 147 # Git source browser
148 - gix = { version = "0.86", default-features = false, features = ["blame", "revision", "sha1", "max-performance-safe"] }
148 + gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
149 149 syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }
150 150 regex = "1"
151 151 semver = "1"
@@ -7165,6 +7165,43 @@
7165 7165 .git-commit-detail-meta .git-commit-oid { font-family: var(--font-mono); font-size: var(--text-fine); word-break: break-all; }
7166 7166 .git-commit-parents a { font-family: var(--font-mono); color: var(--content); text-decoration: none; }
7167 7167 .git-commit-parents a:hover { text-decoration: underline; }
7168 + /* Commit trailers, lifted out of the message body and shown as what they are */
7169 + .git-commit-trailers {
7170 + display: grid;
7171 + grid-template-columns: max-content 1fr;
7172 + gap: var(--gap-bound) var(--gap-section);
7173 + font-size: var(--text-note);
7174 + margin-bottom: var(--gap-section);
7175 + }
7176 + .git-commit-trailers dt { opacity: 0.6; }
7177 + .git-commit-trailers dd { margin: 0; font-family: var(--font-mono); font-size: var(--text-fine); }
7178 + /* Tags, with the annotation an annotated tag carries */
7179 + .git-tag-list { list-style: none; }
7180 + .git-tag { padding: var(--gap-section) 0; border-bottom: 1px solid var(--border); }
7181 + .git-tag:last-child { border-bottom: none; }
7182 + .git-tag-name { font-size: var(--text-note); font-weight: 600; margin: 0 0 var(--gap-bound); }
7183 + .git-tag-name a { color: var(--content); text-decoration: none; }
7184 + .git-tag-name a:hover { text-decoration: underline; }
7185 + .git-tag-lightweight { font-weight: 400; font-size: var(--text-fine); opacity: 0.5; }
7186 + .git-tag-message {
7187 + font-family: var(--font-mono);
7188 + font-size: var(--text-note);
7189 + white-space: pre-wrap;
7190 + line-height: 1.5;
7191 + padding: var(--gap-peer) var(--gap-section);
7192 + border-left: 2px solid var(--border);
7193 + margin-bottom: var(--gap-bound);
7194 + }
7195 + .git-tag-meta {
7196 + font-family: var(--font-mono);
7197 + font-size: var(--text-fine);
7198 + opacity: 0.5;
7199 + display: flex;
7200 + gap: var(--gap-section);
7201 + flex-wrap: wrap;
7202 + }
7203 + .git-tag-meta a { color: var(--content); text-decoration: none; }
7204 + .git-tag-meta a:hover { text-decoration: underline; }
7168 7205 /* Git notes (refs/notes/*) shown against the object they annotate */
7169 7206 .git-notes { margin-bottom: var(--gap-pane); }
7170 7207 .git-note {
@@ -12,8 +12,8 @@
12 12 };
13 13
14 14 use super::{
15 - BlameLine, CommitDetail, CommitInfo, DiffFile, DiffHunk, DiffLine, DiffStatus, GitError,
16 - ParentRef,
15 + BlameLine, CommitDetail, CommitInfo, CommitTrailer, DiffFile, DiffHunk, DiffLine, DiffStatus,
16 + GitError, ParentRef,
17 17 };
18 18
19 19 /// Newest-commit-first history order, matching what the log pages have always
@@ -25,10 +25,40 @@
25 25 oid[..7.min(oid.len())].to_string()
26 26 }
27 27
28 + /// The repository's `.mailmap`, or an empty one where there is none.
29 + ///
30 + /// Loaded once per page rather than once per commit: for a bare repo gitoxide
31 + /// reads `HEAD:.mailmap`, which is a commit and a tree lookup, and a log page
32 + /// asking per row would pay that fifty times over. An empty snapshot resolves
33 + /// every signature to itself, so the no-mailmap repo costs one failed tree
34 + /// lookup and nothing else.
35 + fn mailmap_of(repo: &Repository) -> gix::mailmap::Snapshot {
36 + repo.open_mailmap()
37 + }
38 +
39 + /// An identity after `.mailmap` rewriting, as `(name, email)`.
40 + ///
41 + /// Every author shown by the log, the commit page and blame goes through here,
42 + /// so a repo that has consolidated somebody's four addresses shows one person
43 + /// on every surface rather than on whichever ones remembered to ask.
44 + fn resolved_identity(
45 + mailmap: &gix::mailmap::Snapshot,
46 + signature: Option<gix::actor::SignatureRef<'_>>,
47 + ) -> (String, String) {
48 + let Some(signature) = signature else {
49 + return ("Unknown".to_string(), String::new());
50 + };
51 + let resolved = mailmap.resolve_cow(signature);
52 + (
53 + resolved.name.to_str_lossy().into_owned(),
54 + resolved.email.to_str_lossy().into_owned(),
55 + )
56 + }
57 +
28 58 /// Build the log entry for a commit. Author identity comes from the author
29 59 /// signature, the displayed timestamp from the commit (committer) time, which
30 60 /// is what the log has always shown.
31 - fn commit_info(commit: &gix::Commit<'_>) -> CommitInfo {
61 + fn commit_info(commit: &gix::Commit<'_>, mailmap: &gix::mailmap::Snapshot) -> CommitInfo {
32 62 let oid = commit.id().to_string();
33 63 let short_oid = short(&oid);
34 64
@@ -38,14 +68,7 @@
38 68 .and_then(|m| m.lines().next().map(|l| l.to_str_lossy().into_owned()))
39 69 .unwrap_or_default();
40 70
41 - let author = commit.author().ok();
42 - let author_name = author.map_or_else(
43 - || "Unknown".to_string(),
44 - |a| a.name.to_str_lossy().into_owned(),
45 - );
46 - let author_email = author
47 - .map(|a| a.email.to_str_lossy().into_owned())
48 - .unwrap_or_default();
71 + let (author_name, author_email) = resolved_identity(mailmap, commit.author().ok());
49 72
50 73 let seconds = commit.time().map(|t| t.seconds).unwrap_or_default();
51 74 let time = chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default();
@@ -75,12 +98,13 @@
75 98 .all()
76 99 .map_err(|_| GitError::RefNotFound)?;
77 100
101 + let mailmap = mailmap_of(repo);
78 102 let commits: Vec<CommitInfo> = walk
79 103 .filter_map(std::result::Result::ok)
80 104 .filter_map(|info| repo.find_commit(info.id).ok())
81 105 .skip(offset)
82 106 .take(limit)
83 - .map(|commit| commit_info(&commit))
107 + .map(|commit| commit_info(&commit, &mailmap))
84 108 .collect();
85 109
86 110 Ok(commits)
@@ -104,6 +128,7 @@
104 128 .all()
105 129 .map_err(|_| GitError::RefNotFound)?;
106 130
131 + let mailmap = mailmap_of(repo);
107 132 let mut result = Vec::new();
108 133 let mut skipped = 0;
109 134
@@ -141,7 +166,7 @@
141 166 continue;
142 167 }
143 168
144 - result.push(commit_info(&commit));
169 + result.push(commit_info(&commit, &mailmap));
145 170 }
146 171
147 172 Ok(result)
@@ -165,9 +190,13 @@
165 190 .map(|m| m.to_str_lossy().into_owned())
166 191 .unwrap_or_default();
167 192 let summary = message.lines().next().unwrap_or("").to_string();
193 + let (message_body, trailers) = split_trailers(message.as_bytes());
168 194
195 + let mailmap = mailmap_of(repo);
169 196 let author = commit.author().ok();
170 197 let committer = commit.committer().ok();
198 + let (author_name, author_email) = resolved_identity(&mailmap, author);
199 + let (committer_name, committer_email) = resolved_identity(&mailmap, committer);
171 200
172 201 let format_time = |sig: Option<gix::actor::SignatureRef<'_>>| {
173 202 let seconds = sig
@@ -196,26 +225,61 @@
196 225 short_oid,
197 226 summary,
198 227 full_message: message,
199 - author_name: author.map_or_else(
200 - || "Unknown".to_string(),
201 - |a| a.name.to_str_lossy().into_owned(),
202 - ),
203 - author_email: author
204 - .map(|a| a.email.to_str_lossy().into_owned())
205 - .unwrap_or_default(),
228 + message_body,
229 + trailers,
230 + author_name,
231 + author_email,
206 232 author_time: format_time(author),
207 - committer_name: committer.map_or_else(
208 - || "Unknown".to_string(),
209 - |c| c.name.to_str_lossy().into_owned(),
210 - ),
211 - committer_email: committer
212 - .map(|c| c.email.to_str_lossy().into_owned())
213 - .unwrap_or_default(),
233 + committer_name,
234 + committer_email,
214 235 committer_time: format_time(committer),
215 236 parents,
216 237 })
217 238 }
218 239
240 + /// Split a commit message into the prose part and its trailer block.
241 + ///
242 + /// The trailers are what `git interpret-trailers --parse` would report:
243 + /// gitoxide applies git's own rules for whether the last paragraph counts as a
244 + /// trailer block at all, so a message whose final paragraph merely contains a
245 + /// colon does not lose a line to the metadata panel.
246 + ///
247 + /// Returns the message with the trailer block removed, and the trailers in the
248 + /// order they were written. A message with no trailers returns the message
249 + /// unchanged and an empty vector, which is the common case and renders exactly
250 + /// as it did before.
251 + fn split_trailers(message: &[u8]) -> (String, Vec<CommitTrailer>) {
252 + let parsed = gix::objs::commit::MessageRef::from_bytes(message);
253 + let Some(body) = parsed.body() else {
254 + return (message.to_str_lossy().into_owned(), Vec::new());
255 + };
256 +
257 + let trailers: Vec<CommitTrailer> = body
258 + .trailers()
259 + .map(|t| CommitTrailer {
260 + token: t.token.to_str_lossy().into_owned(),
261 + value: t.value.to_str_lossy().into_owned(),
262 + })
263 + .collect();
264 +
265 + if trailers.is_empty() {
266 + return (message.to_str_lossy().into_owned(), Vec::new());
267 + }
268 +
269 + // `body` is the message past the title, so the title has to be put back;
270 + // trimming the end drops the blank line the trailer block left behind.
271 + let title = parsed.title.to_str_lossy();
272 + let prose = body.without_trailer().to_str_lossy();
273 + let prose = prose.trim_end();
274 + let rebuilt = if prose.is_empty() {
275 + title.trim_end().to_string()
276 + } else {
277 + format!("{}\n\n{}", title.trim_end(), prose)
278 + };
279 +
280 + (rebuilt, trailers)
281 + }
282 +
219 283 /// Escape the HTML-significant characters in a line of file content.
220 284 fn escape(content: &[u8]) -> String {
221 285 content
@@ -519,6 +583,7 @@
519 583 }
520 584 }
521 585
586 + let mailmap = mailmap_of(repo);
522 587 let mut result = Vec::with_capacity(content_lines.len());
523 588
524 589 for (i, line_content) in content_lines.iter().enumerate() {
@@ -533,10 +598,7 @@
533 598
534 599 let commit = repo.find_commit(commit_id).ok();
535 600 let author = commit.as_ref().and_then(|c| c.author().ok());
536 - let author_name = author.map_or_else(
537 - || "Unknown".to_string(),
538 - |a| a.name.to_str_lossy().into_owned(),
539 - );
601 + let (author_name, _) = resolved_identity(&mailmap, author);
540 602 let when = author
541 603 .and_then(|a| a.time().ok())
542 604 .map(|t| t.seconds)
@@ -76,12 +76,39 @@
76 76 pub is_branch: bool,
77 77 }
78 78
79 + /// One tag as the tags page renders it.
80 + ///
81 + /// An annotated tag carries its own author, date and message. Peeling straight
82 + /// through to the commit, which is all the ref bar needs, discards every one of
83 + /// them; this is the type that keeps them.
84 + pub struct TagInfo {
85 + pub name: String,
86 + /// The commit the tag names. `None` for a tag on a blob or a tree.
87 + pub target_oid: Option<String>,
88 + pub target_short_oid: Option<String>,
89 + /// A tag object of its own, rather than a bare name pointing at a commit.
90 + pub is_annotated: bool,
91 + pub tagger_name: Option<String>,
92 + pub tagger_time: Option<String>,
93 + /// Tagger timestamp, for ordering only. A lightweight tag has none.
94 + pub sort_key: Option<i64>,
95 + /// The annotation, whitespace-trimmed. `None` for a lightweight tag or an
96 + /// annotated tag with an empty message.
97 + pub message: Option<String>,
98 + }
99 +
79 100 /// Full commit detail for the commit page.
80 101 pub struct CommitDetail {
81 102 pub oid: String,
82 103 pub short_oid: String,
83 104 pub summary: String,
84 105 pub full_message: String,
106 + /// The message with its trailer block removed, which is what the commit
107 + /// page renders as prose. Identical to `full_message` when there are no
108 + /// trailers.
109 + pub message_body: String,
110 + /// Trailers parsed out of the last paragraph, in the order written.
111 + pub trailers: Vec<CommitTrailer>,
85 112 pub author_name: String,
86 113 pub author_email: String,
87 114 pub author_time: String,
@@ -91,6 +118,16 @@
91 118 pub parents: Vec<ParentRef>,
92 119 }
93 120
121 + /// A `Token: value` trailer from the end of a commit message.
122 + ///
123 + /// Rendered as structured metadata rather than left in the message body: a
124 + /// `Co-authored-by` line is a second author, and reading it as the last line of
125 + /// a paragraph is how every other forge loses that.
126 + pub struct CommitTrailer {
127 + pub token: String,
128 + pub value: String,
129 + }
130 +
94 131 /// Reference to a parent commit.
95 132 pub struct ParentRef {
96 133 pub oid: String,
@@ -859,6 +896,153 @@
859 896 assert_eq!(page2[0].summary, "Initial commit");
860 897 }
861 898
899 + #[test]
900 + fn mailmap_rewrites_author_in_log_and_blame() {
901 + // A bare repo has no worktree, so gitoxide reads `HEAD:.mailmap`. That
902 + // is the only place a forge can find one, and it is the case nothing
903 + // else in this tree exercises.
904 + use gix::objs::tree::EntryKind;
905 +
906 + let (_tmp, bare_path, repo) = init_test_repo("mailmapped");
907 +
908 + let mailmap = repo
909 + .write_blob(b"Proper Name <proper@example.com> <test@example.com>\n")
910 + .unwrap()
911 + .detach();
912 + let source = repo.write_blob(b"one\ntwo\n").unwrap().detach();
913 + let tree = write_tree(
914 + &repo,
915 + &[
916 + (".mailmap", mailmap, EntryKind::Blob),
917 + ("src.txt", source, EntryKind::Blob),
918 + ],
919 + );
920 + // `commit_main` signs as Test <test@example.com>, the left-hand side of
921 + // the mapping above.
922 + let head = commit_main(&repo, "add", tree, Vec::new());
923 +
924 + let repo = open_gix(&bare_path);
925 +
926 + let log = commit_log(&repo, head, 10, 0).unwrap();
927 + assert_eq!(log[0].author_name, "Proper Name");
928 + assert_eq!(log[0].author_email, "proper@example.com");
929 +
930 + let detail = commit_detail(&repo, head).unwrap();
931 + assert_eq!(detail.author_name, "Proper Name");
932 + assert_eq!(detail.committer_name, "Proper Name");
933 +
934 + let blame = blame_file(&repo, head, "src.txt").unwrap();
935 + assert!(blame.iter().all(|l| l.author_name == "Proper Name"));
936 + }
937 +
938 + #[test]
939 + fn no_mailmap_leaves_the_author_alone() {
940 + let (_tmp, bare_path) = make_test_repo();
941 + let repo = open_gix(&bare_path);
942 + let head = resolve_ref(&repo, "main").unwrap();
943 +
944 + let log = commit_log(&repo, head, 10, 0).unwrap();
945 + assert_eq!(log[0].author_name, "Test");
946 + assert_eq!(log[0].author_email, "test@example.com");
947 + }
948 +
949 + #[test]
950 + fn commit_detail_splits_trailers_out_of_the_body() {
951 + use gix::objs::tree::EntryKind;
952 +
953 + let (_tmp, bare_path, repo) = init_test_repo("trailers");
954 + let blob = repo.write_blob(b"x\n").unwrap().detach();
955 + let tree = write_tree(&repo, &[("x.txt", blob, EntryKind::Blob)]);
956 + let head = commit_main(
957 + &repo,
958 + "Fix the thing\n\nSome prose about the fix.\n\nCo-authored-by: Ada <ada@example.com>\nSigned-off-by: Max <max@example.com>\n",
959 + tree,
960 + Vec::new(),
961 + );
962 +
963 + let repo = open_gix(&bare_path);
964 + let detail = commit_detail(&repo, head).unwrap();
965 +
966 + assert_eq!(detail.trailers.len(), 2);
967 + assert_eq!(detail.trailers[0].token, "Co-authored-by");
968 + assert_eq!(detail.trailers[0].value, "Ada <ada@example.com>");
969 + assert_eq!(detail.trailers[1].token, "Signed-off-by");
970 +
971 + // The prose survives; the trailer block does not appear twice.
972 + assert!(detail.message_body.contains("Some prose about the fix."));
973 + assert!(!detail.message_body.contains("Co-authored-by"));
974 + // `full_message` stays the message as committed.
975 + assert!(detail.full_message.contains("Signed-off-by"));
976 + }
977 +
978 + #[test]
979 + fn a_message_without_trailers_is_unchanged() {
980 + let (_tmp, bare_path) = make_test_repo();
981 + let repo = open_gix(&bare_path);
982 + let head = resolve_ref(&repo, "main").unwrap();
983 +
984 + let detail = commit_detail(&repo, head).unwrap();
985 + assert!(detail.trailers.is_empty());
986 + assert_eq!(detail.message_body, detail.full_message);
987 + }
988 +
989 + #[test]
990 + fn list_tags_keeps_the_annotation_and_marks_lightweight_tags() {
991 + // The point of the tags page: an annotated tag's message is a release
992 + // note the author wrote, and peeling to the commit throws it away.
993 + let (_tmp, bare_path) = make_test_repo();
994 + let repo = open_gix(&bare_path);
995 + let head = resolve_ref(&repo, "main").unwrap();
996 +
997 + repo.reference(
998 + "refs/tags/v0.1.0",
999 + head,
1000 + gix::refs::transaction::PreviousValue::MustNotExist,
1001 + "lightweight",
1002 + )
1003 + .unwrap();
1004 +
1005 + let signature = gix::actor::SignatureRef {
1006 + name: "Test".into(),
1007 + email: "test@example.com".into(),
1008 + time: "1700000000 +0000",
1009 + };
1010 + repo.tag(
1011 + "v1.0.0",
1012 + head,
1013 + gix::object::Kind::Commit,
1014 + Some(signature),
1015 + "Release 1.0.0\n\nWhat changed.\n",
1016 + gix::refs::transaction::PreviousValue::MustNotExist,
1017 + )
1018 + .unwrap();
1019 +
1020 + let tags = list_tags(&repo);
1021 + assert_eq!(tags.len(), 2);
1022 +
1023 + let annotated = tags.iter().find(|t| t.name == "v1.0.0").unwrap();
1024 + assert!(annotated.is_annotated);
1025 + assert_eq!(annotated.tagger_name.as_deref(), Some("Test"));
1026 + assert!(
1027 + annotated
1028 + .message
1029 + .as_deref()
1030 + .unwrap()
1031 + .contains("What changed.")
1032 + );
1033 + assert_eq!(
1034 + annotated.target_oid.as_deref(),
1035 + Some(head.to_string().as_str())
1036 + );
1037 +
1038 + let lightweight = tags.iter().find(|t| t.name == "v0.1.0").unwrap();
1039 + assert!(!lightweight.is_annotated);
1040 + assert!(lightweight.message.is_none());
1041 + assert!(lightweight.tagger_name.is_none());
1042 + // Undated, so it sorts after the annotated one whatever its name.
1043 + assert_eq!(tags[0].name, "v1.0.0");
1044 + }
1045 +
862 1046 #[test]
863 1047 fn syntax_highlighter_falls_back_to_plain() {
864 1048 let hl = SyntaxHighlighter::new();
@@ -2,7 +2,7 @@
2 2
3 3 use gix::{ObjectId, Repository, bstr::ByteSlice};
4 4
5 - use super::{GitError, RefInfo};
5 + use super::{GitError, RefInfo, TagInfo};
6 6
7 7 /// Resolve a ref name to a commit OID. Tries branch, tag, raw OID, then revparse.
8 8 pub fn resolve_ref(repo: &Repository, refname: &str) -> Result<ObjectId, GitError> {
@@ -70,3 +70,88 @@
70 70
71 71 refs
72 72 }
73 +
74 + /// List tags with the annotation an annotated tag carries.
75 + ///
76 + /// A lightweight tag is a name pointing at a commit and nothing more, so its
77 + /// tagger and message are `None`. An annotated tag is a real object with its
78 + /// own author, date and message, all of which every forge throws away on the
79 + /// way to the commit it peels to. Newest first by tagger date, with
80 + /// lightweight tags (which have no date of their own) after the annotated ones.
81 + pub fn list_tags(repo: &Repository) -> Vec<TagInfo> {
82 + let Ok(platform) = repo.references() else {
83 + return Vec::new();
84 + };
85 + let Ok(tags) = platform.prefixed("refs/tags/") else {
86 + return Vec::new();
87 + };
88 +
89 + let mut out: Vec<TagInfo> = Vec::new();
90 +
91 + for mut reference in tags.flatten() {
92 + let name = reference.name().shorten().to_str_lossy().into_owned();
93 +
94 + // Read what the ref points at BEFORE peeling: `peel_to_id` rewrites the
95 + // reference's target in place, so asking afterwards yields the commit
96 + // and the tag object is lost.
97 + let pointee = reference.id().detach();
98 +
99 + // The commit the tag ultimately names, annotated or not. A tag on a
100 + // blob or a tree peels to no commit; it is still listed, without a
101 + // target link, rather than dropped.
102 + let target_oid = reference.peel_to_id().ok().map(gix::Id::detach);
103 +
104 + let tag_object = repo
105 + .find_object(pointee)
106 + .ok()
107 + .filter(|o| o.kind == gix::object::Kind::Tag)
108 + .and_then(|o| o.try_into_tag().ok());
109 +
110 + let (tagger_name, tagger_time, tagger_seconds, message) = match tag_object.as_ref() {
111 + Some(tag) => {
112 + let decoded = tag.decode().ok();
113 + // `TagRef::tagger` is the raw header bytes; the method parses it.
114 + let tagger = decoded.as_ref().and_then(|t| t.tagger().ok()).flatten();
115 + // A tag object is allowed to carry no tagger header. Formatting
116 + // a missing timestamp anyway would print 1970 as though it were
117 + // the release date.
118 + let seconds = tagger.and_then(|t| t.time().ok()).map(|t| t.seconds);
119 + (
120 + tagger.map(|t| t.name.to_str_lossy().into_owned()),
121 + seconds.map(|s| {
122 + chrono::DateTime::from_timestamp(s, 0)
123 + .unwrap_or_default()
124 + .format("%Y-%m-%d %H:%M UTC")
125 + .to_string()
126 + }),
127 + seconds,
128 + decoded
129 + .as_ref()
130 + .map(|t| t.message.to_str_lossy().trim().to_owned())
131 + .filter(|m| !m.is_empty()),
132 + )
133 + }
134 + None => (None, None, None, None),
135 + };
136 +
137 + out.push(TagInfo {
138 + name,
139 + target_oid: target_oid.map(|o| o.to_string()),
140 + target_short_oid: target_oid.map(|o| o.to_string()[..7].to_string()),
141 + is_annotated: tag_object.is_some(),
142 + tagger_name,
143 + tagger_time,
144 + sort_key: tagger_seconds,
145 + message,
146 + });
147 + }
148 +
149 + // Newest annotated tag first; undated (lightweight) tags fall to the end in
150 + // name order, which is the only stable order they have.
151 + out.sort_by(|a, b| {
152 + b.sort_key
153 + .cmp(&a.sort_key)
154 + .then_with(|| a.name.cmp(&b.name))
155 + });
156 + out
157 + }
@@ -274,6 +274,7 @@
274 274 GitCommitsTemplate,
275 275 GitCommitDetailTemplate,
276 276 GitNotesTemplate,
277 + GitTagsTemplate,
277 278 GitBlameTemplate,
278 279 GitUserReposTemplate,
279 280 GitExploreTemplate,
@@ -10,6 +10,7 @@
10 10 <nav class="git-nav-links">
11 11 <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}"{% if active_tab == "files" %} class="is-selected"{% endif %}>Files</a>
12 12 <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}"{% if active_tab == "commits" || active_tab == "commit" %} class="is-selected"{% endif %}>Commits</a>
13 + <a href="/git/{{ owner }}/{{ repo_name }}/tags"{% if active_tab == "tags" %} class="is-selected"{% endif %}>Tags</a>
13 14 <a href="/git/{{ owner }}/{{ repo_name }}/notes"{% if active_tab == "notes" %} class="is-selected"{% endif %}>Notes</a>
14 15 <a href="/git/{{ owner }}/{{ repo_name }}/issues"{% if active_tab == "issues" %} class="is-selected"{% endif %}>Issues{% if open_issue_count > 0 %} ({{ open_issue_count }}){% endif %}</a>
15 16 {% if is_owner %}