Add git blame
(#8889)
This adds a new action to the editor: `editor: toggle git blame`. When used it turns on a sidebar containing `git blame` information for the currently open buffer. The git blame information is updated when the buffer changes. It handles additions, deletions, modifications, changes to the underlying git data (new commits, changed commits, ...), file saves. It also handles folding and wrapping lines correctly. When the user hovers over a commit, a tooltip displays information for the commit that introduced the line. If the repository has a remote with the name `origin` configured, then clicking on a blame entry opens the permalink to the commit on the code host. Users can right-click on a blame entry to get a context menu which allows them to copy the SHA of the commit. The feature also works on shared projects, e.g. when collaborating a peer can request `git blame` data. As of this PR, Zed now comes bundled with a `git` binary so that users don't have to have `git` installed locally to use this feature. ### Screenshots    ### TODOs - [x] Bundling `git` binary ### Release Notes Release Notes: - Added `editor: toggle git blame` command that toggles a sidebar with git blame information for the current buffer. --------- Co-authored-by: Antonio <antonio@zed.dev> Co-authored-by: Piotr <piotr@zed.dev> Co-authored-by: Bennet <bennetbo@gmx.de> Co-authored-by: Mikayla <mikayla@zed.dev>
This commit is contained in:
parent
e2d6b0deba
commit
7f54935324
39 changed files with 3760 additions and 157 deletions
|
@ -26,6 +26,7 @@ tempfile.workspace = true
|
|||
lazy_static.workspace = true
|
||||
parking_lot.workspace = true
|
||||
smol.workspace = true
|
||||
git.workspace = true
|
||||
git2.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
|
|
|
@ -9,7 +9,7 @@ use async_tar::Archive;
|
|||
use futures::{future::BoxFuture, AsyncRead, Stream, StreamExt};
|
||||
use git2::Repository as LibGitRepository;
|
||||
use parking_lot::Mutex;
|
||||
use repository::GitRepository;
|
||||
use repository::{GitRepository, RealGitRepository};
|
||||
use rope::Rope;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use smol::io::AsyncReadExt;
|
||||
|
@ -111,7 +111,16 @@ pub struct Metadata {
|
|||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
pub struct RealFs;
|
||||
#[derive(Default)]
|
||||
pub struct RealFs {
|
||||
git_binary_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl RealFs {
|
||||
pub fn new(git_binary_path: Option<PathBuf>) -> Self {
|
||||
Self { git_binary_path }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Fs for RealFs {
|
||||
|
@ -431,7 +440,10 @@ impl Fs for RealFs {
|
|||
LibGitRepository::open(dotgit_path)
|
||||
.log_err()
|
||||
.map::<Arc<Mutex<dyn GitRepository>>, _>(|libgit_repository| {
|
||||
Arc::new(Mutex::new(libgit_repository))
|
||||
Arc::new(Mutex::new(RealGitRepository::new(
|
||||
libgit_repository,
|
||||
self.git_binary_path.clone(),
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -824,6 +836,17 @@ impl FakeFs {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(&Path, git::blame::Blame)>) {
|
||||
self.with_git_state(dot_git, true, |state| {
|
||||
state.blames.clear();
|
||||
state.blames.extend(
|
||||
blames
|
||||
.into_iter()
|
||||
.map(|(path, blame)| (path.to_path_buf(), blame)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_status_for_repo_via_working_copy_change(
|
||||
&self,
|
||||
dot_git: &Path,
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use collections::HashMap;
|
||||
use git::blame::Blame;
|
||||
use git2::{BranchType, StatusShow};
|
||||
use parking_lot::Mutex;
|
||||
use rope::Rope;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
|
@ -53,6 +55,8 @@ pub trait GitRepository: Send {
|
|||
fn branches(&self) -> Result<Vec<Branch>>;
|
||||
fn change_branch(&self, _: &str) -> Result<()>;
|
||||
fn create_branch(&self, _: &str) -> Result<()>;
|
||||
|
||||
fn blame(&self, path: &Path, content: Rope) -> Result<git::blame::Blame>;
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for dyn GitRepository {
|
||||
|
@ -61,9 +65,23 @@ impl std::fmt::Debug for dyn GitRepository {
|
|||
}
|
||||
}
|
||||
|
||||
impl GitRepository for LibGitRepository {
|
||||
pub struct RealGitRepository {
|
||||
pub repository: LibGitRepository,
|
||||
pub git_binary_path: PathBuf,
|
||||
}
|
||||
|
||||
impl RealGitRepository {
|
||||
pub fn new(repository: LibGitRepository, git_binary_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GitRepository for RealGitRepository {
|
||||
fn reload_index(&self) {
|
||||
if let Ok(mut index) = self.index() {
|
||||
if let Ok(mut index) = self.repository.index() {
|
||||
_ = index.read(false);
|
||||
}
|
||||
}
|
||||
|
@ -85,7 +103,7 @@ impl GitRepository for LibGitRepository {
|
|||
Ok(Some(String::from_utf8(content)?))
|
||||
}
|
||||
|
||||
match logic(self, relative_file_path) {
|
||||
match logic(&self.repository, relative_file_path) {
|
||||
Ok(value) => return value,
|
||||
Err(err) => log::error!("Error loading head text: {:?}", err),
|
||||
}
|
||||
|
@ -93,18 +111,18 @@ impl GitRepository for LibGitRepository {
|
|||
}
|
||||
|
||||
fn remote_url(&self, name: &str) -> Option<String> {
|
||||
let remote = self.find_remote(name).ok()?;
|
||||
let remote = self.repository.find_remote(name).ok()?;
|
||||
remote.url().map(|url| url.to_string())
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
let head = self.head().log_err()?;
|
||||
let head = self.repository.head().log_err()?;
|
||||
let branch = String::from_utf8_lossy(head.shorthand_bytes());
|
||||
Some(branch.to_string())
|
||||
}
|
||||
|
||||
fn head_sha(&self) -> Option<String> {
|
||||
let head = self.head().ok()?;
|
||||
let head = self.repository.head().ok()?;
|
||||
head.target().map(|oid| oid.to_string())
|
||||
}
|
||||
|
||||
|
@ -115,7 +133,7 @@ impl GitRepository for LibGitRepository {
|
|||
options.pathspec(path_prefix);
|
||||
options.show(StatusShow::Index);
|
||||
|
||||
if let Some(statuses) = self.statuses(Some(&mut options)).log_err() {
|
||||
if let Some(statuses) = self.repository.statuses(Some(&mut options)).log_err() {
|
||||
for status in statuses.iter() {
|
||||
let path = RepoPath(PathBuf::try_from_bytes(status.path_bytes()).unwrap());
|
||||
let status = status.status();
|
||||
|
@ -132,7 +150,7 @@ impl GitRepository for LibGitRepository {
|
|||
fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus> {
|
||||
// If the file has not changed since it was added to the index, then
|
||||
// there can't be any changes.
|
||||
if matches_index(self, path, mtime) {
|
||||
if matches_index(&self.repository, path, mtime) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
@ -144,7 +162,7 @@ impl GitRepository for LibGitRepository {
|
|||
options.include_unmodified(true);
|
||||
options.show(StatusShow::Workdir);
|
||||
|
||||
let statuses = self.statuses(Some(&mut options)).log_err()?;
|
||||
let statuses = self.repository.statuses(Some(&mut options)).log_err()?;
|
||||
let status = statuses.get(0).and_then(|s| read_status(s.status()));
|
||||
status
|
||||
}
|
||||
|
@ -160,17 +178,17 @@ impl GitRepository for LibGitRepository {
|
|||
// If the file has not changed since it was added to the index, then
|
||||
// there's no need to examine the working directory file: just compare
|
||||
// the blob in the index to the one in the HEAD commit.
|
||||
if matches_index(self, path, mtime) {
|
||||
if matches_index(&self.repository, path, mtime) {
|
||||
options.show(StatusShow::Index);
|
||||
}
|
||||
|
||||
let statuses = self.statuses(Some(&mut options)).log_err()?;
|
||||
let statuses = self.repository.statuses(Some(&mut options)).log_err()?;
|
||||
let status = statuses.get(0).and_then(|s| read_status(s.status()));
|
||||
status
|
||||
}
|
||||
|
||||
fn branches(&self) -> Result<Vec<Branch>> {
|
||||
let local_branches = self.branches(Some(BranchType::Local))?;
|
||||
let local_branches = self.repository.branches(Some(BranchType::Local))?;
|
||||
let valid_branches = local_branches
|
||||
.filter_map(|branch| {
|
||||
branch.ok().and_then(|(branch, _)| {
|
||||
|
@ -192,11 +210,11 @@ impl GitRepository for LibGitRepository {
|
|||
Ok(valid_branches)
|
||||
}
|
||||
fn change_branch(&self, name: &str) -> Result<()> {
|
||||
let revision = self.find_branch(name, BranchType::Local)?;
|
||||
let revision = self.repository.find_branch(name, BranchType::Local)?;
|
||||
let revision = revision.get();
|
||||
let as_tree = revision.peel_to_tree()?;
|
||||
self.checkout_tree(as_tree.as_object(), None)?;
|
||||
self.set_head(
|
||||
self.repository.checkout_tree(as_tree.as_object(), None)?;
|
||||
self.repository.set_head(
|
||||
revision
|
||||
.name()
|
||||
.ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
|
||||
|
@ -204,11 +222,29 @@ impl GitRepository for LibGitRepository {
|
|||
Ok(())
|
||||
}
|
||||
fn create_branch(&self, name: &str) -> Result<()> {
|
||||
let current_commit = self.head()?.peel_to_commit()?;
|
||||
self.branch(name, ¤t_commit, false)?;
|
||||
let current_commit = self.repository.head()?.peel_to_commit()?;
|
||||
self.repository.branch(name, ¤t_commit, false)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn blame(&self, path: &Path, content: Rope) -> Result<git::blame::Blame> {
|
||||
let git_dir_path = self.repository.path();
|
||||
let working_directory = git_dir_path.parent().with_context(|| {
|
||||
format!("failed to get git working directory for {:?}", git_dir_path)
|
||||
})?;
|
||||
|
||||
const REMOTE_NAME: &str = "origin";
|
||||
let remote_url = self.remote_url(REMOTE_NAME);
|
||||
|
||||
git::blame::Blame::for_path(
|
||||
&self.git_binary_path,
|
||||
working_directory,
|
||||
path,
|
||||
&content,
|
||||
remote_url,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_index(repo: &LibGitRepository, path: &RepoPath, mtime: SystemTime) -> bool {
|
||||
|
@ -251,6 +287,7 @@ pub struct FakeGitRepository {
|
|||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FakeGitRepositoryState {
|
||||
pub index_contents: HashMap<PathBuf, String>,
|
||||
pub blames: HashMap<PathBuf, Blame>,
|
||||
pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
|
||||
pub branch_name: Option<String>,
|
||||
}
|
||||
|
@ -317,6 +354,15 @@ impl GitRepository for FakeGitRepository {
|
|||
state.branch_name = Some(name.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn blame(&self, path: &Path, _content: Rope) -> Result<git::blame::Blame> {
|
||||
let state = self.state.lock();
|
||||
state
|
||||
.blames
|
||||
.get(path)
|
||||
.with_context(|| format!("failed to get blame for {:?}", path))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue