max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 %} |