From 4b6690fc0a5a500d96ff00ac1731480bd214399b Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 01:11:57 +0000
Subject: [PATCH] feat: implement `blame` command in `helix-vcs`

---
 Cargo.lock           | 16 +++++++++++++
 helix-vcs/Cargo.toml |  2 +-
 helix-vcs/src/git.rs | 54 +++++++++++++++++++++++++++++++++++++++++++-
 helix-vcs/src/lib.rs | 15 ++++++++++++
 4 files changed, 85 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index e731eeaf..260c516e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -509,6 +509,7 @@ checksum = "736f14636705f3a56ea52b553e67282519418d9a35bb1e90b3a9637a00296b68"
 dependencies = [
  "gix-actor",
  "gix-attributes",
+ "gix-blame",
  "gix-command",
  "gix-commitgraph",
  "gix-config",
@@ -591,6 +592,21 @@ dependencies = [
  "thiserror 2.0.12",
 ]
 
+[[package]]
+name = "gix-blame"
+version = "0.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adc795e239a2347eb50ed18b8c529382dd8b62439c57277f79af3d8f8928a986"
+dependencies = [
+ "gix-diff",
+ "gix-hash",
+ "gix-object",
+ "gix-trace",
+ "gix-traverse",
+ "gix-worktree",
+ "thiserror 2.0.12",
+]
+
 [[package]]
 name = "gix-chunk"
 version = "0.4.11"
diff --git a/helix-vcs/Cargo.toml b/helix-vcs/Cargo.toml
index 289c334a..905e1247 100644
--- a/helix-vcs/Cargo.toml
+++ b/helix-vcs/Cargo.toml
@@ -19,7 +19,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "p
 parking_lot = "0.12"
 arc-swap = { version = "1.7.1" }
 
-gix = { version = "0.70.0", features = ["attributes", "status"], default-features = false, optional = true }
+gix = { version = "0.70.0", features = ["attributes", "status", "blame"], default-features = false, optional = true }
 imara-diff = "0.1.8"
 anyhow = "1"
 
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index b8ddd79f..c395bd1d 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -2,10 +2,11 @@ use anyhow::{bail, Context, Result};
 use arc_swap::ArcSwap;
 use gix::filter::plumbing::driver::apply::Delay;
 use std::io::Read;
+use std::os::unix::ffi::OsStrExt;
 use std::path::Path;
 use std::sync::Arc;
 
-use gix::bstr::ByteSlice;
+use gix::bstr::{BStr, ByteSlice};
 use gix::diff::Rewrites;
 use gix::dir::entry::Status;
 use gix::objs::tree::EntryKind;
@@ -125,6 +126,57 @@ fn open_repo(path: &Path) -> Result<ThreadSafeRepository> {
     Ok(res)
 }
 
+pub struct BlameInformation {
+    pub commit_hash: String,
+    pub author_name: String,
+    pub commit_date: String,
+    pub commit_message: String,
+}
+
+/// Emulates the result of running `git blame` from the command line.
+pub fn blame(file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformation> {
+    let repo_dir = get_repo_dir(file)?;
+    let repo = open_repo(repo_dir)
+        .context("failed to open git repo")?
+        .to_thread_local();
+
+    let suspect = repo.head()?.peel_to_commit_in_place()?;
+    let traverse = gix::traverse::commit::topo::Builder::from_iters(
+        &repo.objects,
+        [suspect.id],
+        None::<Vec<gix::ObjectId>>,
+    )
+    .build()?;
+    let mut resource_cache = repo.diff_resource_cache_for_tree_diff()?;
+    let latest_commit_id = gix::blame::file(
+        &repo.objects,
+        traverse,
+        &mut resource_cache,
+        BStr::new(file.as_os_str().as_bytes()),
+        Some(range),
+    )?
+    .entries
+    .first()
+    .context("No commits found")?
+    .commit_id;
+
+    let commit = repo.find_commit(latest_commit_id)?;
+
+    let author = commit.author()?;
+
+    let commit_date = author.time.format(gix::date::time::format::SHORT);
+    let author_name = author.name.to_string();
+    let commit_hash = commit.short_id()?.to_string();
+    let commit_message = commit.message()?.title.to_string();
+
+    Ok(BlameInformation {
+        commit_hash,
+        author_name,
+        commit_date,
+        commit_message,
+    })
+}
+
 /// Emulates the result of running `git status` from the command line.
 fn status(repo: &Repository, f: impl Fn(Result<FileChange>) -> bool) -> Result<()> {
     let work_dir = repo
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index 539be779..a4782718 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -1,5 +1,6 @@
 use anyhow::{anyhow, bail, Result};
 use arc_swap::ArcSwap;
+use git::BlameInformation;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
@@ -48,6 +49,12 @@ impl DiffProviderRegistry {
             })
     }
 
+    pub fn blame(&self, file: &Path, range: std::ops::Range<u32>) -> Option<BlameInformation> {
+        self.providers
+            .iter()
+            .find_map(|provider| provider.blame(file, range.clone()).ok())
+    }
+
     /// Fire-and-forget changed file iteration. Runs everything in a background task. Keeps
     /// iteration until `on_change` returns `false`.
     pub fn for_each_changed_file(
@@ -108,6 +115,14 @@ impl DiffProvider {
         }
     }
 
+    fn blame(&self, file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformation> {
+        match self {
+            #[cfg(feature = "git")]
+            Self::Git => git::blame(file, range),
+            Self::None => bail!("No blame support compiled in"),
+        }
+    }
+
     fn for_each_changed_file(
         &self,
         cwd: &Path,