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>
327 lines
11 KiB
Rust
327 lines
11 KiB
Rust
use crate::{
|
|
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
|
|
repository::GitBinary, status::StatusCode,
|
|
};
|
|
use anyhow::{Context as _, Result};
|
|
use collections::{HashMap, HashSet};
|
|
use gpui::SharedString;
|
|
use std::{str::FromStr, sync::Arc};
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ParsedCommitMessage {
|
|
pub message: SharedString,
|
|
pub permalink: Option<url::Url>,
|
|
pub pull_request: Option<crate::hosting_provider::PullRequest>,
|
|
pub remote: Option<GitRemote>,
|
|
}
|
|
|
|
impl ParsedCommitMessage {
|
|
pub fn parse(
|
|
sha: String,
|
|
message: String,
|
|
remote_url: Option<&str>,
|
|
provider_registry: Option<Arc<GitHostingProviderRegistry>>,
|
|
) -> Self {
|
|
if let Some((hosting_provider, remote)) = provider_registry
|
|
.and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
|
|
{
|
|
let pull_request = hosting_provider.extract_pull_request(&remote, &message);
|
|
Self {
|
|
message: message.into(),
|
|
permalink: Some(
|
|
hosting_provider
|
|
.build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
|
|
),
|
|
pull_request,
|
|
remote: Some(GitRemote {
|
|
host: hosting_provider,
|
|
owner: remote.owner.into(),
|
|
repo: remote.repo.into(),
|
|
}),
|
|
}
|
|
} else {
|
|
Self {
|
|
message: message.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
|
|
if shas.is_empty() {
|
|
return Ok(HashMap::default());
|
|
}
|
|
|
|
let output = if cfg!(windows) {
|
|
// Windows has a maximum invocable command length, so we chunk the input.
|
|
// Actual max is 32767, but we leave some room for the rest of the command as we aren't in precise control of what std might do here
|
|
const MAX_CMD_LENGTH: usize = 30000;
|
|
// 40 bytes of hash, 2 quotes and a separating space
|
|
const SHA_LENGTH: usize = 40 + 2 + 1;
|
|
const MAX_ENTRIES_PER_INVOCATION: usize = MAX_CMD_LENGTH / SHA_LENGTH;
|
|
|
|
let mut result = vec![];
|
|
for shas in shas.chunks(MAX_ENTRIES_PER_INVOCATION) {
|
|
let partial = get_messages_impl(git, shas).await?;
|
|
result.extend(partial);
|
|
}
|
|
result
|
|
} else {
|
|
get_messages_impl(git, shas).await?
|
|
};
|
|
|
|
Ok(shas
|
|
.iter()
|
|
.cloned()
|
|
.zip(output)
|
|
.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
|
|
.build_command(&["show"])
|
|
.arg("-s")
|
|
.arg(format!("--format=%B{}", MARKER))
|
|
.args(shas.iter().map(ToString::to_string))
|
|
.output()
|
|
.await
|
|
.context("starting git show process")?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"'git show' failed with error {:?}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(String::from_utf8_lossy(&output.stdout)
|
|
.trim()
|
|
.split_terminator(MARKER)
|
|
.map(|str| str.trim().replace("<", "<").replace(">", ">"))
|
|
.collect::<Vec<_>>())
|
|
}
|
|
|
|
pub(crate) const GITLINK_MODE: &str = "160000";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum CommitDiffObjectKind {
|
|
Blob,
|
|
Gitlink,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) struct CommitDiffObject<'a> {
|
|
pub oid: &'a str,
|
|
pub kind: CommitDiffObjectKind,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) struct CommitDiffEntry<'a> {
|
|
pub path: &'a str,
|
|
pub status: StatusCode,
|
|
pub old_object: Option<CommitDiffObject<'a>>,
|
|
pub new_object: Option<CommitDiffObject<'a>>,
|
|
}
|
|
|
|
/// Parses the output of `git diff --raw --no-abbrev -z`.
|
|
pub(crate) fn parse_git_diff_raw(
|
|
content: &str,
|
|
) -> impl Iterator<Item = Result<CommitDiffEntry<'_>>> {
|
|
let mut parts = content.split('\0');
|
|
std::iter::from_fn(move || {
|
|
let metadata = parts.next()?;
|
|
if metadata.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let path = match parts.next() {
|
|
Some(path) => path,
|
|
None => return Some(Err(anyhow::anyhow!("raw diff is missing the path"))),
|
|
};
|
|
Some(parse_git_diff_raw_entry(metadata, path))
|
|
})
|
|
}
|
|
|
|
fn parse_git_diff_raw_entry<'a>(metadata: &'a str, path: &'a str) -> Result<CommitDiffEntry<'a>> {
|
|
let mut fields = metadata
|
|
.strip_prefix(':')
|
|
.context("raw diff metadata is missing its ':' prefix")?
|
|
.split_ascii_whitespace();
|
|
let old_mode = fields.next().context("raw diff is missing the old mode")?;
|
|
let new_mode = fields.next().context("raw diff is missing the new mode")?;
|
|
let old_oid = fields
|
|
.next()
|
|
.context("raw diff is missing the old object ID")?;
|
|
let new_oid = fields
|
|
.next()
|
|
.context("raw diff is missing the new object ID")?;
|
|
let status = match fields.next() {
|
|
Some("M") => StatusCode::Modified,
|
|
Some("T") => StatusCode::TypeChanged,
|
|
Some("A") => StatusCode::Added,
|
|
Some("D") => StatusCode::Deleted,
|
|
Some(status) => anyhow::bail!("unsupported raw diff status {status}"),
|
|
None => anyhow::bail!("raw diff is missing the status"),
|
|
};
|
|
|
|
Ok(CommitDiffEntry {
|
|
path,
|
|
status,
|
|
old_object: (!old_oid.bytes().all(|byte| byte == b'0')).then(|| CommitDiffObject {
|
|
oid: old_oid,
|
|
kind: if old_mode == GITLINK_MODE {
|
|
CommitDiffObjectKind::Gitlink
|
|
} else {
|
|
CommitDiffObjectKind::Blob
|
|
},
|
|
}),
|
|
new_object: (!new_oid.bytes().all(|byte| byte == b'0')).then(|| CommitDiffObject {
|
|
oid: new_oid,
|
|
kind: if new_mode == GITLINK_MODE {
|
|
CommitDiffObjectKind::Gitlink
|
|
} else {
|
|
CommitDiffObjectKind::Blob
|
|
},
|
|
}),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_git_diff_raw() {
|
|
let input = concat!(
|
|
":100644 100644 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\x00file.txt\x00",
|
|
":160000 160000 3333333333333333333333333333333333333333 4444444444444444444444444444444444444444 M\x00modules/example\x00",
|
|
":000000 100644 0000000000000000000000000000000000000000 5555555555555555555555555555555555555555 A\x00added.txt\x00",
|
|
":160000 000000 6666666666666666666666666666666666666666 0000000000000000000000000000000000000000 D\x00deleted-module\x00",
|
|
":100644 160000 7777777777777777777777777777777777777777 8888888888888888888888888888888888888888 T\x00type-change\x00",
|
|
);
|
|
|
|
let entries = parse_git_diff_raw(input)
|
|
.collect::<Result<Vec<_>>>()
|
|
.unwrap();
|
|
let [file, gitlink, added, deleted, type_change] = entries.as_slice() else {
|
|
panic!("expected five raw diff entries");
|
|
};
|
|
|
|
assert_eq!(file.path, "file.txt");
|
|
assert_eq!(file.status, StatusCode::Modified);
|
|
assert_eq!(
|
|
file.new_object.map(|object| object.kind),
|
|
Some(CommitDiffObjectKind::Blob)
|
|
);
|
|
assert_eq!(gitlink.path, "modules/example");
|
|
assert_eq!(gitlink.status, StatusCode::Modified);
|
|
assert_eq!(
|
|
gitlink.old_object.map(|object| object.kind),
|
|
Some(CommitDiffObjectKind::Gitlink)
|
|
);
|
|
assert_eq!(
|
|
gitlink.new_object.map(|object| object.kind),
|
|
Some(CommitDiffObjectKind::Gitlink)
|
|
);
|
|
assert!(added.old_object.is_none());
|
|
assert_eq!(added.status, StatusCode::Added);
|
|
assert!(deleted.new_object.is_none());
|
|
assert_eq!(deleted.status, StatusCode::Deleted);
|
|
assert_eq!(type_change.status, StatusCode::TypeChanged);
|
|
assert_eq!(
|
|
type_change.old_object.map(|object| object.kind),
|
|
Some(CommitDiffObjectKind::Blob)
|
|
);
|
|
assert_eq!(
|
|
type_change.new_object.map(|object| object.kind),
|
|
Some(CommitDiffObjectKind::Gitlink)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_git_diff_raw_rejects_malformed_metadata() {
|
|
let error = parse_git_diff_raw(":100644\x00file.txt\x00")
|
|
.next()
|
|
.expect("expected a raw diff entry")
|
|
.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(())
|
|
}
|
|
}
|