Show tag names in git blame tooltips (#60757)

Fetch tag names for commits included in blame data and pass them through
the blame rendering path. Render tag names as chips in the blame hover
tooltip and expanded blame popover. Tag lookup is best-effort, so blame
still renders if tag lookup fails.

> Note: Tag names are currently only fetched for local repositories. For
remote projects (SSH) and collab sessions, blame data travels over the
`BlameBufferResponse` proto message, which doesn't carry tag names yet.

  ## Testing

- `cargo test -p git
test_parse_tag_names_for_lightweight_and_annotated_tags`
  - `cargo check -p git_ui`
  - `cargo check -p editor`
  - `cargo check -p project`

  Manual testing:
  - Opened `git: blame` on a file with a tagged commit.
- Verified tag chips are shown in the blame hover tooltip and expanded
popover.

  ## Self-Review Checklist:

  - [x] I've reviewed my own diff for quality, security, and reliability
  - [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and [icon]

(https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
  - [x] Tests cover the new/changed behavior
  - [x] Performance impact has been considered and is acceptable

  ## Showcase

Before:
<img width="1655" height="660" alt="before"
src="https://github.com/user-attachments/assets/dbefd729-f8df-48a9-8249-83c93518fe76"
/>

After:
<img width="1655" height="660" alt="after"
src="https://github.com/user-attachments/assets/2c1097de-b268-45d9-9d13-0f3e06058603"
/>

---

Release Notes:

- Git: Made tags visible in Git blame tooltips.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
Ruslan SemaginandDanilo Leal authored and GitHub committed 2026-07-14 01:21:38 +00:00
1 parent b1b7041cbf
commit b220d078bf
10 files changed
+217 -15

No files matched your search

@@ -4214,6 +4214,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
.into_iter()
.map(|(sha, message)| (sha.parse().unwrap(), message.into()))
.collect(),
tag_names: Default::default(),
};
client_a.fs().set_blame_for_repo(
Path::new(path!("/my-repo/.git")),
+4
View File
@@ -6950,10 +6950,12 @@ fn render_blame_entry_popover(
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let blame = blame.read(cx);
let repository = blame.repository(cx, buffer)?;
let tag_names = blame.tag_names_for_entry(buffer, &blame_entry);
renderer.render_blame_entry_popover(
blame_entry,
scroll_handle,
commit_message,
tag_names,
markdown,
repository,
workspace,
@@ -6990,11 +6992,13 @@ fn render_blame_entry(
let blame = blame.read(cx);
let details = blame.details_for_entry(buffer, &blame_entry);
let tag_names = blame.tag_names_for_entry(buffer, &blame_entry);
let repository = blame.repository(cx, buffer)?;
renderer.render_blame_entry(
&style.text,
blame_entry,
details,
tag_names,
repository,
workspace.downgrade(),
editor,
+38 -4
View File
@@ -8,8 +8,8 @@ use git::{
commit::ParsedCommitMessage,
};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
TextStyle, WeakEntity, Window,
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, SharedString,
Subscription, Task, TextStyle, WeakEntity, Window,
};
use itertools::Itertools;
use language::{Bias, BufferSnapshot, Edit};
@@ -69,6 +69,7 @@ struct GitBlameBuffer {
buffer_snapshot: BufferSnapshot,
buffer_edits: text::Subscription<usize>,
commit_details: HashMap<Oid, ParsedCommitMessage>,
commit_tag_names: HashMap<Oid, Vec<SharedString>>,
}
pub struct GitBlame {
@@ -91,6 +92,7 @@ pub trait BlameRenderer {
_: &TextStyle,
_: BlameEntry,
_: Option<ParsedCommitMessage>,
_: Vec<SharedString>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: Entity<Editor>,
@@ -112,6 +114,7 @@ pub trait BlameRenderer {
_: BlameEntry,
_: ScrollHandle,
_: Option<ParsedCommitMessage>,
_: Vec<SharedString>,
_: Entity<Markdown>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
@@ -139,6 +142,7 @@ impl BlameRenderer for () {
_: &TextStyle,
_: BlameEntry,
_: Option<ParsedCommitMessage>,
_: Vec<SharedString>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: Entity<Editor>,
@@ -164,6 +168,7 @@ impl BlameRenderer for () {
_: BlameEntry,
_: ScrollHandle,
_: Option<ParsedCommitMessage>,
_: Vec<SharedString>,
_: Entity<Markdown>,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
@@ -289,6 +294,14 @@ impl GitBlame {
.cloned()
}
pub fn tag_names_for_entry(&self, buffer: BufferId, entry: &BlameEntry) -> Vec<SharedString> {
self.buffers
.get(&buffer)
.and_then(|buffer| buffer.commit_tag_names.get(&entry.sha))
.cloned()
.unwrap_or_default()
}
pub fn blame_for_rows<'a>(
&'a mut self,
rows: &'a [RowInfo],
@@ -557,7 +570,11 @@ impl GitBlame {
let mut errors = vec![];
for (id, snapshot, buffer_edits, blame, remote_url) in blame {
match blame {
Ok(Some(Blame { entries, messages })) => {
Ok(Some(Blame {
entries,
messages,
tag_names,
})) => {
let entries = build_blame_entry_sum_tree(
entries,
snapshot.max_point().row,
@@ -575,12 +592,25 @@ impl GitBlame {
(oid, parsed_commit_message)
})
.collect();
let commit_tag_names = tag_names
.into_iter()
.map(|(oid, tag_names)| {
(
oid,
tag_names
.into_iter()
.map(SharedString::from)
.collect(),
)
})
.collect();
res.push((
id,
snapshot,
buffer_edits,
Some(entries),
commit_details,
commit_tag_names,
));
}
Ok(None) => res.push((
@@ -589,6 +619,7 @@ impl GitBlame {
buffer_edits,
None,
Default::default(),
Default::default(),
)),
Err(e) => errors.push(e),
}
@@ -603,7 +634,9 @@ impl GitBlame {
this.update(cx, |this, cx| {
this.buffers.clear();
for (id, snapshot, buffer_edits, entries, commit_details) in all_results {
for (id, snapshot, buffer_edits, entries, commit_details, commit_tag_names) in
all_results
{
let Some(entries) = entries else {
continue;
};
@@ -614,6 +647,7 @@ impl GitBlame {
buffer_snapshot: snapshot,
entries,
commit_details,
commit_tag_names,
},
);
}
+21 -6
View File
@@ -1,9 +1,9 @@
use crate::Oid;
use crate::commit::get_messages;
use crate::commit::{get_messages, get_tag_names};
use crate::repository::{GitBinary, RepoPath};
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use futures::{AsyncWriteExt, try_join};
use futures::{AsyncWriteExt, TryFutureExt, try_join};
use serde::{Deserialize, Serialize};
use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use std::ops::Range;
@@ -17,6 +17,7 @@ use util::command::Stdio;
pub struct Blame {
pub entries: Vec<BlameEntry>,
pub messages: HashMap<Oid, String>,
pub tag_names: HashMap<Oid, Vec<String>>,
}
impl Blame {
@@ -35,12 +36,26 @@ impl Blame {
}
let shas = unique_shas.into_iter().collect::<Vec<_>>();
let messages = get_messages(git, &shas)
.await
.context("failed to get commit messages")?;
let (messages, tag_names) = try_join!(
get_messages(git, &shas)
.map_err(|error| error.context("failed to get commit messages")),
async {
match get_tag_names(git, &shas).await {
Ok(tag_names) => Ok(tag_names),
Err(error) => {
log::warn!("failed to get commit tag names: {error:#}");
Ok(HashMap::default())
}
}
},
)?;
entries.sort_unstable_by_key(|entry| entry.range.start);
Ok(Self { entries, messages })
Ok(Self {
entries,
messages,
tag_names,
})
}
}
+79 -2
View File
@@ -3,9 +3,9 @@ use crate::{
repository::GitBinary, status::StatusCode,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use collections::{HashMap, HashSet};
use gpui::SharedString;
use std::sync::Arc;
use std::{str::FromStr, sync::Arc};
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
@@ -78,6 +78,60 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result<HashMa
.collect::<HashMap<Oid, String>>())
}
pub(crate) async fn get_tag_names(
git: &GitBinary,
shas: &[Oid],
) -> Result<HashMap<Oid, Vec<String>>> {
if shas.is_empty() {
return Ok(HashMap::default());
}
let output = git
.build_command(&[
"for-each-ref",
"refs/tags",
"--sort=-creatordate",
"--format=%(objectname)%00%(*objectname)%00%(refname:short)",
])
.output()
.await
.context("starting git for-each-ref process")?;
anyhow::ensure!(
output.status.success(),
"'git for-each-ref' failed with error {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(parse_tag_names(
&String::from_utf8_lossy(&output.stdout),
shas,
))
}
fn parse_tag_names(output: &str, shas: &[Oid]) -> HashMap<Oid, Vec<String>> {
let shas = shas.iter().copied().collect::<HashSet<_>>();
let mut result = HashMap::<Oid, Vec<String>>::default();
for line in output.lines() {
let mut fields = line.split('\0');
let object_sha = fields.next();
let peeled_sha = fields.next().filter(|sha| !sha.is_empty());
let Some(sha) = peeled_sha
.or(object_sha)
.and_then(|sha| Oid::from_str(sha).ok())
else {
continue;
};
let Some(tag_name) = fields.next().filter(|tag_name| !tag_name.is_empty()) else {
continue;
};
result.entry(sha).or_default().push(tag_name.to_string());
}
result.retain(|sha, _| shas.contains(sha));
result
}
async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>> {
const MARKER: &str = "<MARKER>";
let output = git
@@ -246,4 +300,27 @@ mod tests {
.expect_err("expected malformed metadata to fail");
assert!(error.to_string().contains("new mode"));
}
#[test]
fn test_parse_tag_names_for_lightweight_and_annotated_tags() -> Result<()> {
let tagged_commit = Oid::from_str("1111111111111111111111111111111111111111")?;
let tag_object = Oid::from_str("2222222222222222222222222222222222222222")?;
let other_commit = Oid::from_str("3333333333333333333333333333333333333333")?;
let output = format!(
"{tagged_commit}\0\0v1.0.0\n\
{tag_object}\0{tagged_commit}\0v1.1.0\n\
{other_commit}\0\0ignored\n"
);
let parsed = parse_tag_names(&output, &[tagged_commit]);
assert_eq!(
parsed,
HashMap::from_iter([(
tagged_commit,
vec![String::from("v1.0.0"), String::from("v1.1.0")]
)])
);
Ok(())
}
}
+8 -1
View File
@@ -1,5 +1,5 @@
use crate::{
commit_tooltip::{CommitAvatar, CommitTooltip},
commit_tooltip::{CommitAvatar, CommitTooltip, commit_tag_chips},
commit_view::CommitView,
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
@@ -131,6 +131,7 @@ impl BlameRenderer for GitBlameRenderer {
style: &TextStyle,
blame_entry: BlameEntry,
details: Option<ParsedCommitMessage>,
tag_names: Vec<SharedString>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
editor: Entity<Editor>,
@@ -226,6 +227,7 @@ impl BlameRenderer for GitBlameRenderer {
CommitTooltip::blame_entry(
&blame_entry,
details.clone(),
tag_names.clone(),
repository.clone(),
workspace.clone(),
cx,
@@ -266,6 +268,7 @@ impl BlameRenderer for GitBlameRenderer {
blame: BlameEntry,
scroll_handle: ScrollHandle,
details: Option<ParsedCommitMessage>,
tag_names: Vec<SharedString>,
markdown: Entity<Markdown>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
@@ -402,12 +405,16 @@ impl BlameRenderer for GitBlameRenderer {
.w_full()
.justify_between()
.pt_1()
.gap_1()
.flex_wrap()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(absolute_timestamp)
.child(
h_flex()
.gap_1()
.min_w_0()
.children(commit_tag_chips(&tag_names))
.when_some(pull_request, |this, pr| {
this.child(
Button::new(
+53 -1
View File
@@ -14,7 +14,7 @@ use settings::Settings;
use std::hash::Hash;
use theme_settings::ThemeSettings;
use time::{OffsetDateTime, UtcOffset};
use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container};
use ui::{Avatar, Chip, CopyButton, Divider, Tooltip, prelude::*, tooltip_container};
use workspace::Workspace;
#[derive(Clone, Debug)]
@@ -24,6 +24,51 @@ pub struct CommitDetails {
pub author_email: SharedString,
pub commit_time: OffsetDateTime,
pub message: Option<ParsedCommitMessage>,
pub tag_names: Vec<SharedString>,
}
const MAX_COMMIT_TOOLTIP_TAG_CHIPS: usize = 2;
pub(crate) fn commit_tag_chips(tag_names: &[SharedString]) -> Option<impl IntoElement> {
if tag_names.is_empty() {
return None;
}
let (visible_tags, hidden_tags) =
tag_names.split_at(tag_names.len().min(MAX_COMMIT_TOOLTIP_TAG_CHIPS));
Some(
h_flex().max_w(relative(0.6)).gap_1().child(
h_flex()
.gap_1()
.min_w_0()
.children(
visible_tags
.iter()
.map(|tag_name| Chip::new(tag_name.clone()).truncate()),
)
.when(!hidden_tags.is_empty(), |this| {
let hidden_tags = hidden_tags.to_vec();
this.child(Chip::new(format!("+{}", hidden_tags.len())).tooltip(
Tooltip::element(move |_window, cx| {
v_flex()
.gap_1()
.children(itertools::Itertools::intersperse_with(
hidden_tags.iter().map(|tag_name| {
Label::new(tag_name.clone())
.size(LabelSize::Small)
.buffer_font(cx)
.into_any_element()
}),
|| Divider::horizontal().into_any_element(),
))
.into_any_element()
}),
))
})
.child(Divider::vertical()),
),
)
}
pub struct CommitAvatar<'a> {
@@ -172,6 +217,7 @@ impl CommitTooltip {
pub fn blame_entry(
blame: &BlameEntry,
details: Option<ParsedCommitMessage>,
tag_names: Vec<SharedString>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
@@ -192,6 +238,7 @@ impl CommitTooltip {
.into(),
author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
message: details,
tag_names,
},
repository,
workspace,
@@ -270,6 +317,7 @@ impl Render for CommitTooltip {
.message
.as_ref()
.and_then(|details| details.pull_request.clone());
let tag_names = self.commit.tag_names.clone();
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
@@ -336,12 +384,16 @@ impl Render for CommitTooltip {
.w_full()
.justify_between()
.pt_1()
.gap_1()
.flex_wrap()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(absolute_timestamp)
.child(
h_flex()
.gap_1()
.min_w_0()
.children(commit_tag_chips(&tag_names))
.when_some(pull_request, |this, pr| {
this.child(
Button::new(
+7
View File
@@ -1911,6 +1911,12 @@ impl GitGraph {
let author_email = commit_data.author_email.clone();
let message = commit_data.message.clone();
let commit_timestamp = commit_data.commit_timestamp;
let tag_names = commit
.data
.tag_names()
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>();
let workspace = self.workspace.clone();
let repository = repository.clone();
this.hoverable_tooltip(move |_window, cx| {
@@ -1931,6 +1937,7 @@ impl GitGraph {
remote_url.as_deref(),
Some(provider_registry),
)),
tag_names: tag_names.clone(),
};
cx.new(|cx| {
CommitTooltip::new(
+1
View File
@@ -8034,6 +8034,7 @@ impl GitPanelMessageTooltip {
remote_url.as_deref(),
provider_registry,
)),
tag_names: Vec::new(),
};
this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
+5 -1
View File
@@ -9649,7 +9649,11 @@ fn deserialize_blame_buffer_response(
.filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message)))
.collect::<HashMap<_, _>>();
Some(Blame { entries, messages })
Some(Blame {
entries,
messages,
tag_names: Default::default(),
})
}
fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource {