Support blaming parent revisions (#62614)

Closes https://github.com/zed-industries/zed/discussions/42583

Adds more tooltip entries and `editor::BlameRevision`,
`editor::BlamePreviousRevision` actions to use.
Started to highlight gutter blame entries that belong to currently
annotated commit.


https://github.com/user-attachments/assets/ba754e0b-6431-407c-8d79-2f8b0324fde1


Release Notes:

- Supported blaming parent revisions
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-08-14 20:24:13 +00:00
1 parent 232eaba704
commit c1dab7a0d3
17 files changed
+1251 -140

No files matched your search

+1
View File
@@ -372,6 +372,7 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::DownloadFileByPath>)
.add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
.add_request_handler(forward_read_only_project_request::<proto::GetDefaultBranch>)
.add_request_handler(forward_read_only_project_request::<proto::BlameBufferAtRevision>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::LspExtExpandMacro>)
@@ -4214,7 +4214,10 @@ 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(),
tag_names: [("1b1b1b", vec!["v1.0.0".to_string()])]
.into_iter()
.map(|(sha, tag_names)| (sha.parse().unwrap(), tag_names))
.collect(),
};
client_a.fs().set_blame_for_repo(
Path::new(path!("/my-repo/.git")),
@@ -4300,6 +4303,10 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
let details = blame.details_for_entry(*buffer, entry).unwrap();
assert_eq!(details.message, format!("message for idx-{}", idx));
}
assert_eq!(
blame.tag_names_for_entry(buffer_id_b, &blame_entry("1b1b1b", 0..1)),
vec![SharedString::from("v1.0.0")]
);
});
});
@@ -4375,6 +4382,61 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
]
);
});
let revision = "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d"
.parse::<git::Oid>()
.unwrap();
let content_at_revision = "old line1\nold line2\n";
let blame_at_revision = git::blame::Blame {
entries: vec![blame_entry("1b1b1b", 0..1), blame_entry("0d0d0d", 1..2)],
messages: [
("1b1b1b", "message for idx-0"),
("0d0d0d", "message for idx-1"),
]
.into_iter()
.map(|(sha, message)| (sha.parse().unwrap(), message.into()))
.collect(),
tag_names: [("0d0d0d", vec!["v1.0.0".to_string()])]
.into_iter()
.map(|(sha, tag_names)| (sha.parse().unwrap(), tag_names))
.collect(),
};
client_a
.fs()
.with_git_state(Path::new(path!("/my-repo/.git")), true, |state| {
state.refs.insert("HEAD".into(), revision.to_string());
state
.head_contents
.insert(repo_path("file.txt"), content_at_revision.to_string());
state
.blames_at_revision
.insert((repo_path("file.txt"), revision), blame_at_revision.clone());
})
.unwrap();
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
let repository_b = project_b.read_with(cx_b, |project, cx| {
project
.git_store()
.read(cx)
.repositories()
.values()
.next()
.unwrap()
.clone()
});
let (content, forwarded_blame) = repository_b
.update(cx_b, |repository, cx| {
repository.blame_buffer_at_revision(repo_path("file.txt"), revision, cx)
})
.await
.unwrap();
assert_eq!(content, content_at_revision);
assert_eq!(forwarded_blame, blame_at_revision);
}
#[gpui::test(iterations = 30)]
+4
View File
@@ -876,6 +876,10 @@ actions!(
ToggleGitBlameInline,
/// Opens the git commit for the blame at cursor.
OpenGitBlameCommit,
/// Opens a blame of the file at the revision of the blame entry at cursor.
BlameRevision,
/// Opens a blame of the file at the revision preceding the blame entry at cursor.
BlamePreviousRevision,
/// Toggles the diagnostics panel.
ToggleDiagnostics,
/// Toggles indent guides display.
+8 -3
View File
@@ -106,7 +106,7 @@ pub use element::{
CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
file_status_label_color, render_breadcrumb_text,
};
pub use git::blame::BlameRenderer;
pub use git::blame::{BlameRenderer, GitBlame};
pub use git::{
DiffHunkDelegate, ResolvedDiffHunk, ResolvedDiffHunks, RestoreOnlyDiffHunkDelegate,
RestoreOnlyUnstagedDiffHunkDelegate, UncommittedDiffHunkDelegate, render_diff_hunk_controls,
@@ -158,7 +158,7 @@ use futures::{
future::{self, Shared},
};
use fuzzy::{StringMatch, StringMatchCandidate};
use git::blame::{GitBlame, GlobalBlameRenderer};
use git::blame::GlobalBlameRenderer;
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext,
AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context,
@@ -3330,7 +3330,12 @@ impl Editor {
cx.notify();
return;
}
if self.show_git_blame_gutter {
if self.show_git_blame_gutter
&& !self
.blame
.as_ref()
.is_some_and(|blame| blame.read(cx).is_static())
{
self.show_git_blame_gutter = false;
cx.notify();
return;
+5 -1
View File
@@ -509,7 +509,11 @@ impl EditorElement {
register_action(editor, window, Editor::copy_file_location);
register_action(editor, window, Editor::toggle_git_blame);
register_action(editor, window, Editor::toggle_git_blame_inline);
register_action(editor, window, Editor::open_git_blame_commit);
if editor.read(cx).blame().is_some() {
register_action(editor, window, Editor::open_git_blame_commit);
register_action(editor, window, Editor::blame_revision);
register_action(editor, window, Editor::blame_previous_revision);
}
register_action(editor, window, Editor::toggle_selected_diff_hunks);
register_action(editor, window, Editor::toggle_staged_selected_diff_hunks);
register_action(editor, window, Editor::stage_and_next);
+126 -7
View File
@@ -1,8 +1,12 @@
pub(super) mod blame;
use super::*;
use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use ::git::{
Oid, Restore, blame::BlameEntry, commit::ParsedCommitMessage, repository::RepoPath,
status::FileStatus,
};
use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind};
use project::git_store::Repository;
#[derive(Clone)]
pub struct ResolvedDiffHunk {
@@ -2145,15 +2149,36 @@ impl Editor {
})
}
pub fn set_blame(
&mut self,
blame: Entity<GitBlame>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.blame_subscription = Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify()));
self.blame = Some(blame);
self.show_git_blame_gutter = true;
cx.notify();
}
fn start_git_blame(
&mut self,
user_triggered: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self
.blame
.as_ref()
.is_some_and(|blame| blame.read(cx).is_static())
{
return;
}
if let Some(project) = self.project() {
if let Some(buffer) = self.buffer().read(cx).as_singleton()
&& buffer.read(cx).file().is_none()
&& buffer.read(cx).file().is_none_or(|file| {
matches!(file.disk_state(), language::DiskState::Historic { .. })
})
{
return;
}
@@ -2248,7 +2273,19 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let blame = self.blame.as_ref()?;
let (blame_entry, repo) = self.blame_entry_at_cursor(window, cx)?;
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let workspace = self.workspace()?.downgrade();
renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
None
}
fn blame_entry_at_cursor(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<(BlameEntry, Entity<Repository>)> {
let blame = self.blame.clone()?;
let snapshot = self.snapshot(window, cx);
let cursor = self
.selections
@@ -2269,11 +2306,93 @@ impl Editor {
.next()
})
.flatten()?;
let repository = blame.read(cx).repository(cx, buffer.remote_id())?;
Some((blame_entry, repository))
}
pub(super) fn blame_revision(
&mut self,
_: &BlameRevision,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((blame_entry, repository)) = self.blame_entry_at_cursor(window, cx) else {
self.show_blame_revision_toast("No blame entry for this line", cx);
return;
};
if blame_entry.sha.is_zero() {
self.show_blame_revision_toast("Cannot blame revision: the line is not committed", cx);
return;
}
if self
.blame
.as_ref()
.is_some_and(|blame| blame.read(cx).highlighted_sha() == Some(blame_entry.sha))
{
self.show_blame_revision_toast("Already blaming at this revision", cx);
return;
}
let Some(path) = RepoPath::new(&blame_entry.filename).log_err() else {
return;
};
self.open_blame_revision(path, blame_entry.sha, repository, window, cx);
}
pub(super) fn blame_previous_revision(
&mut self,
_: &BlamePreviousRevision,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((blame_entry, repository)) = self.blame_entry_at_cursor(window, cx) else {
self.show_blame_revision_toast("No blame entry for this line", cx);
return;
};
let Some((revision, filename)) = blame_entry.previous_sha_and_filename() else {
self.show_blame_revision_toast("No previous revision for this line", cx);
return;
};
let Some(path) = RepoPath::new(filename).log_err() else {
return;
};
self.open_blame_revision(path, revision, repository, window, cx);
}
fn open_blame_revision(
&mut self,
path: RepoPath,
revision: Oid,
repository: Entity<Repository>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace() else {
return;
};
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let repo = blame.read(cx).repository(cx, buffer.remote_id())?;
let workspace = self.workspace()?.downgrade();
renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
None
renderer.open_blame_revision(
path,
revision,
repository,
workspace.downgrade(),
window,
cx,
);
}
fn show_blame_revision_toast(&self, message: &str, cx: &mut Context<Self>) {
struct BlameRevisionToast;
if let Some(workspace) = self.workspace() {
workspace.update(cx, |workspace, cx| {
workspace.show_toast(
Toast::new(
NotificationId::unique::<BlameRevisionToast>(),
message.to_owned(),
),
cx,
);
});
}
}
fn has_blame_entries(&self, cx: &App) -> bool {
+300 -27
View File
@@ -6,13 +6,14 @@ use git::{
GitHostingProviderRegistry, Oid,
blame::{Blame, BlameEntry},
commit::ParsedCommitMessage,
repository::RepoPath,
};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, Pixels, ScrollHandle, SharedString,
Subscription, Task, TextStyle, WeakEntity, Window,
};
use itertools::Itertools;
use language::{Bias, BufferSnapshot, Edit};
use language::{Bias, Buffer, BufferSnapshot, Edit};
use markdown::Markdown;
use multi_buffer::{MultiBuffer, RowInfo};
use project::{
@@ -72,6 +73,26 @@ struct GitBlameBuffer {
commit_tag_names: HashMap<Oid, Vec<SharedString>>,
}
/// Determines where the blame data comes from and whether it is kept up to date.
enum GitBlameKind {
/// Blame of the working copy of every buffer in the multibuffer, computed
/// through the project's git state and regenerated on edits, saves, and
/// repository changes. The repository is resolved per buffer through the
/// project.
Live,
/// Precomputed per-buffer blames at a fixed revision, created via
/// [`GitBlame::new_static`]. They are never regenerated. The annotated
/// buffers are synthetic read-only buffers that do not belong to the
/// project, so the repository cannot be resolved through them and is
/// stored explicitly; all buffers must belong to the same repository.
Static {
repository: Entity<Repository>,
/// The revision being viewed; gutter entries introduced by this commit
/// are highlighted.
highlighted_sha: Option<Oid>,
},
}
pub struct GitBlame {
project: Entity<Project>,
multi_buffer: WeakEntity<MultiBuffer>,
@@ -82,6 +103,7 @@ pub struct GitBlame {
user_triggered: bool,
regenerate_on_edit_task: Task<Result<()>>,
_regenerate_subscriptions: Vec<Subscription>,
kind: GitBlameKind,
}
pub trait BlameRenderer {
@@ -134,6 +156,17 @@ pub trait BlameRenderer {
_: &mut Window,
_: &mut App,
);
fn open_blame_revision(
&self,
_: RepoPath,
_: Oid,
_: Entity<Repository>,
_: WeakEntity<Workspace>,
_: &mut Window,
_: &mut App,
) {
}
}
impl BlameRenderer for () {
@@ -268,12 +301,80 @@ impl GitBlame {
project_subscription,
git_store_subscription,
],
kind: GitBlameKind::Live,
};
this.generate(cx);
this
}
pub fn new_static(
multi_buffer: Entity<MultiBuffer>,
project: Entity<Project>,
repository: Entity<Repository>,
blames: impl IntoIterator<Item = (Entity<Buffer>, Blame)>,
highlighted_sha: Option<Oid>,
cx: &mut Context<Self>,
) -> Self {
let remote_url = repository.read(cx).default_remote_url();
let provider_registry = GitHostingProviderRegistry::default_global(cx);
let mut buffers = HashMap::default();
for (buffer, blame) in blames {
let snapshot = buffer.read(cx).snapshot();
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
let (commit_details, commit_tag_names) = parse_commit_details(
blame.messages,
blame.tag_names,
remote_url.as_deref(),
&provider_registry,
);
let entries = build_blame_entry_sum_tree(blame.entries, snapshot.max_point().row);
buffers.insert(
snapshot.remote_id(),
GitBlameBuffer {
entries,
buffer_snapshot: snapshot,
buffer_edits,
commit_details,
commit_tag_names,
},
);
}
Self {
project,
multi_buffer: multi_buffer.downgrade(),
buffers,
task: Task::ready(Ok(())),
focused: true,
changed_while_blurred: false,
user_triggered: true,
regenerate_on_edit_task: Task::ready(Ok(())),
_regenerate_subscriptions: Vec::new(),
kind: GitBlameKind::Static {
repository,
highlighted_sha,
},
}
}
pub fn is_static(&self) -> bool {
matches!(self.kind, GitBlameKind::Static { .. })
}
pub fn highlighted_sha(&self) -> Option<Oid> {
match &self.kind {
GitBlameKind::Live => None,
GitBlameKind::Static {
highlighted_sha, ..
} => *highlighted_sha,
}
}
pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
if let GitBlameKind::Static { repository, .. } = &self.kind {
return Some(repository.clone());
}
self.project
.read(cx)
.git_store()
@@ -507,6 +608,9 @@ impl GitBlame {
#[ztracing::instrument(skip_all)]
fn generate(&mut self, cx: &mut Context<Self>) {
if self.is_static() {
return;
}
if !self.focused {
self.changed_while_blurred = true;
return;
@@ -583,31 +687,13 @@ impl GitBlame {
entries,
snapshot.max_point().row,
);
let commit_details = messages
.into_iter()
.map(|(oid, message)| {
let parsed_commit_message =
ParsedCommitMessage::parse(
oid.to_string(),
message,
remote_url.as_deref(),
Some(provider_registry.clone()),
);
(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();
let (commit_details, commit_tag_names) =
parse_commit_details(
messages,
tag_names,
remote_url.as_deref(),
&provider_registry,
);
res.push((
id,
snapshot,
@@ -698,6 +784,42 @@ impl GitBlame {
const REGENERATE_ON_EDIT_DEBOUNCE_INTERVAL: Duration = Duration::from_secs(2);
fn parse_commit_details(
messages: HashMap<Oid, String>,
tag_names: HashMap<Oid, Vec<String>>,
remote_url: Option<&str>,
provider_registry: &Arc<GitHostingProviderRegistry>,
) -> (
HashMap<Oid, ParsedCommitMessage>,
HashMap<Oid, Vec<SharedString>>,
) {
let commit_details = messages
.into_iter()
.map(|(oid, message)| {
let parsed_commit_message = ParsedCommitMessage::parse(
oid.to_string(),
message,
remote_url,
Some(provider_registry.clone()),
);
(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::<Vec<_>>(),
)
})
.collect();
(commit_details, commit_tag_names)
}
fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
let mut current_row = 0;
let mut entries = SumTree::from_iter(
@@ -740,7 +862,7 @@ mod tests {
use super::*;
use git::repository::repo_path;
use gpui::Context;
use language::{Point, Rope};
use language::{Capability, Point, Rope};
use project::FakeFs;
use rand::prelude::*;
use serde_json::json;
@@ -1049,6 +1171,157 @@ mod tests {
});
}
#[gpui::test]
async fn test_static_blame(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/my-repo"),
json!({
".git": {},
"file_one.txt": "Line 1\nLine 2\nLine 3\n",
"file_two.txt": "Other 1\nOther 2\n",
}),
)
.await;
let project = Project::test(fs, [path!("/my-repo").as_ref()], cx).await;
let buffer_one = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/my-repo/file_one.txt"), cx)
})
.await
.unwrap();
let buffer_two = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/my-repo/file_two.txt"), cx)
})
.await
.unwrap();
let buffer_one_id = buffer_one.read_with(cx, |buffer, _| buffer.remote_id());
let buffer_two_id = buffer_two.read_with(cx, |buffer, _| buffer.remote_id());
let multi_buffer = cx.new(|cx| {
let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite);
multi_buffer.set_excerpts_for_buffer(
buffer_one.clone(),
[Point::zero()..buffer_one.read(cx).max_point()],
0,
cx,
);
multi_buffer.set_excerpts_for_buffer(
buffer_two.clone(),
[Point::zero()..buffer_two.read(cx).max_point()],
0,
cx,
);
multi_buffer
});
cx.executor().run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.git_store()
.read(cx)
.repositories()
.values()
.next()
.unwrap()
.clone()
});
let highlighted_sha = "0d0d0d".parse::<Oid>().unwrap();
let blame_one = Blame {
entries: vec![blame_entry("1b1b1b", 0..1), blame_entry("0d0d0d", 1..2)],
messages: [
("1b1b1b".parse::<Oid>().unwrap(), "commit one".to_string()),
("0d0d0d".parse::<Oid>().unwrap(), "commit two".to_string()),
]
.into_iter()
.collect(),
tag_names: [("0d0d0d".parse::<Oid>().unwrap(), vec!["v1.0.0".to_string()])]
.into_iter()
.collect(),
};
let blame_two = Blame {
entries: vec![blame_entry("0d0d0d", 0..2)],
messages: [("0d0d0d".parse::<Oid>().unwrap(), "commit two".to_string())]
.into_iter()
.collect(),
tag_names: HashMap::default(),
};
let git_blame = cx.new(|cx| {
GitBlame::new_static(
multi_buffer,
project.clone(),
repository.clone(),
[
(buffer_one.clone(), blame_one),
(buffer_two.clone(), blame_two),
],
Some(highlighted_sha),
cx,
)
});
git_blame.update(cx, |blame, cx| {
assert!(blame.is_static());
assert_eq!(blame.highlighted_sha(), Some(highlighted_sha));
assert_eq!(
blame.repository(cx, buffer_one_id),
Some(repository.clone())
);
assert_eq!(
blame.repository(cx, buffer_two_id),
Some(repository.clone())
);
assert_blame_rows(
blame,
buffer_one_id,
0..3,
vec![
Some(blame_entry("1b1b1b", 0..1)),
Some(blame_entry("0d0d0d", 1..2)),
None,
],
cx,
);
assert_blame_rows(
blame,
buffer_two_id,
0..2,
vec![
Some(blame_entry("0d0d0d", 0..2)),
Some(blame_entry("0d0d0d", 0..2)),
],
cx,
);
assert_eq!(
blame
.details_for_entry(buffer_one_id, &blame_entry("1b1b1b", 0..1))
.unwrap()
.message,
"commit one"
);
assert_eq!(
blame
.details_for_entry(buffer_two_id, &blame_entry("0d0d0d", 0..2))
.unwrap()
.message,
"commit two"
);
assert_eq!(
blame.tag_names_for_entry(buffer_one_id, &blame_entry("0d0d0d", 1..2)),
vec![SharedString::from("v1.0.0")]
);
assert_eq!(
blame.tag_names_for_entry(buffer_two_id, &blame_entry("0d0d0d", 0..2)),
Vec::<SharedString>::new()
);
});
}
#[gpui::test]
async fn test_blame_for_rows_with_edits(cx: &mut gpui::TestAppContext) {
init_test(cx);
+24 -1
View File
@@ -66,6 +66,7 @@ pub struct FakeGitRepositoryState {
pub merge_base_contents: HashMap<RepoPath, Oid>,
pub oids: HashMap<Oid, String>,
pub blames: HashMap<RepoPath, Blame>,
pub blames_at_revision: HashMap<(RepoPath, Oid), Blame>,
pub current_branch_name: Option<String>,
pub branches: HashSet<String>,
/// List of remotes, keys are names and values are URLs
@@ -90,6 +91,7 @@ impl FakeGitRepositoryState {
index_contents: Default::default(),
unmerged_paths: Default::default(),
blames: Default::default(),
blames_at_revision: Default::default(),
current_branch_name: Default::default(),
branches: Default::default(),
simulated_index_write_error_message: Default::default(),
@@ -269,7 +271,14 @@ impl GitRepository for FakeGitRepository {
match prefix {
"" => state.index_contents.get(&repo_path).cloned(),
"HEAD" => state.head_contents.get(&repo_path).cloned(),
_ => None,
_ if state.refs.get("HEAD").map(String::as_str) == Some(prefix) => {
state.head_contents.get(&repo_path).cloned()
}
_ => state
.commit_history
.iter()
.find(|snapshot| snapshot.sha == prefix)
.and_then(|snapshot| snapshot.head_contents.get(&repo_path).cloned()),
}
})
.collect())
@@ -989,6 +998,20 @@ impl GitRepository for FakeGitRepository {
})
}
fn blame_at_revision(
&self,
path: RepoPath,
revision: Oid,
) -> BoxFuture<'_, Result<git::blame::Blame>> {
self.with_state_async(false, move |state| {
state
.blames_at_revision
.get(&(path.clone(), revision))
.with_context(|| format!("failed to get blame for {path:?} at {revision}"))
.cloned()
})
}
fn stage_paths(
&self,
paths: Vec<RepoPath>,
+141 -16
View File
@@ -13,7 +13,7 @@ use time::UtcOffset;
use time::macros::format_description;
use util::command::Stdio;
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Blame {
pub entries: Vec<BlameEntry>,
pub messages: HashMap<Oid, String>,
@@ -27,8 +27,20 @@ impl Blame {
content: &Rope,
line_ending: LineEnding,
) -> Result<Self> {
let mut entries = run_git_blame(git, path, content, line_ending).await?;
let entries = run_git_blame(git, path, BlameSource::Contents(content, line_ending)).await?;
Self::with_commit_details(git, entries).await
}
pub(crate) async fn for_path_at_revision(
git: &GitBinary,
path: &RepoPath,
revision: Oid,
) -> Result<Self> {
let entries = run_git_blame(git, path, BlameSource::Revision(revision)).await?;
Self::with_commit_details(git, entries).await
}
async fn with_commit_details(git: &GitBinary, mut entries: Vec<BlameEntry>) -> Result<Self> {
let mut unique_shas = HashSet::default();
for entry in entries.iter_mut() {
@@ -63,18 +75,37 @@ const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD";
const GIT_BLAME_NO_PATH: &str = "fatal: no such path";
const BLAME_PARSE_YIELD_INTERVAL: usize = 512;
#[derive(Clone, Copy)]
enum BlameSource<'a> {
Contents(&'a Rope, LineEnding),
Revision(Oid),
}
async fn run_git_blame(
git: &GitBinary,
path: &RepoPath,
contents: &Rope,
line_ending: LineEnding,
source: BlameSource<'_>,
) -> Result<Vec<BlameEntry>> {
let mut child = {
let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str());
let _enter = span.enter();
git.build_command(&["blame", "--incremental", "--contents", "-", "--"])
let mut args = vec!["blame", "--incremental"];
let revision_string;
match source {
BlameSource::Contents(..) => args.extend(["--contents", "-"]),
BlameSource::Revision(revision) => {
revision_string = revision.to_string();
args.push(&revision_string);
}
}
args.push("--");
git.build_command(&args)
.arg(path.as_unix_str())
.stdin(Stdio::piped())
.stdin(if matches!(source, BlameSource::Contents(..)) {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
@@ -82,10 +113,7 @@ async fn run_git_blame(
.context("starting git blame process")?
};
let mut stdin = child
.stdin
.take()
.context("failed to get pipe to stdin of git blame command")?;
let stdin = child.stdin.take();
let stdout = child
.stdout
.take()
@@ -96,10 +124,14 @@ async fn run_git_blame(
.context("failed to get stderr from git blame command")?;
let write_stdin = async move {
for chunk in text::chunks_with_line_ending(contents, line_ending) {
stdin.write_all(chunk.as_bytes()).await?;
if let BlameSource::Contents(contents, line_ending) = source {
let mut stdin = stdin.context("failed to get pipe to stdin of git blame command")?;
for chunk in text::chunks_with_line_ending(contents, line_ending) {
stdin.write_all(chunk.as_bytes()).await?;
}
stdin.flush().await?;
}
stdin.flush().await.map_err(Into::into)
anyhow::Ok(())
};
let read_stdout = async move {
@@ -233,6 +265,11 @@ impl BlameEntry {
})
}
pub fn previous_sha_and_filename(&self) -> Option<(Oid, &str)> {
let (sha, filename) = self.previous.as_deref()?.split_once(' ')?;
Some((sha.parse().ok()?, filename))
}
pub fn author_offset_date_time(&self) -> Result<time::OffsetDateTime> {
if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) {
let format = format_description!("[offset_hour][offset_minute]");
@@ -338,10 +375,17 @@ impl GitBlameParser {
let is_committed = !entry.sha.is_zero();
match key {
"filename" => {
entry.filename = value.into();
entry.filename = unquote_git_path(value);
done = true;
}
"previous" => entry.previous = Some(value.into()),
"previous" => {
entry.previous = Some(match value.split_once(' ') {
Some((sha, filename)) => {
format!("{sha} {}", unquote_git_path(filename))
}
None => value.into(),
})
}
"summary" if is_committed => entry.summary = Some(value.into()),
"author" if is_committed => entry.author = Some(value.into()),
@@ -383,13 +427,55 @@ impl GitBlameParser {
}
}
fn unquote_git_path(value: &str) -> String {
let Some(quoted) = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
else {
return value.to_owned();
};
let mut bytes = Vec::with_capacity(quoted.len());
let mut input = quoted.bytes().peekable();
while let Some(byte) = input.next() {
if byte != b'\\' {
bytes.push(byte);
continue;
}
match input.next() {
Some(b'a') => bytes.push(0x07),
Some(b'b') => bytes.push(0x08),
Some(b'f') => bytes.push(0x0c),
Some(b'n') => bytes.push(b'\n'),
Some(b'r') => bytes.push(b'\r'),
Some(b't') => bytes.push(b'\t'),
Some(b'v') => bytes.push(0x0b),
Some(digit @ b'0'..=b'7') => {
let mut octal = u32::from(digit - b'0');
for _ in 0..2 {
match input.peek() {
Some(&next @ b'0'..=b'7') if octal * 8 + u32::from(next - b'0') <= 0xff => {
octal = octal * 8 + u32::from(next - b'0');
input.next();
}
_ => break,
}
}
bytes.push(octal as u8);
}
Some(other) => bytes.push(other),
None => {}
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::blame::GitBlameParser;
use super::BlameEntry;
use super::{BlameEntry, unquote_git_path};
fn parse_git_blame(output: &str) -> anyhow::Result<Vec<BlameEntry>> {
let mut parser = GitBlameParser::new();
@@ -459,4 +545,43 @@ mod tests {
let entries = parse_git_blame(&output).unwrap();
assert_eq_golden(&entries, "blame_incremental_complex");
}
#[test]
fn test_parse_git_blame_quoted_filename() {
let output = r#"6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1
author Joe Schmoe
author-mail <joe.schmoe@example.com>
author-time 1709741400
author-tz +0100
committer Joe Schmoe
committer-mail <joe.schmoe@example.com>
committer-time 1709741400
committer-tz +0100
summary Joe's cool commit
previous 486c2409237a2c627230589e567024a96751d475 "\303\274rlich \"file\".txt"
filename "\303\274rlich \"file\".txt"
"#;
let entries = parse_git_blame(output).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "ürlich \"file\".txt");
assert_eq!(
entries[0].previous.as_deref(),
Some("486c2409237a2c627230589e567024a96751d475 ürlich \"file\".txt")
);
assert_eq!(
entries[0].previous_sha_and_filename(),
Some((
"486c2409237a2c627230589e567024a96751d475".parse().unwrap(),
"ürlich \"file\".txt"
))
);
}
#[test]
fn test_unquote_git_path() {
assert_eq!(unquote_git_path("plain file.txt"), "plain file.txt");
assert_eq!(unquote_git_path(r#""a\\b\"c\td""#), "a\\b\"c\td");
assert_eq!(unquote_git_path(r#""\1\12\123""#), "\u{1}\nS");
assert_eq!(unquote_git_path(r#""\777""#), "?7");
}
}
+1 -1
View File
@@ -225,7 +225,7 @@ impl Oid {
&self.bytes[..self.format.byte_len()]
}
pub(crate) fn is_zero(&self) -> bool {
pub fn is_zero(&self) -> bool {
self.as_bytes().iter().all(|byte| *byte == 0)
}
+94
View File
@@ -889,6 +889,12 @@ pub trait GitRepository: Send + Sync {
line_ending: LineEnding,
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
fn blame_at_revision(
&self,
path: RepoPath,
revision: Oid,
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
/// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
fn path(&self) -> PathBuf;
@@ -1723,6 +1729,11 @@ impl GitRepository for RealGitRepository {
if revisions.is_empty() {
return Ok(Vec::new());
}
if let Some(revision) = revisions.iter().find(|revision| revision.contains('\n')) {
anyhow::bail!(
"revision spec {revision:?} contains a newline and cannot be passed to git cat-file --batch"
);
}
let mut process = git
.build_command(&["cat-file", "--batch"])
@@ -2373,6 +2384,21 @@ impl GitRepository for RealGitRepository {
.boxed()
}
fn blame_at_revision(
&self,
path: RepoPath,
revision: Oid,
) -> BoxFuture<'_, Result<crate::blame::Blame>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
crate::blame::Blame::for_path_at_revision(&git, &path, revision).await
})
.boxed()
}
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
let git = self.git_binary_in_worktree();
self.executor
@@ -5449,6 +5475,74 @@ mod tests {
);
}
#[gpui::test]
async fn test_blame_at_revision(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
git_init_repo(repo_dir.path());
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
let file_name = "ürlich file1";
fs::write(repo_dir.path().join(file_name), "line one\n").unwrap();
git_command(repo_dir.path(), ["add", "-A"]);
git_command(repo_dir.path(), ["commit", "-m", "First commit"]);
let first_sha = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]);
fs::write(repo_dir.path().join(file_name), "line one\nline two\n").unwrap();
git_command(repo_dir.path(), ["add", "-A"]);
git_command(repo_dir.path(), ["commit", "-m", "Second commit"]);
let second_sha = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]);
let blame_at_head = repo
.blame_at_revision(repo_path(file_name), second_sha.parse().unwrap())
.await
.unwrap();
assert_eq!(
blame_at_head
.entries
.iter()
.map(|entry| {
(
entry.sha.to_string(),
entry.range.clone(),
entry.filename.clone(),
entry.previous.clone(),
)
})
.collect::<Vec<_>>(),
vec![
(first_sha.clone(), 0..1, file_name.to_owned(), None),
(
second_sha.clone(),
1..2,
file_name.to_owned(),
Some(format!("{first_sha} {file_name}"))
),
]
);
let blame_at_first = repo
.blame_at_revision(repo_path(file_name), first_sha.parse().unwrap())
.await
.unwrap();
assert_eq!(
blame_at_first
.entries
.iter()
.map(|entry| (entry.sha.to_string(), entry.range.clone()))
.collect::<Vec<_>>(),
vec![(first_sha.clone(), 0..1)]
);
}
#[gpui::test]
async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
disable_git_global_config();
+224 -4
View File
@@ -1,9 +1,15 @@
use crate::{
commit_tooltip::{CommitAvatar, CommitTooltip, commit_tag_chips},
commit_view::CommitView,
commit_view::{CommitView, GitBlob, build_buffer, worktree_id_for_repo_path},
};
use anyhow::Context as _;
use editor::{BlameRenderer, Editor, GitBlame, MultiBuffer, hover_markdown_style};
use git::{
Oid,
blame::BlameEntry,
commit::ParsedCommitMessage,
repository::{CommitSummary, RepoPath},
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
ClipboardItem, Entity, Hsla, MouseButton, Pixels, Rems, ScrollHandle, Subscription, TextStyle,
TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
@@ -14,10 +20,12 @@ use project::{
project_settings::{InlineBlameLocation, ProjectSettings},
};
use settings::Settings as _;
use std::sync::Arc;
use theme_settings::ThemeSettings;
use time::OffsetDateTime;
use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container};
use workspace::Workspace;
use util::paths::PathStyle;
use workspace::{Workspace, notifications::NotifyTaskExt as _};
const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
const GIT_BLAME_GUTTER_MARGIN: Rems = rems(0.5);
@@ -160,6 +168,10 @@ impl BlameRenderer for GitBlameRenderer {
let short_commit_id = blame_entry.sha.display_short();
let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
let is_highlighted = editor
.read(cx)
.blame()
.is_some_and(|blame| blame.read(cx).highlighted_sha() == Some(blame_entry.sha));
let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar {
let author_email = blame_entry.author_mail.as_ref().map(|email| {
@@ -195,6 +207,9 @@ impl BlameRenderer for GitBlameRenderer {
.font(style.font())
.line_height(style.line_height)
.text_color(cx.theme().status().hint)
.when(is_highlighted, |this| {
this.bg(cx.theme().colors().element_selected)
})
.child(
h_flex()
.gap_2()
@@ -209,6 +224,8 @@ impl BlameRenderer for GitBlameRenderer {
let blame_entry = blame_entry.clone();
let details = details.clone();
let editor = editor.clone();
let repository = repository.clone();
let workspace = workspace.clone();
move |event, window, cx| {
cx.stop_propagation();
@@ -216,6 +233,8 @@ impl BlameRenderer for GitBlameRenderer {
&blame_entry,
details.as_ref(),
editor.clone(),
repository.clone(),
workspace.clone(),
event.position,
window,
cx,
@@ -506,18 +525,44 @@ impl BlameRenderer for GitBlameRenderer {
cx,
)
}
fn open_blame_revision(
&self,
path: RepoPath,
revision: Oid,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) {
open_buffer_blame_at_revision(repository, workspace, path, revision, window, cx);
}
}
fn deploy_blame_entry_context_menu(
blame_entry: &BlameEntry,
details: Option<&ParsedCommitMessage>,
editor: Entity<Editor>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
position: gpui::Point<Pixels>,
window: &mut Window,
cx: &mut App,
) {
let highlighted_sha = editor
.read(cx)
.blame()
.and_then(|blame| blame.read(cx).highlighted_sha());
let context_menu = ContextMenu::build(window, cx, move |menu, _, _| {
let sha = format!("{}", blame_entry.sha);
let blame_revision = (!blame_entry.sha.is_zero()
&& Some(blame_entry.sha) != highlighted_sha)
.then_some(blame_entry.sha)
.zip(RepoPath::new(&blame_entry.filename).ok());
let blame_previous_revision = blame_entry
.previous_sha_and_filename()
.and_then(|(sha, filename)| Some((sha, RepoPath::new(filename).ok()?)));
let has_blame_targets = blame_revision.is_some() || blame_previous_revision.is_some();
menu.on_blur_subscription(Subscription::new(|| {}))
.entry("Copy Commit SHA", None, move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(sha.clone()));
@@ -530,6 +575,35 @@ fn deploy_blame_entry_context_menu(
})
},
)
.when(has_blame_targets, |this| this.separator())
.when_some(blame_revision, |this, (revision, path)| {
let repository = repository.clone();
let workspace = workspace.clone();
this.entry("Blame Revision", None, move |window, cx| {
open_buffer_blame_at_revision(
repository.clone(),
workspace.clone(),
path.clone(),
revision,
window,
cx,
);
})
})
.when_some(blame_previous_revision, |this, (revision, path)| {
let repository = repository.clone();
let workspace = workspace.clone();
this.entry("Blame Previous Revision", None, move |window, cx| {
open_buffer_blame_at_revision(
repository.clone(),
workspace.clone(),
path.clone(),
revision,
window,
cx,
);
})
})
});
editor.update(cx, move |editor, cx| {
@@ -539,6 +613,152 @@ fn deploy_blame_entry_context_menu(
});
}
fn open_buffer_blame_at_revision(
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
path: RepoPath,
revision: Oid,
window: &mut Window,
cx: &mut App,
) {
window
.spawn(cx, {
let workspace = workspace.clone();
async move |cx| {
let (language_registry, worktree_id) =
workspace.read_with(cx, |workspace, cx| {
let project = workspace.project().read(cx);
(
project.languages().clone(),
worktree_id_for_repo_path(repository.read(cx), project, &path, cx),
)
})?;
let worktree_id = worktree_id.context("project has no worktrees")?;
let file_name = path
.file_name()
.map(|name| name.to_string())
.unwrap_or_else(|| path.display(PathStyle::local()).to_string());
let display_name = format!("{file_name} @ {}", revision.display_short());
let activated_existing = workspace.update_in(cx, |workspace, window, cx| {
activate_existing_blame_editor(
workspace,
&repository,
&path,
revision,
window,
cx,
)
})?;
if activated_existing {
return Ok(());
}
let (content, blame) = repository
.update(cx, |repository, cx| {
repository.blame_buffer_at_revision(path.clone(), revision, cx)
})
.await?;
let file = Arc::new(GitBlob {
path: path.clone(),
worktree_id,
is_deleted: false,
is_binary: false,
display_name: display_name.clone(),
}) as Arc<dyn language::File>;
let buffer = build_buffer(content, file, &language_registry, cx).await?;
workspace.update_in(cx, |workspace, window, cx| {
if activate_existing_blame_editor(
workspace,
&repository,
&path,
revision,
window,
cx,
) {
return;
}
let project = workspace.project().clone();
let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let editor = cx.new(|cx| {
let mut editor = Editor::for_multibuffer(
multi_buffer.clone(),
Some(project.clone()),
window,
cx,
);
editor.set_read_only(true);
editor.set_should_serialize(false, cx);
editor
});
let git_blame = cx.new(|cx| {
GitBlame::new_static(
multi_buffer,
project,
repository,
[(buffer, blame)],
Some(revision),
cx,
)
});
editor.update(cx, |editor, cx| editor.set_blame(git_blame, window, cx));
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
})
}
})
.detach_and_notify_err(workspace, window, cx);
}
fn activate_existing_blame_editor(
workspace: &mut Workspace,
repository: &Entity<Repository>,
path: &RepoPath,
revision: Oid,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool {
match existing_blame_editor(workspace, repository, path, revision, cx) {
Some(existing) => {
workspace.activate_item(&existing, true, true, window, cx);
true
}
None => false,
}
}
fn existing_blame_editor(
workspace: &Workspace,
repository: &Entity<Repository>,
path: &RepoPath,
revision: Oid,
cx: &App,
) -> Option<Entity<Editor>> {
workspace
.panes()
.iter()
.flat_map(|pane| pane.read(cx).items())
.find_map(|item| {
let editor = item.downcast::<Editor>()?;
let blame = editor.read(cx).blame()?;
if blame.read(cx).highlighted_sha() != Some(revision) {
return None;
}
let buffer = editor.read(cx).buffer().read(cx).as_singleton()?;
let buffer_id = buffer.read(cx).remote_id();
if blame.read(cx).repository(cx, buffer_id).as_ref() != Some(repository) {
return None;
}
let file = buffer.read(cx).file()?;
let blob = (file.as_ref() as &dyn std::any::Any).downcast_ref::<GitBlob>()?;
(blob.path == *path).then_some(editor)
})
}
fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
match blame_entry.author_offset_date_time() {
Ok(timestamp) => {
+41 -21
View File
@@ -85,14 +85,36 @@ pub struct CommitView {
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
remote: Option<GitRemote>,
_load_diff_task: Task<Result<()>>,
}
struct GitBlob {
path: RepoPath,
worktree_id: WorktreeId,
is_deleted: bool,
is_binary: bool,
display_name: String,
pub(crate) struct GitBlob {
pub(crate) path: RepoPath,
pub(crate) worktree_id: WorktreeId,
pub(crate) is_deleted: bool,
pub(crate) is_binary: bool,
pub(crate) display_name: String,
}
pub(crate) fn worktree_id_for_repo_path(
repository: &Repository,
project: &Project,
path: &RepoPath,
cx: &App,
) -> Option<WorktreeId> {
repository
.repo_path_to_project_path(path, cx)
.map(|project_path| project_path.worktree_id)
.or_else(|| {
let (worktree, _) = project.find_worktree(&repository.work_directory_abs_path, cx)?;
Some(worktree.read(cx).id())
})
.or_else(|| {
project
.worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).id())
})
}
struct CommitDiffAddon {
@@ -287,15 +309,10 @@ impl CommitView {
});
let commit_sha = Arc::<str>::from(commit.sha.as_ref());
let first_worktree_id = project
.read(cx)
.worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).id());
let repository_clone = repository.clone();
let project_clone = project.clone();
cx.spawn_in(window, async move |this, cx| {
let load_diff_task = cx.spawn_in(window, async move |this, cx| {
let mut binary_buffer_ids: HashSet<language::BufferId> = HashSet::default();
let mut file_statuses: HashMap<language::BufferId, FileStatus> = HashMap::default();
@@ -318,11 +335,13 @@ impl CommitView {
};
let old_text = if is_binary { None } else { raw_old_text };
let worktree_id = repository_clone
.update(cx, |repository, cx| {
repository
.repo_path_to_project_path(&file.path, cx)
.map(|path| path.worktree_id)
.or(first_worktree_id)
.read_with(cx, |repository, cx| {
worktree_id_for_repo_path(
repository,
project_clone.read(cx),
&file.path,
cx,
)
})
.context("project has no worktrees")?;
let short_sha = commit_sha
@@ -453,8 +472,7 @@ impl CommitView {
})?;
anyhow::Ok(())
})
.detach();
});
let snapshot = repository.read(cx).snapshot();
let remote_url = snapshot
@@ -483,6 +501,7 @@ impl CommitView {
project,
workspace,
remote,
_load_diff_task: load_diff_task,
}
}
@@ -957,7 +976,7 @@ impl language::File for GitBlob {
}
}
async fn build_buffer(
pub(crate) async fn build_buffer(
mut text: String,
blob: Arc<dyn File>,
language_registry: &Arc<language::LanguageRegistry>,
@@ -1235,6 +1254,7 @@ impl Item for CommitView {
project: self.project.clone(),
workspace: self.workspace.clone(),
remote: self.remote.clone(),
_load_diff_task: Task::ready(Ok(())),
}
})))
}
+192 -57
View File
@@ -39,7 +39,7 @@ use git::{
FileHistoryChangedFileSets, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint,
InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput,
RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree,
delete_branch_flag,
delete_branch_flag, is_binary_content,
},
stash::{GitStash, StashEntry},
status::{
@@ -860,6 +860,7 @@ impl GitStore {
client.add_entity_message_handler(Self::handle_update_diff_bases);
client.add_entity_request_handler(Self::handle_get_permalink_to_line);
client.add_entity_request_handler(Self::handle_blame_buffer);
client.add_entity_request_handler(Self::handle_blame_buffer_at_revision);
client.add_entity_message_handler(Self::handle_update_repository);
client.add_entity_message_handler(Self::handle_remove_repository);
client.add_entity_request_handler(Self::handle_git_clone);
@@ -4628,6 +4629,39 @@ impl GitStore {
Ok(serialize_blame_buffer_response(blame))
}
async fn handle_blame_buffer_at_revision(
this: Entity<Self>,
envelope: TypedEnvelope<proto::BlameBufferAtRevision>,
mut cx: AsyncApp,
) -> Result<proto::BlameBufferAtRevisionResponse> {
let repository_id = RepositoryId(envelope.payload.repository_id);
let path = RepoPath::from_proto(&envelope.payload.path)?;
let revision = envelope
.payload
.revision
.parse::<git::Oid>()
.with_context(|| format!("invalid revision {:?}", envelope.payload.revision))?;
let (content, blame) = this
.update(&mut cx, |this, cx| {
let repository = this.repositories().get(&repository_id)?;
Some(repository.update(cx, |repository, cx| {
repository.blame_buffer_at_revision(path, revision, cx)
}))
})
.context("missing repository")?
.await?;
Ok(proto::BlameBufferAtRevisionResponse {
content,
entries: blame
.entries
.into_iter()
.map(serialize_blame_entry)
.collect(),
messages: serialize_commit_messages(blame.messages),
tag_names: serialize_commit_tag_names(blame.tag_names),
})
}
async fn handle_get_permalink_to_line(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GetPermalinkToLine>,
@@ -9810,6 +9844,60 @@ impl Repository {
)
}
pub fn blame_buffer_at_revision(
&mut self,
path: RepoPath,
revision: Oid,
cx: &App,
) -> Task<Result<(String, git::blame::Blame)>> {
let repository_id = self.snapshot.id;
let rx = self.send_job("blame_buffer_at_revision", None, {
let path = path.clone();
move |state, _| async move {
match state {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
let content_task = backend
.load_revisions(vec![format!("{revision}:{}", path.as_unix_str())]);
let blame_task = backend.blame_at_revision(path.clone(), revision);
let (mut contents, blame) = futures::try_join!(content_task, blame_task)?;
let content = contents.pop().flatten().with_context(|| {
format!(
"cannot load {:?} at revision {revision}: the file is missing or binary",
path.as_ref()
)
})?;
anyhow::Ok((content, blame))
}
RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => {
let response = client
.request(proto::BlameBufferAtRevision {
project_id: project_id.to_proto(),
repository_id: repository_id.0,
path: path.as_unix_str().to_owned(),
revision: revision.to_string(),
})
.await?;
let blame = blame_from_proto(
response.entries,
response.messages,
response.tag_names,
);
Ok((response.content, blame))
}
}
}
});
cx.spawn(move |_: &mut AsyncApp| async move {
let (content, blame) = rx.await??;
anyhow::ensure!(
!is_binary_content(content.as_bytes()),
"cannot blame binary file {:?} at revision {revision}",
path.as_ref()
);
Ok((content, blame))
})
}
fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task<Result<String>> {
let repository_id = self.snapshot.id;
let rx = self.send_job("load_blob_content", None, move |state, _| async move {
@@ -10373,36 +10461,112 @@ fn serialize_blame_buffer_response(blame: Option<git::blame::Blame>) -> proto::B
let entries = blame
.entries
.into_iter()
.map(|entry| proto::BlameEntry {
sha: entry.sha.as_bytes().into(),
start_line: entry.range.start,
end_line: entry.range.end,
original_line_number: entry.original_line_number,
author: entry.author,
author_mail: entry.author_mail,
author_time: entry.author_time,
author_tz: entry.author_tz,
committer: entry.committer_name,
committer_mail: entry.committer_email,
committer_time: entry.committer_time,
committer_tz: entry.committer_tz,
summary: entry.summary,
previous: entry.previous,
filename: entry.filename,
})
.map(serialize_blame_entry)
.collect::<Vec<_>>();
let messages = serialize_commit_messages(blame.messages);
let tag_names = serialize_commit_tag_names(blame.tag_names);
let messages = blame
.messages
proto::BlameBufferResponse {
blame_response: Some(proto::blame_buffer_response::BlameResponse {
entries,
messages,
tag_names,
}),
}
}
fn serialize_blame_entry(entry: git::blame::BlameEntry) -> proto::BlameEntry {
proto::BlameEntry {
sha: entry.sha.as_bytes().into(),
start_line: entry.range.start,
end_line: entry.range.end,
original_line_number: entry.original_line_number,
author: entry.author,
author_mail: entry.author_mail,
author_time: entry.author_time,
author_tz: entry.author_tz,
committer: entry.committer_name,
committer_mail: entry.committer_email,
committer_time: entry.committer_time,
committer_tz: entry.committer_tz,
summary: entry.summary,
previous: entry.previous,
filename: entry.filename,
}
}
fn deserialize_blame_entry(entry: proto::BlameEntry) -> Option<git::blame::BlameEntry> {
Some(git::blame::BlameEntry {
sha: git::Oid::from_bytes(&entry.sha).ok()?,
range: entry.start_line..entry.end_line,
original_line_number: entry.original_line_number,
committer_name: entry.committer,
committer_time: entry.committer_time,
committer_tz: entry.committer_tz,
committer_email: entry.committer_mail,
author: entry.author,
author_mail: entry.author_mail,
author_time: entry.author_time,
author_tz: entry.author_tz,
summary: entry.summary,
previous: entry.previous,
filename: entry.filename,
})
}
fn serialize_commit_messages(messages: HashMap<git::Oid, String>) -> Vec<proto::CommitMessage> {
messages
.into_iter()
.map(|(oid, message)| proto::CommitMessage {
oid: oid.as_bytes().into(),
message,
})
.collect::<Vec<_>>();
.collect()
}
proto::BlameBufferResponse {
blame_response: Some(proto::blame_buffer_response::BlameResponse { entries, messages }),
fn deserialize_commit_message(message: proto::CommitMessage) -> Option<(git::Oid, String)> {
Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))
}
fn serialize_commit_tag_names(
tag_names: HashMap<git::Oid, Vec<String>>,
) -> Vec<proto::CommitTagNames> {
tag_names
.into_iter()
.map(|(oid, tag_names)| proto::CommitTagNames {
oid: oid.as_bytes().into(),
tag_names,
})
.collect()
}
fn deserialize_commit_tag_names(
tag_names: proto::CommitTagNames,
) -> Option<(git::Oid, Vec<String>)> {
Some((
git::Oid::from_bytes(&tag_names.oid).ok()?,
tag_names.tag_names,
))
}
fn blame_from_proto(
entries: Vec<proto::BlameEntry>,
messages: Vec<proto::CommitMessage>,
tag_names: Vec<proto::CommitTagNames>,
) -> git::blame::Blame {
git::blame::Blame {
entries: entries
.into_iter()
.filter_map(deserialize_blame_entry)
.collect(),
messages: messages
.into_iter()
.filter_map(deserialize_commit_message)
.collect(),
tag_names: tag_names
.into_iter()
.filter_map(deserialize_commit_tag_names)
.collect(),
}
}
@@ -10410,40 +10574,11 @@ fn deserialize_blame_buffer_response(
response: proto::BlameBufferResponse,
) -> Option<git::blame::Blame> {
let response = response.blame_response?;
let entries = response
.entries
.into_iter()
.filter_map(|entry| {
Some(git::blame::BlameEntry {
sha: git::Oid::from_bytes(&entry.sha).ok()?,
range: entry.start_line..entry.end_line,
original_line_number: entry.original_line_number,
committer_name: entry.committer,
committer_time: entry.committer_time,
committer_tz: entry.committer_tz,
committer_email: entry.committer_mail,
author: entry.author,
author_mail: entry.author_mail,
author_time: entry.author_time,
author_tz: entry.author_tz,
summary: entry.summary,
previous: entry.previous,
filename: entry.filename,
})
})
.collect::<Vec<_>>();
let messages = response
.messages
.into_iter()
.filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message)))
.collect::<HashMap<_, _>>();
Some(Blame {
entries,
messages,
tag_names: Default::default(),
})
Some(blame_from_proto(
response.entries,
response.messages,
response.tag_names,
))
}
fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource {
+20
View File
@@ -525,6 +525,7 @@ message BlameBufferResponse {
repeated CommitMessage messages = 2;
reserved 3;
reserved 4;
repeated CommitTagNames tag_names = 5;
}
optional BlameResponse blame_response = 5;
@@ -532,6 +533,25 @@ message BlameBufferResponse {
reserved 1 to 4;
}
message BlameBufferAtRevision {
uint64 project_id = 1;
uint64 repository_id = 2;
string path = 3;
string revision = 4;
}
message BlameBufferAtRevisionResponse {
string content = 1;
repeated BlameEntry entries = 2;
repeated CommitMessage messages = 3;
repeated CommitTagNames tag_names = 4;
}
message CommitTagNames {
bytes oid = 1;
repeated string tag_names = 2;
}
message GetDefaultBranch {
uint64 project_id = 1;
uint64 repository_id = 2;
+3 -1
View File
@@ -505,7 +505,9 @@ message Envelope {
RefreshDocumentColors refresh_document_colors = 474;
RefreshDocumentLinks refresh_document_links = 475;
RefreshFoldingRanges refresh_folding_ranges = 476;
RefreshDocumentSymbols refresh_document_symbols = 477; // current max
RefreshDocumentSymbols refresh_document_symbols = 477;
BlameBufferAtRevision blame_buffer_at_revision = 478;
BlameBufferAtRevisionResponse blame_buffer_at_revision_response = 479; // current max
}
reserved 87 to 88;
+4
View File
@@ -358,6 +358,8 @@ messages!(
(GetTreeDiffResponse, Background),
(GetBlobContent, Background),
(GetBlobContentResponse, Background),
(BlameBufferAtRevision, Background),
(BlameBufferAtRevisionResponse, Background),
(LoadCommitTemplate, Background),
(LoadCommitTemplateResponse, Background),
(GitClone, Background),
@@ -603,6 +605,7 @@ request_messages!(
(PullWorkspaceDiagnostics, Ack),
(GetDefaultBranch, GetDefaultBranchResponse),
(GetBlobContent, GetBlobContentResponse),
(BlameBufferAtRevision, BlameBufferAtRevisionResponse),
(LoadCommitTemplate, LoadCommitTemplateResponse),
(GetTreeDiff, GetTreeDiffResponse),
(GitClone, GitCloneResponse),
@@ -827,6 +830,7 @@ entity_messages!(
GetDefaultBranch,
GetTreeDiff,
GetBlobContent,
BlameBufferAtRevision,
LoadCommitTemplate,
GitClone,
GetAgentServerCommand,