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 01/38] 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,

From 0eadcd46ef95697b55381b08e0942f1936e95034 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 02:45:28 +0000
Subject: [PATCH 02/38] fix: use relative path when finding file

style: cargo fmt

_
---
 helix-term/src/commands/typed.rs | 37 +++++++++++++++++++++++++++
 helix-vcs/src/git.rs             | 44 ++++++++++++++++++++++----------
 helix-vcs/src/lib.rs             | 12 ++++++---
 3 files changed, 76 insertions(+), 17 deletions(-)

diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 9661689c..d41dbbd5 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -70,6 +70,32 @@ impl CommandCompleter {
     }
 }
 
+fn blame(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
+    if event != PromptEvent::Validate {
+        return Ok(());
+    }
+
+    let (view, doc) = current_ref!(cx.editor);
+    let selection = doc.selection(view.id);
+    let (from, to) = selection
+        .line_ranges(doc.text().slice(..))
+        .next()
+        .map(|(from, to)| (from as u32, to as u32))
+        .context("No selections")?;
+    let result = cx
+        .editor
+        .diff_providers
+        .blame(doc.path().context("Not in a file")?, from..to)
+        .inspect_err(|err| {
+            log::error!("Could not get blame: {err}");
+        })
+        .context("No blame information")?;
+
+    cx.editor.set_status(result.to_string());
+
+    Ok(())
+}
+
 fn quit(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     log::debug!("quitting...");
 
@@ -3555,6 +3581,17 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
             ..Signature::DEFAULT
         },
     },
+    TypableCommand {
+        name: "blame-line-range",
+        aliases: &["blame"],
+        doc: "Blames a range of lines. No args: Blame selection. 1 arg: Blame line. 2 args: Represents (from, to) line range to git blame.",
+        fun: blame,
+        completer: CommandCompleter::none(),
+        signature: Signature {
+            positionals: (0, Some(2)),
+            ..Signature::DEFAULT
+        },
+    },
 ];
 
 pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> =
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index c395bd1d..80021576 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -1,8 +1,8 @@
 use anyhow::{bail, Context, Result};
 use arc_swap::ArcSwap;
+use core::fmt;
 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;
 
@@ -133,6 +133,16 @@ pub struct BlameInformation {
     pub commit_message: String,
 }
 
+impl fmt::Display for BlameInformation {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{} - {} - {} - {}",
+            self.author_name, self.commit_date, self.commit_message, self.commit_hash
+        )
+    }
+}
+
 /// 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)?;
@@ -141,18 +151,30 @@ pub fn blame(file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformatio
         .to_thread_local();
 
     let suspect = repo.head()?.peel_to_commit_in_place()?;
-    let traverse = gix::traverse::commit::topo::Builder::from_iters(
+
+    let relative_path = file
+        .strip_prefix(
+            repo.path()
+                .parent()
+                .context("Could not get parent path of repository")?,
+        )
+        .unwrap_or(file)
+        .to_str()
+        .context("Could not convert path to string")?;
+
+    let traverse_all_commits = 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,
+        traverse_all_commits,
         &mut resource_cache,
-        BStr::new(file.as_os_str().as_bytes()),
+        BStr::new(relative_path),
         Some(range),
     )?
     .entries
@@ -161,19 +183,13 @@ pub fn blame(file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformatio
     .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,
+        commit_hash: commit.short_id()?.to_string(),
+        author_name: author.name.to_string(),
+        commit_date: author.time.format(gix::date::time::format::SHORT),
+        commit_message: commit.message()?.title.to_string(),
     })
 }
 
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index a4782718..9b73a42e 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -1,4 +1,4 @@
-use anyhow::{anyhow, bail, Result};
+use anyhow::{anyhow, bail, Context, Result};
 use arc_swap::ArcSwap;
 use git::BlameInformation;
 use std::{
@@ -49,10 +49,16 @@ impl DiffProviderRegistry {
             })
     }
 
-    pub fn blame(&self, file: &Path, range: std::ops::Range<u32>) -> Option<BlameInformation> {
+    pub fn blame(
+        &self,
+        file: &Path,
+        range: std::ops::Range<u32>,
+    ) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
-            .find_map(|provider| provider.blame(file, range.clone()).ok())
+            .map(|provider| provider.blame(file, range.clone()))
+            .next()
+            .context("neno")?
     }
 
     /// Fire-and-forget changed file iteration. Runs everything in a background task. Keeps

From e2e3ce4ea02a1dfbd690d60c8e27408bc3ad7552 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 02:57:32 +0000
Subject: [PATCH 03/38] chore: better error message

---
 helix-vcs/src/lib.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index 9b73a42e..abc7689f 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -58,7 +58,7 @@ impl DiffProviderRegistry {
             .iter()
             .map(|provider| provider.blame(file, range.clone()))
             .next()
-            .context("neno")?
+            .context("No provider found")?
     }
 
     /// Fire-and-forget changed file iteration. Runs everything in a background task. Keeps

From fed006456abcd46c2e85838bba02df964f198afb Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 03:03:48 +0000
Subject: [PATCH 04/38] refactor: rename to `blame_line`

---
 helix-term/src/commands/typed.rs |  8 ++------
 helix-vcs/src/git.rs             |  4 ++--
 helix-vcs/src/lib.rs             | 10 +++++-----
 3 files changed, 9 insertions(+), 13 deletions(-)

diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index d41dbbd5..6a668b76 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -77,15 +77,11 @@ fn blame(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyho
 
     let (view, doc) = current_ref!(cx.editor);
     let selection = doc.selection(view.id);
-    let (from, to) = selection
-        .line_ranges(doc.text().slice(..))
-        .next()
-        .map(|(from, to)| (from as u32, to as u32))
-        .context("No selections")?;
+    let cursor_line = selection.primary().cursor(doc.text().slice(..));
     let result = cx
         .editor
         .diff_providers
-        .blame(doc.path().context("Not in a file")?, from..to)
+        .blame_line(doc.path().context("Not in a file")?, cursor_line.try_into()?)
         .inspect_err(|err| {
             log::error!("Could not get blame: {err}");
         })
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index 80021576..dcc30fbf 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -144,7 +144,7 @@ impl fmt::Display for BlameInformation {
 }
 
 /// Emulates the result of running `git blame` from the command line.
-pub fn blame(file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformation> {
+pub fn blame_line(file: &Path, line: u32) -> Result<BlameInformation> {
     let repo_dir = get_repo_dir(file)?;
     let repo = open_repo(repo_dir)
         .context("failed to open git repo")?
@@ -175,7 +175,7 @@ pub fn blame(file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformatio
         traverse_all_commits,
         &mut resource_cache,
         BStr::new(relative_path),
-        Some(range),
+        Some(line..line.saturating_add(0)),
     )?
     .entries
     .first()
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index abc7689f..551d81f9 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -49,14 +49,14 @@ impl DiffProviderRegistry {
             })
     }
 
-    pub fn blame(
+    pub fn blame_line(
         &self,
         file: &Path,
-        range: std::ops::Range<u32>,
+        line: u32,
     ) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
-            .map(|provider| provider.blame(file, range.clone()))
+            .map(|provider| provider.blame_line(file, line))
             .next()
             .context("No provider found")?
     }
@@ -121,10 +121,10 @@ impl DiffProvider {
         }
     }
 
-    fn blame(&self, file: &Path, range: std::ops::Range<u32>) -> Result<BlameInformation> {
+    fn blame_line(&self, file: &Path, line: u32) -> Result<BlameInformation> {
         match self {
             #[cfg(feature = "git")]
-            Self::Git => git::blame(file, range),
+            Self::Git => git::blame_line(file, line),
             Self::None => bail!("No blame support compiled in"),
         }
     }

From b76ddf5d882c15724f933e41a80550790ae4603a Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 03:12:22 +0000
Subject: [PATCH 05/38] fix: use line of primary cursor for git blame

---
 helix-term/src/commands/typed.rs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 6a668b76..0d5e1a98 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -76,8 +76,9 @@ fn blame(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyho
     }
 
     let (view, doc) = current_ref!(cx.editor);
+    let text = doc.text();
     let selection = doc.selection(view.id);
-    let cursor_line = selection.primary().cursor(doc.text().slice(..));
+    let cursor_line = text.char_to_line(selection.primary().cursor(doc.text().slice(..)));
     let result = cx
         .editor
         .diff_providers

From e2cebacf331e4f6c68518ae6e9eaab1b78cdf3a0 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 15:39:47 +0000
Subject: [PATCH 06/38] feat: basic implementation of blocking Blame handler

---
 helix-term/src/handlers.rs       |  4 +++
 helix-term/src/handlers/blame.rs | 61 ++++++++++++++++++++++++++++++++
 helix-view/src/editor.rs         | 16 +++++++++
 helix-view/src/handlers.rs       |  6 ++++
 4 files changed, 87 insertions(+)
 create mode 100644 helix-term/src/handlers/blame.rs

diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index b580e678..6f18d43b 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -11,6 +11,7 @@ use crate::handlers::signature_help::SignatureHelpHandler;
 pub use helix_view::handlers::Handlers;
 
 mod auto_save;
+mod blame;
 pub mod completion;
 mod diagnostics;
 mod signature_help;
@@ -22,11 +23,13 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     let event_tx = completion::CompletionHandler::new(config).spawn();
     let signature_hints = SignatureHelpHandler::new().spawn();
     let auto_save = AutoSaveHandler::new().spawn();
+    let blame = blame::BlameHandler.spawn();
 
     let handlers = Handlers {
         completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
         signature_hints,
         auto_save,
+        blame,
     };
 
     completion::register_hooks(&handlers);
@@ -34,5 +37,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     auto_save::register_hooks(&handlers);
     diagnostics::register_hooks(&handlers);
     snippet::register_hooks(&handlers);
+    blame::register_hooks(&handlers);
     handlers
 }
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
new file mode 100644
index 00000000..50f1e842
--- /dev/null
+++ b/helix-term/src/handlers/blame.rs
@@ -0,0 +1,61 @@
+#![allow(dead_code, unused_variables)]
+use helix_event::{register_hook, send_blocking};
+use helix_view::{
+    handlers::{BlameEvent, Handlers},
+    Editor,
+};
+
+use crate::{events::PostCommand, job};
+
+pub struct BlameHandler;
+
+impl helix_event::AsyncHook for BlameHandler {
+    type Event = BlameEvent;
+
+    fn handle_event(
+        &mut self,
+        event: Self::Event,
+        timeout: Option<tokio::time::Instant>,
+    ) -> Option<tokio::time::Instant> {
+        self.finish_debounce();
+        None
+    }
+
+    fn finish_debounce(&mut self) {
+        job::dispatch_blocking(move |editor, _| {
+            request_git_blame(editor);
+        })
+    }
+}
+
+pub(super) fn register_hooks(handlers: &Handlers) {
+    let tx = handlers.blame.clone();
+    register_hook!(move |event: &mut PostCommand<'_, '_>| {
+        if event.cx.editor.config().vcs.blame {
+            send_blocking(&tx, BlameEvent::PostCommand);
+        }
+
+        Ok(())
+    });
+}
+
+fn request_git_blame(editor: &mut Editor) {
+    let (view, doc) = current_ref!(editor);
+    let text = doc.text();
+    let selection = doc.selection(view.id);
+    let Some(file) = doc.path() else {
+        return;
+    };
+    let Ok(cursor_line) = TryInto::<u32>::try_into(
+        text.char_to_line(selection.primary().cursor(doc.text().slice(..))),
+    ) else {
+        return;
+    };
+
+    let output = editor.diff_providers.blame_line(file, cursor_line);
+
+    match output {
+        Ok(blame) => editor.set_status(blame.to_string()),
+        Err(err) => editor.set_error(err.to_string()),
+    }
+}
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index cdc48a54..7008be51 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -171,6 +171,19 @@ impl Default for GutterLineNumbersConfig {
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
+pub struct VersionControlConfig {
+    /// Whether to enable git blame
+    pub blame: bool,
+}
+
+impl Default for VersionControlConfig {
+    fn default() -> Self {
+        Self { blame: true }
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
 pub struct FilePickerConfig {
@@ -366,6 +379,8 @@ pub struct Config {
     pub end_of_line_diagnostics: DiagnosticFilter,
     // Set to override the default clipboard provider
     pub clipboard_provider: ClipboardProvider,
+    /// Version control
+    pub vcs: VersionControlConfig,
 }
 
 #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
@@ -1009,6 +1024,7 @@ impl Default for Config {
             inline_diagnostics: InlineDiagnosticsConfig::default(),
             end_of_line_diagnostics: DiagnosticFilter::Disable,
             clipboard_provider: ClipboardProvider::default(),
+            vcs: VersionControlConfig::default(),
         }
     }
 }
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index a26c4ddb..0bc310b6 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -16,11 +16,17 @@ pub enum AutoSaveEvent {
     LeftInsertMode,
 }
 
+#[derive(Debug)]
+pub enum BlameEvent {
+    PostCommand,
+}
+
 pub struct Handlers {
     // only public because most of the actual implementation is in helix-term right now :/
     pub completions: CompletionHandler,
     pub signature_hints: Sender<lsp::SignatureHelpEvent>,
     pub auto_save: Sender<AutoSaveEvent>,
+    pub blame: Sender<BlameEvent>,
 }
 
 impl Handlers {

From 8cfa56b643de04c02b080ca7e8562baf38d6e774 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 16:59:01 +0000
Subject: [PATCH 07/38] feat: implement basic virtual text (end of line blame)

---
 helix-term/src/handlers/blame.rs | 36 +++++++++++++++++++++-----------
 helix-vcs/src/git.rs             |  2 +-
 helix-view/src/document.rs       |  2 ++
 helix-view/src/view.rs           |  8 +++++++
 4 files changed, 35 insertions(+), 13 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 50f1e842..e733f953 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -1,4 +1,4 @@
-#![allow(dead_code, unused_variables)]
+use helix_core::text_annotations::InlineAnnotation;
 use helix_event::{register_hook, send_blocking};
 use helix_view::{
     handlers::{BlameEvent, Handlers},
@@ -14,14 +14,15 @@ impl helix_event::AsyncHook for BlameHandler {
 
     fn handle_event(
         &mut self,
-        event: Self::Event,
-        timeout: Option<tokio::time::Instant>,
+        _event: Self::Event,
+        _timeout: Option<tokio::time::Instant>,
     ) -> Option<tokio::time::Instant> {
         self.finish_debounce();
         None
     }
 
     fn finish_debounce(&mut self) {
+        // TODO: this blocks on the main thread. Figure out how not to do that
         job::dispatch_blocking(move |editor, _| {
             request_git_blame(editor);
         })
@@ -40,22 +41,33 @@ pub(super) fn register_hooks(handlers: &Handlers) {
 }
 
 fn request_git_blame(editor: &mut Editor) {
-    let (view, doc) = current_ref!(editor);
+    let blame_enabled = editor.config().vcs.blame;
+    let (view, doc) = current!(editor);
     let text = doc.text();
     let selection = doc.selection(view.id);
     let Some(file) = doc.path() else {
         return;
     };
-    let Ok(cursor_line) = TryInto::<u32>::try_into(
-        text.char_to_line(selection.primary().cursor(doc.text().slice(..))),
-    ) else {
+    if !blame_enabled {
+        return;
+    }
+
+    let cursor_lin = text.char_to_line(selection.primary().cursor(doc.text().slice(..)));
+    let Ok(cursor_line) = TryInto::<u32>::try_into(cursor_lin) else {
         return;
     };
 
-    let output = editor.diff_providers.blame_line(file, cursor_line);
+    // gix-blame expects a 1-based line
+    let Ok(output) = editor.diff_providers.blame_line(file, cursor_line + 1) else {
+        return;
+    };
 
-    match output {
-        Ok(blame) => editor.set_status(blame.to_string()),
-        Err(err) => editor.set_error(err.to_string()),
-    }
+    doc.blame = Some(vec![InlineAnnotation::new(
+        text.try_line_to_char(cursor_lin + 1)
+            .unwrap_or(text.len_chars())
+        // to get the last position in the current line
+        - 1,
+        output.to_string(),
+    )]);
+    log::error!("{:?}", doc.blame);
 }
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index dcc30fbf..1ea004d8 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -137,7 +137,7 @@ impl fmt::Display for BlameInformation {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(
             f,
-            "{} - {} - {} - {}",
+            "    {} - {} - {} - {}",
             self.author_name, self.commit_date, self.commit_message, self.commit_hash
         )
     }
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 06a708f0..46d84b7e 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -143,6 +143,7 @@ pub struct Document {
     ///
     /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
     pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
+    pub blame: Option<Vec<InlineAnnotation>>,
     pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
     /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
     /// update from the LSP
@@ -698,6 +699,7 @@ impl Document {
             focused_at: std::time::Instant::now(),
             readonly: false,
             jump_labels: HashMap::new(),
+            blame: None,
         }
     }
 
diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs
index a229f01e..a45f74a7 100644
--- a/helix-view/src/view.rs
+++ b/helix-view/src/view.rs
@@ -452,6 +452,14 @@ impl View {
             text_annotations.add_overlay(labels, style);
         }
 
+        if let Some(blame_annotation) = &doc.blame {
+            let annotation_style = theme
+                .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.type"))
+                .map(Highlight);
+
+            text_annotations.add_inline_annotations(blame_annotation, annotation_style);
+        }
+
         if let Some(DocumentInlayHints {
             id: _,
             type_inlay_hints,

From 1ac4e519323b78b8e86001cb203d09e7567d1eee Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 18:45:04 +0000
Subject: [PATCH 08/38] feat: figure out how to draw stuff at the end of lines

---
 helix-term/src/handlers.rs                    |  2 +-
 helix-term/src/ui/editor.rs                   |  9 ++
 helix-term/src/ui/text_decorations.rs         |  1 +
 helix-term/src/ui/text_decorations/blame.rs   | 92 +++++++++++++++++++
 .../src/ui/text_decorations/diagnostics.rs    | 31 ++++++-
 5 files changed, 132 insertions(+), 3 deletions(-)
 create mode 100644 helix-term/src/ui/text_decorations/blame.rs

diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index 6f18d43b..2ea0bf90 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -37,6 +37,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     auto_save::register_hooks(&handlers);
     diagnostics::register_hooks(&handlers);
     snippet::register_hooks(&handlers);
-    blame::register_hooks(&handlers);
+    // blame::register_hooks(&handlers);
     handlers
 }
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 6be56574..ff127b2f 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -201,6 +201,15 @@ impl EditorView {
             inline_diagnostic_config,
             config.end_of_line_diagnostics,
         ));
+        log::error!("{}", primary_cursor);
+        // if config.vcs.blame {
+        //     decorations.add_decoration(text_decorations::blame::EolBlame::new(
+        //         doc,
+        //         theme,
+        //         primary_cursor,
+        //         "hello world".to_string(),
+        //     ));
+        // }
         render_document(
             surface,
             inner,
diff --git a/helix-term/src/ui/text_decorations.rs b/helix-term/src/ui/text_decorations.rs
index 931ea431..f9d757ad 100644
--- a/helix-term/src/ui/text_decorations.rs
+++ b/helix-term/src/ui/text_decorations.rs
@@ -8,6 +8,7 @@ use crate::ui::document::{LinePos, TextRenderer};
 
 pub use diagnostics::InlineDiagnostics;
 
+pub mod blame;
 mod diagnostics;
 
 /// Decorations are the primary mechanism for extending the text rendering.
diff --git a/helix-term/src/ui/text_decorations/blame.rs b/helix-term/src/ui/text_decorations/blame.rs
new file mode 100644
index 00000000..585e9821
--- /dev/null
+++ b/helix-term/src/ui/text_decorations/blame.rs
@@ -0,0 +1,92 @@
+#![allow(dead_code, unused_variables, unused_mut)]
+
+use helix_core::doc_formatter::FormattedGrapheme;
+use helix_core::Position;
+
+use helix_view::theme::Style;
+use helix_view::{Document, Theme};
+
+use crate::ui::document::{LinePos, TextRenderer};
+use crate::ui::text_decorations::Decoration;
+
+pub struct EolBlame<'a> {
+    message: String,
+    doc: &'a Document,
+    cursor: usize,
+    style: Style,
+}
+
+impl<'a> EolBlame<'a> {
+    pub fn new(doc: &'a Document, theme: &Theme, cursor: usize, message: String) -> Self {
+        EolBlame {
+            style: theme.get("ui.virtual.blame"),
+            message,
+            doc,
+            cursor,
+        }
+    }
+}
+
+impl Decoration for EolBlame<'_> {
+    // fn decorate_line(&mut self, renderer: &mut TextRenderer, pos: LinePos) {
+    //     // renderer.draw_dec
+    //     //     ration_grapheme(grapheme, style, row, col)
+    //     let col_off = 50;
+    // }
+
+    fn render_virt_lines(
+        &mut self,
+        renderer: &mut TextRenderer,
+        pos: LinePos,
+        virt_off: Position,
+    ) -> Position {
+        let row = pos.visual_line;
+        let col = virt_off.col as u16;
+        // if col != self.cursor as u16 {
+        //     return Position::new(0, 0);
+        // }
+        let style = self.style;
+        let width = renderer.viewport.width;
+        let start_col = col - renderer.offset.col as u16;
+        // start drawing the git blame 1 space after the end of the line
+        let draw_col = col + 1;
+
+        let end_col = renderer
+            .column_in_bounds(draw_col as usize, 1)
+            .then(|| {
+                renderer
+                    .set_string_truncated(
+                        renderer.viewport.x + draw_col,
+                        row,
+                        &self.message,
+                        width.saturating_sub(draw_col) as usize,
+                        |_| self.style,
+                        true,
+                        false,
+                    )
+                    .0
+            })
+            .unwrap_or(start_col);
+        log::error!("cursor: {}, row: {row}, col: {col}, start_col: {start_col}, draw_col: {draw_col}, end_col: {end_col}", self.cursor);
+
+        let col_off = end_col - start_col;
+
+        Position::new(0, col_off as usize)
+    }
+
+    // fn reset_pos(&mut self, _pos: usize) -> usize {
+    //     usize::MAX
+    // }
+
+    // fn skip_concealed_anchor(&mut self, conceal_end_char_idx: usize) -> usize {
+    //     self.reset_pos(conceal_end_char_idx)
+    // }
+
+    // fn decorate_grapheme(
+    //     &mut self,
+    //     _renderer: &mut TextRenderer,
+    //     _grapheme: &FormattedGrapheme,
+    // ) -> usize {
+    //     usize::MAX
+    // }
+}
diff --git a/helix-term/src/ui/text_decorations/diagnostics.rs b/helix-term/src/ui/text_decorations/diagnostics.rs
index fb82bcf5..df7ea439 100644
--- a/helix-term/src/ui/text_decorations/diagnostics.rs
+++ b/helix-term/src/ui/text_decorations/diagnostics.rs
@@ -271,14 +271,41 @@ impl Decoration for InlineDiagnostics<'_> {
             DiagnosticFilter::Disable => None,
         };
         if let Some((eol_diagnostic, _)) = eol_diagnostic {
-            let mut renderer = Renderer {
+            let renderer = Renderer {
                 renderer,
                 first_row: pos.visual_line,
                 row: pos.visual_line,
                 config: &self.state.config,
                 styles: &self.styles,
             };
-            col_off = renderer.draw_eol_diagnostic(eol_diagnostic, pos.visual_line, virt_off.col);
+            // let ref mut this = renderer;
+            let row = pos.visual_line;
+            let col = virt_off.col;
+            let style = renderer.styles.severity_style(eol_diagnostic.severity());
+            let width = renderer.renderer.viewport.width;
+            let start_col = (col - renderer.renderer.offset.col) as u16;
+            let mut end_col = start_col;
+            let mut draw_col = (col + 1) as u16;
+
+            for line in eol_diagnostic.message.lines() {
+                if !renderer.renderer.column_in_bounds(draw_col as usize, 1) {
+                    break;
+                }
+
+                (end_col, _) = renderer.renderer.set_string_truncated(
+                    renderer.renderer.viewport.x + draw_col,
+                    row,
+                    line,
+                    width.saturating_sub(draw_col) as usize,
+                    |_| style,
+                    true,
+                    false,
+                );
+
+                draw_col = end_col - renderer.renderer.viewport.x + 2; // double space between lines
+            }
+
+            col_off = end_col - start_col;
         }
 
         self.state.compute_line_diagnostics();

From 711aa58d401128f3e74e7870d91ef11f90c2340e Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 19:01:05 +0000
Subject: [PATCH 09/38] feat: implement end of line virtual text for the
 current line

---
 helix-term/src/ui/editor.rs                 | 17 ++++++++---------
 helix-term/src/ui/text_decorations/blame.rs |  4 +++-
 2 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index ff127b2f..3ce7aa4e 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -201,15 +201,14 @@ impl EditorView {
             inline_diagnostic_config,
             config.end_of_line_diagnostics,
         ));
-        log::error!("{}", primary_cursor);
-        // if config.vcs.blame {
-        //     decorations.add_decoration(text_decorations::blame::EolBlame::new(
-        //         doc,
-        //         theme,
-        //         primary_cursor,
-        //         "hello world".to_string(),
-        //     ));
-        // }
+        if config.vcs.blame {
+            decorations.add_decoration(text_decorations::blame::EolBlame::new(
+                doc,
+                theme,
+                doc.text().char_to_line(primary_cursor),
+                "hello world".to_string(),
+            ));
+        }
         render_document(
             surface,
             inner,
diff --git a/helix-term/src/ui/text_decorations/blame.rs b/helix-term/src/ui/text_decorations/blame.rs
index 585e9821..3f64522e 100644
--- a/helix-term/src/ui/text_decorations/blame.rs
+++ b/helix-term/src/ui/text_decorations/blame.rs
@@ -40,6 +40,9 @@ impl Decoration for EolBlame<'_> {
         pos: LinePos,
         virt_off: Position,
     ) -> Position {
+        if self.cursor != pos.doc_line {
+            return Position::new(0, 0);
+        }
         let row = pos.visual_line;
         let col = virt_off.col as u16;
         // if col != self.cursor as u16 {
@@ -67,7 +70,6 @@ impl Decoration for EolBlame<'_> {
                     .0
             })
             .unwrap_or(start_col);
-        log::error!("cursor: {}, row: {row}, col: {col}, start_col: {start_col}, draw_col: {draw_col}, end_col: {end_col}", self.cursor);
 
         let col_off = end_col - start_col;
 

From ddf8ac11587b26ddb093f09f7badfd48fb281d46 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 19:09:02 +0000
Subject: [PATCH 10/38] feat: implement inline git blame

---
 helix-term/src/handlers.rs                  |  2 +-
 helix-term/src/handlers/blame.rs            | 17 +++++------
 helix-term/src/ui/editor.rs                 | 14 +++++----
 helix-term/src/ui/text_decorations/blame.rs | 32 ++-------------------
 helix-vcs/src/git.rs                        |  2 +-
 helix-view/src/document.rs                  |  2 +-
 helix-view/src/view.rs                      |  8 ------
 7 files changed, 23 insertions(+), 54 deletions(-)

diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index 2ea0bf90..6f18d43b 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -37,6 +37,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     auto_save::register_hooks(&handlers);
     diagnostics::register_hooks(&handlers);
     snippet::register_hooks(&handlers);
-    // blame::register_hooks(&handlers);
+    blame::register_hooks(&handlers);
     handlers
 }
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index e733f953..720556d4 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -62,12 +62,13 @@ fn request_git_blame(editor: &mut Editor) {
         return;
     };
 
-    doc.blame = Some(vec![InlineAnnotation::new(
-        text.try_line_to_char(cursor_lin + 1)
-            .unwrap_or(text.len_chars())
-        // to get the last position in the current line
-        - 1,
-        output.to_string(),
-    )]);
-    log::error!("{:?}", doc.blame);
+    doc.blame = Some(output.to_string());
+    // doc.blame = Some(vec![InlineAnnotation::new(
+    //     text.try_line_to_char(cursor_lin + 1)
+    //         .unwrap_or(text.len_chars())
+    //     // to get the last position in the current line
+    //     - 1,
+    //     output.to_string(),
+    // )]);
+    // log::error!("{:?}", doc.blame);
 }
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 3ce7aa4e..107dbce7 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -202,12 +202,14 @@ impl EditorView {
             config.end_of_line_diagnostics,
         ));
         if config.vcs.blame {
-            decorations.add_decoration(text_decorations::blame::EolBlame::new(
-                doc,
-                theme,
-                doc.text().char_to_line(primary_cursor),
-                "hello world".to_string(),
-            ));
+            if let Some(blame) = &doc.blame {
+                decorations.add_decoration(text_decorations::blame::EolBlame::new(
+                    doc,
+                    theme,
+                    doc.text().char_to_line(primary_cursor),
+                    blame,
+                ));
+            }
         }
         render_document(
             surface,
diff --git a/helix-term/src/ui/text_decorations/blame.rs b/helix-term/src/ui/text_decorations/blame.rs
index 3f64522e..766074da 100644
--- a/helix-term/src/ui/text_decorations/blame.rs
+++ b/helix-term/src/ui/text_decorations/blame.rs
@@ -1,6 +1,5 @@
 #![allow(dead_code, unused_variables, unused_mut)]
 
-use helix_core::doc_formatter::FormattedGrapheme;
 use helix_core::Position;
 
 use helix_view::theme::Style;
@@ -10,14 +9,14 @@ use crate::ui::document::{LinePos, TextRenderer};
 use crate::ui::text_decorations::Decoration;
 
 pub struct EolBlame<'a> {
-    message: String,
+    message: &'a str,
     doc: &'a Document,
     cursor: usize,
     style: Style,
 }
 
 impl<'a> EolBlame<'a> {
-    pub fn new(doc: &'a Document, theme: &Theme, cursor: usize, message: String) -> Self {
+    pub fn new(doc: &'a Document, theme: &Theme, cursor: usize, message: &'a str) -> Self {
         EolBlame {
             style: theme.get("ui.virtual.blame"),
             message,
@@ -28,12 +27,6 @@ impl<'a> EolBlame<'a> {
 }
 
 impl Decoration for EolBlame<'_> {
-    // fn decorate_line(&mut self, renderer: &mut TextRenderer, pos: LinePos) {
-    //     // renderer.draw_dec
-    //     //     ration_grapheme(grapheme, style, row, col)
-    //     let col_off = 50;
-    // }
-
     fn render_virt_lines(
         &mut self,
         renderer: &mut TextRenderer,
@@ -45,9 +38,6 @@ impl Decoration for EolBlame<'_> {
         }
         let row = pos.visual_line;
         let col = virt_off.col as u16;
-        // if col != self.cursor as u16 {
-        //     return Position::new(0, 0);
-        // }
         let style = self.style;
         let width = renderer.viewport.width;
         let start_col = col - renderer.offset.col as u16;
@@ -61,7 +51,7 @@ impl Decoration for EolBlame<'_> {
                     .set_string_truncated(
                         renderer.viewport.x + draw_col,
                         row,
-                        &self.message,
+                        self.message,
                         width.saturating_sub(draw_col) as usize,
                         |_| self.style,
                         true,
@@ -75,20 +65,4 @@ impl Decoration for EolBlame<'_> {
 
         Position::new(0, col_off as usize)
     }
-
-    // fn reset_pos(&mut self, _pos: usize) -> usize {
-    //     usize::MAX
-    // }
-
-    // fn skip_concealed_anchor(&mut self, conceal_end_char_idx: usize) -> usize {
-    //     self.reset_pos(conceal_end_char_idx)
-    // }
-
-    // fn decorate_grapheme(
-    //     &mut self,
-    //     _renderer: &mut TextRenderer,
-    //     _grapheme: &FormattedGrapheme,
-    // ) -> usize {
-    //     usize::MAX
-    // }
 }
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index 1ea004d8..5847f810 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -137,7 +137,7 @@ impl fmt::Display for BlameInformation {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(
             f,
-            "    {} - {} - {} - {}",
+            "{} • {} • {} • {}",
             self.author_name, self.commit_date, self.commit_message, self.commit_hash
         )
     }
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 46d84b7e..482cd1df 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -143,7 +143,7 @@ pub struct Document {
     ///
     /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
     pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
-    pub blame: Option<Vec<InlineAnnotation>>,
+    pub blame: Option<String>,
     pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
     /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
     /// update from the LSP
diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs
index a45f74a7..a229f01e 100644
--- a/helix-view/src/view.rs
+++ b/helix-view/src/view.rs
@@ -452,14 +452,6 @@ impl View {
             text_annotations.add_overlay(labels, style);
         }
 
-        if let Some(blame_annotation) = &doc.blame {
-            let annotation_style = theme
-                .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.type"))
-                .map(Highlight);
-
-            text_annotations.add_inline_annotations(blame_annotation, annotation_style);
-        }
-
         if let Some(DocumentInlayHints {
             id: _,
             type_inlay_hints,

From 9bdf9ac941303e4b7e02303d26712ebe618abf6c Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 19:53:22 +0000
Subject: [PATCH 11/38] chore: clean up

chore: remove unused import

_
---
 helix-term/src/commands/typed.rs              | 34 -------------------
 helix-term/src/handlers/blame.rs              | 11 +-----
 helix-term/src/ui/text_decorations/blame.rs   |  4 +--
 .../src/ui/text_decorations/diagnostics.rs    | 31 ++---------------
 helix-vcs/src/git.rs                          |  2 +-
 helix-vcs/src/lib.rs                          |  7 ++--
 6 files changed, 8 insertions(+), 81 deletions(-)

diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 0d5e1a98..9661689c 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -70,29 +70,6 @@ impl CommandCompleter {
     }
 }
 
-fn blame(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
-    if event != PromptEvent::Validate {
-        return Ok(());
-    }
-
-    let (view, doc) = current_ref!(cx.editor);
-    let text = doc.text();
-    let selection = doc.selection(view.id);
-    let cursor_line = text.char_to_line(selection.primary().cursor(doc.text().slice(..)));
-    let result = cx
-        .editor
-        .diff_providers
-        .blame_line(doc.path().context("Not in a file")?, cursor_line.try_into()?)
-        .inspect_err(|err| {
-            log::error!("Could not get blame: {err}");
-        })
-        .context("No blame information")?;
-
-    cx.editor.set_status(result.to_string());
-
-    Ok(())
-}
-
 fn quit(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     log::debug!("quitting...");
 
@@ -3578,17 +3555,6 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
             ..Signature::DEFAULT
         },
     },
-    TypableCommand {
-        name: "blame-line-range",
-        aliases: &["blame"],
-        doc: "Blames a range of lines. No args: Blame selection. 1 arg: Blame line. 2 args: Represents (from, to) line range to git blame.",
-        fun: blame,
-        completer: CommandCompleter::none(),
-        signature: Signature {
-            positionals: (0, Some(2)),
-            ..Signature::DEFAULT
-        },
-    },
 ];
 
 pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> =
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 720556d4..6600ab9e 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -1,4 +1,3 @@
-use helix_core::text_annotations::InlineAnnotation;
 use helix_event::{register_hook, send_blocking};
 use helix_view::{
     handlers::{BlameEvent, Handlers},
@@ -57,18 +56,10 @@ fn request_git_blame(editor: &mut Editor) {
         return;
     };
 
-    // gix-blame expects a 1-based line
+    // 0-based into 1-based line number
     let Ok(output) = editor.diff_providers.blame_line(file, cursor_line + 1) else {
         return;
     };
 
     doc.blame = Some(output.to_string());
-    // doc.blame = Some(vec![InlineAnnotation::new(
-    //     text.try_line_to_char(cursor_lin + 1)
-    //         .unwrap_or(text.len_chars())
-    //     // to get the last position in the current line
-    //     - 1,
-    //     output.to_string(),
-    // )]);
-    // log::error!("{:?}", doc.blame);
 }
diff --git a/helix-term/src/ui/text_decorations/blame.rs b/helix-term/src/ui/text_decorations/blame.rs
index 766074da..ddb9976a 100644
--- a/helix-term/src/ui/text_decorations/blame.rs
+++ b/helix-term/src/ui/text_decorations/blame.rs
@@ -41,8 +41,8 @@ impl Decoration for EolBlame<'_> {
         let style = self.style;
         let width = renderer.viewport.width;
         let start_col = col - renderer.offset.col as u16;
-        // start drawing the git blame 1 space after the end of the line
-        let draw_col = col + 1;
+        // start drawing the git blame 6 spaces after the end of the line
+        let draw_col = col + 6;
 
         let end_col = renderer
             .column_in_bounds(draw_col as usize, 1)
diff --git a/helix-term/src/ui/text_decorations/diagnostics.rs b/helix-term/src/ui/text_decorations/diagnostics.rs
index df7ea439..fb82bcf5 100644
--- a/helix-term/src/ui/text_decorations/diagnostics.rs
+++ b/helix-term/src/ui/text_decorations/diagnostics.rs
@@ -271,41 +271,14 @@ impl Decoration for InlineDiagnostics<'_> {
             DiagnosticFilter::Disable => None,
         };
         if let Some((eol_diagnostic, _)) = eol_diagnostic {
-            let renderer = Renderer {
+            let mut renderer = Renderer {
                 renderer,
                 first_row: pos.visual_line,
                 row: pos.visual_line,
                 config: &self.state.config,
                 styles: &self.styles,
             };
-            // let ref mut this = renderer;
-            let row = pos.visual_line;
-            let col = virt_off.col;
-            let style = renderer.styles.severity_style(eol_diagnostic.severity());
-            let width = renderer.renderer.viewport.width;
-            let start_col = (col - renderer.renderer.offset.col) as u16;
-            let mut end_col = start_col;
-            let mut draw_col = (col + 1) as u16;
-
-            for line in eol_diagnostic.message.lines() {
-                if !renderer.renderer.column_in_bounds(draw_col as usize, 1) {
-                    break;
-                }
-
-                (end_col, _) = renderer.renderer.set_string_truncated(
-                    renderer.renderer.viewport.x + draw_col,
-                    row,
-                    line,
-                    width.saturating_sub(draw_col) as usize,
-                    |_| style,
-                    true,
-                    false,
-                );
-
-                draw_col = end_col - renderer.renderer.viewport.x + 2; // double space between lines
-            }
-
-            col_off = end_col - start_col;
+            col_off = renderer.draw_eol_diagnostic(eol_diagnostic, pos.visual_line, virt_off.col);
         }
 
         self.state.compute_line_diagnostics();
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index 5847f810..b72bd0ae 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -137,7 +137,7 @@ impl fmt::Display for BlameInformation {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(
             f,
-            "{} • {} • {} • {}",
+            "{}, {} • {} • {}",
             self.author_name, self.commit_date, self.commit_message, self.commit_hash
         )
     }
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index 551d81f9..8f89c1a1 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -49,11 +49,8 @@ impl DiffProviderRegistry {
             })
     }
 
-    pub fn blame_line(
-        &self,
-        file: &Path,
-        line: u32,
-    ) -> anyhow::Result<BlameInformation> {
+    /// Blame line in a file. Lines are 1-indexed
+    pub fn blame_line(&self, file: &Path, line: u32) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
             .map(|provider| provider.blame_line(file, line))

From 1439261d17c99d69db1e7d7cde15effc712c8c3b Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 20:01:23 +0000
Subject: [PATCH 12/38] chore: set `blame` to `false` by default

---
 helix-view/src/editor.rs | 8 +-------
 1 file changed, 1 insertion(+), 7 deletions(-)

diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 7008be51..62000910 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -171,19 +171,13 @@ impl Default for GutterLineNumbersConfig {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
 pub struct VersionControlConfig {
     /// Whether to enable git blame
     pub blame: bool,
 }
 
-impl Default for VersionControlConfig {
-    fn default() -> Self {
-        Self { blame: true }
-    }
-}
-
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
 pub struct FilePickerConfig {

From 1134c9def313e63e8483d57ee5df1a836dcce742 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 20:02:55 +0000
Subject: [PATCH 13/38] docs: document `[editor.vcs.blame]`

---
 book/src/editor.md | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/book/src/editor.md b/book/src/editor.md
index 79f7284c..a4b409d1 100644
--- a/book/src/editor.md
+++ b/book/src/editor.md
@@ -4,6 +4,7 @@
 - [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
 - [`[editor.statusline]` Section](#editorstatusline-section)
 - [`[editor.lsp]` Section](#editorlsp-section)
+- [`[editor.vcs]` Section](#editorvcs-section)
 - [`[editor.cursor-shape]` Section](#editorcursor-shape-section)
 - [`[editor.file-picker]` Section](#editorfile-picker-section)
 - [`[editor.auto-pairs]` Section](#editorauto-pairs-section)
@@ -159,6 +160,14 @@ The following statusline elements can be configured:
 
 [^2]: You may also have to activate them in the language server config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
 
+### `[editor.vcs]` Section
+
+Version control config.
+
+| Key     | Description                                | Default |
+| ------- | ------------------------------------------ | ------- |
+| `blame` | Show git blame output for the current line | `false` |
+
 ### `[editor.cursor-shape]` Section
 
 Defines the shape of cursor in each mode.

From c518f845f59623345af6ab29ed0fa2e8b300480c Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 20:23:43 +0000
Subject: [PATCH 14/38] chore: add progress

---
 helix-term/src/handlers/blame.rs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 6600ab9e..53200243 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -22,6 +22,12 @@ impl helix_event::AsyncHook for BlameHandler {
 
     fn finish_debounce(&mut self) {
         // TODO: this blocks on the main thread. Figure out how not to do that
+        //
+        // Attempts so far:
+        // - tokio::spawn
+        // - std::thread::spawn
+        //
+        // For some reason none of the above fix the issue of blocking the UI.
         job::dispatch_blocking(move |editor, _| {
             request_git_blame(editor);
         })

From 1782c358600626c3bb68a82a0ae17466c99e181a Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Tue, 18 Mar 2025 23:51:18 +0000
Subject: [PATCH 15/38] perf: use background task for worker

_
---
 helix-term/src/handlers.rs       |   2 +-
 helix-term/src/handlers/blame.rs | 124 ++++++++++++++++++++-----------
 helix-vcs/src/lib.rs             |   4 +-
 helix-view/src/handlers.rs       |   9 ++-
 4 files changed, 91 insertions(+), 48 deletions(-)

diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index 6f18d43b..d332d227 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -23,7 +23,7 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     let event_tx = completion::CompletionHandler::new(config).spawn();
     let signature_hints = SignatureHelpHandler::new().spawn();
     let auto_save = AutoSaveHandler::new().spawn();
-    let blame = blame::BlameHandler.spawn();
+    let blame = blame::BlameHandler::default().spawn();
 
     let handlers = Handlers {
         completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 53200243..67b2b877 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -1,71 +1,107 @@
+use std::{path::PathBuf, time::Duration};
+
 use helix_event::{register_hook, send_blocking};
-use helix_view::{
-    handlers::{BlameEvent, Handlers},
-    Editor,
-};
+use helix_vcs::DiffProviderRegistry;
+use helix_view::handlers::{BlameEvent, Handlers};
+use tokio::{task::JoinHandle, time::Instant};
 
 use crate::{events::PostCommand, job};
 
-pub struct BlameHandler;
+#[derive(Default)]
+pub struct BlameHandler {
+    worker: Option<JoinHandle<anyhow::Result<String>>>,
+}
+
+async fn compute_diff(
+    file: PathBuf,
+    line: u32,
+    diff_providers: DiffProviderRegistry,
+) -> anyhow::Result<String> {
+    // std::thread::sleep(Duration::from_secs(5));
+    // Ok("hhe".to_string())
+    diff_providers
+        .blame_line(&file, line)
+        .map(|s| s.to_string())
+}
 
 impl helix_event::AsyncHook for BlameHandler {
     type Event = BlameEvent;
 
     fn handle_event(
         &mut self,
-        _event: Self::Event,
+        event: Self::Event,
         _timeout: Option<tokio::time::Instant>,
     ) -> Option<tokio::time::Instant> {
-        self.finish_debounce();
-        None
+        if let Some(worker) = &self.worker {
+            if worker.is_finished() {
+                self.finish_debounce();
+                return None;
+            }
+            return Some(Instant::now() + Duration::from_millis(50));
+        }
+
+        let BlameEvent::PostCommand {
+            file,
+            cursor_line,
+            diff_providers,
+        } = event;
+
+        let worker = tokio::spawn(compute_diff(file, cursor_line, diff_providers));
+        self.worker = Some(worker);
+        Some(Instant::now() + Duration::from_millis(50))
     }
 
     fn finish_debounce(&mut self) {
-        // TODO: this blocks on the main thread. Figure out how not to do that
-        //
-        // Attempts so far:
-        // - tokio::spawn
-        // - std::thread::spawn
-        //
-        // For some reason none of the above fix the issue of blocking the UI.
-        job::dispatch_blocking(move |editor, _| {
-            request_git_blame(editor);
-        })
+        if let Some(worker) = &self.worker {
+            if worker.is_finished() {
+                let worker = self.worker.take().unwrap();
+                tokio::spawn(handle_worker(worker));
+            }
+        }
     }
 }
 
+async fn handle_worker(worker: JoinHandle<anyhow::Result<String>>) {
+    let Ok(Ok(outcome)) = worker.await else {
+        return;
+    };
+    job::dispatch(move |editor, _| {
+        let doc = doc_mut!(editor);
+        doc.blame = Some(outcome);
+    })
+    .await;
+}
+
 pub(super) fn register_hooks(handlers: &Handlers) {
     let tx = handlers.blame.clone();
     register_hook!(move |event: &mut PostCommand<'_, '_>| {
         if event.cx.editor.config().vcs.blame {
-            send_blocking(&tx, BlameEvent::PostCommand);
+            let blame_enabled = event.cx.editor.config().vcs.blame;
+            let (view, doc) = current!(event.cx.editor);
+            let text = doc.text();
+            let selection = doc.selection(view.id);
+            let Some(file) = doc.path() else {
+                panic!();
+            };
+            if !blame_enabled {
+                panic!();
+            }
+
+            let cursor_lin = text.char_to_line(selection.primary().cursor(doc.text().slice(..)));
+            let Ok(cursor_line) = TryInto::<u32>::try_into(cursor_lin) else {
+                panic!();
+            };
+
+            send_blocking(
+                &tx,
+                BlameEvent::PostCommand {
+                    file: file.to_path_buf(),
+                    cursor_line,
+                    diff_providers: event.cx.editor.diff_providers.clone(),
+                },
+            );
         }
 
         Ok(())
     });
 }
-
-fn request_git_blame(editor: &mut Editor) {
-    let blame_enabled = editor.config().vcs.blame;
-    let (view, doc) = current!(editor);
-    let text = doc.text();
-    let selection = doc.selection(view.id);
-    let Some(file) = doc.path() else {
-        return;
-    };
-    if !blame_enabled {
-        return;
-    }
-
-    let cursor_lin = text.char_to_line(selection.primary().cursor(doc.text().slice(..)));
-    let Ok(cursor_line) = TryInto::<u32>::try_into(cursor_lin) else {
-        return;
-    };
-
-    // 0-based into 1-based line number
-    let Ok(output) = editor.diff_providers.blame_line(file, cursor_line + 1) else {
-        return;
-    };
-
-    doc.blame = Some(output.to_string());
-}
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index 8f89c1a1..a513bda4 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -17,7 +17,7 @@ mod status;
 
 pub use status::FileChange;
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 pub struct DiffProviderRegistry {
     providers: Vec<DiffProvider>,
 }
@@ -94,7 +94,7 @@ impl Default for DiffProviderRegistry {
 /// cloning [DiffProviderRegistry] as `Clone` cannot be used in trait objects.
 ///
 /// `Copy` is simply to ensure the `clone()` call is the simplest it can be.
-#[derive(Copy, Clone)]
+#[derive(Copy, Clone, Debug)]
 pub enum DiffProvider {
     #[cfg(feature = "git")]
     Git,
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index 0bc310b6..6090bf84 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -1,5 +1,8 @@
+use std::path::PathBuf;
+
 use completion::{CompletionEvent, CompletionHandler};
 use helix_event::send_blocking;
+use helix_vcs::DiffProviderRegistry;
 use tokio::sync::mpsc::Sender;
 
 use crate::handlers::lsp::SignatureHelpInvoked;
@@ -18,7 +21,11 @@ pub enum AutoSaveEvent {
 
 #[derive(Debug)]
 pub enum BlameEvent {
-    PostCommand,
+    PostCommand {
+        file: PathBuf,
+        cursor_line: u32,
+        diff_providers: DiffProviderRegistry,
+    },
 }
 
 pub struct Handlers {

From ff6019827311bef42f2d49a5f37166e3747e4127 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 00:04:54 +0000
Subject: [PATCH 16/38] chore: remove unnecessary panic!s

---
 helix-term/src/handlers/blame.rs | 13 +++++--------
 1 file changed, 5 insertions(+), 8 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 67b2b877..eef0201b 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -76,20 +76,17 @@ pub(super) fn register_hooks(handlers: &Handlers) {
     let tx = handlers.blame.clone();
     register_hook!(move |event: &mut PostCommand<'_, '_>| {
         if event.cx.editor.config().vcs.blame {
-            let blame_enabled = event.cx.editor.config().vcs.blame;
             let (view, doc) = current!(event.cx.editor);
             let text = doc.text();
             let selection = doc.selection(view.id);
             let Some(file) = doc.path() else {
-                panic!();
+                return Ok(());
             };
-            if !blame_enabled {
-                panic!();
-            }
 
-            let cursor_lin = text.char_to_line(selection.primary().cursor(doc.text().slice(..)));
-            let Ok(cursor_line) = TryInto::<u32>::try_into(cursor_lin) else {
-                panic!();
+            let Ok(cursor_line) = TryInto::<u32>::try_into(
+                text.char_to_line(selection.primary().cursor(doc.text().slice(..))),
+            ) else {
+                return Ok(());
             };
 
             send_blocking(

From e6bf6bc2f41c998d79228a3440a653021ef7b05f Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 00:05:41 +0000
Subject: [PATCH 17/38] chore: remove commented code

---
 helix-term/src/handlers/blame.rs | 2 --
 1 file changed, 2 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index eef0201b..0f157a95 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -17,8 +17,6 @@ async fn compute_diff(
     line: u32,
     diff_providers: DiffProviderRegistry,
 ) -> anyhow::Result<String> {
-    // std::thread::sleep(Duration::from_secs(5));
-    // Ok("hhe".to_string())
     diff_providers
         .blame_line(&file, line)
         .map(|s| s.to_string())

From 215fb29fe6dff15f73e7b7c34adc92d9c9d225a3 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 12:35:35 +0000
Subject: [PATCH 18/38] refactor: remove some layers of abstraction

---
 helix-term/src/handlers/blame.rs | 44 +++++++++++++-------------------
 1 file changed, 18 insertions(+), 26 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 0f157a95..8d7bf5b2 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -1,7 +1,6 @@
-use std::{path::PathBuf, time::Duration};
+use std::time::Duration;
 
 use helix_event::{register_hook, send_blocking};
-use helix_vcs::DiffProviderRegistry;
 use helix_view::handlers::{BlameEvent, Handlers};
 use tokio::{task::JoinHandle, time::Instant};
 
@@ -12,16 +11,6 @@ pub struct BlameHandler {
     worker: Option<JoinHandle<anyhow::Result<String>>>,
 }
 
-async fn compute_diff(
-    file: PathBuf,
-    line: u32,
-    diff_providers: DiffProviderRegistry,
-) -> anyhow::Result<String> {
-    diff_providers
-        .blame_line(&file, line)
-        .map(|s| s.to_string())
-}
-
 impl helix_event::AsyncHook for BlameHandler {
     type Event = BlameEvent;
 
@@ -44,7 +33,11 @@ impl helix_event::AsyncHook for BlameHandler {
             diff_providers,
         } = event;
 
-        let worker = tokio::spawn(compute_diff(file, cursor_line, diff_providers));
+        let worker = tokio::spawn(async move {
+            diff_providers
+                .blame_line(&file, cursor_line)
+                .map(|s| s.to_string())
+        });
         self.worker = Some(worker);
         Some(Instant::now() + Duration::from_millis(50))
     }
@@ -53,23 +46,21 @@ impl helix_event::AsyncHook for BlameHandler {
         if let Some(worker) = &self.worker {
             if worker.is_finished() {
                 let worker = self.worker.take().unwrap();
-                tokio::spawn(handle_worker(worker));
+                tokio::spawn(async move {
+                    let Ok(Ok(outcome)) = worker.await else {
+                        return;
+                    };
+                    job::dispatch(move |editor, _| {
+                        let doc = doc_mut!(editor);
+                        doc.blame = Some(outcome);
+                    })
+                    .await;
+                });
             }
         }
     }
 }
 
-async fn handle_worker(worker: JoinHandle<anyhow::Result<String>>) {
-    let Ok(Ok(outcome)) = worker.await else {
-        return;
-    };
-    job::dispatch(move |editor, _| {
-        let doc = doc_mut!(editor);
-        doc.blame = Some(outcome);
-    })
-    .await;
-}
-
 pub(super) fn register_hooks(handlers: &Handlers) {
     let tx = handlers.blame.clone();
     register_hook!(move |event: &mut PostCommand<'_, '_>| {
@@ -80,6 +71,7 @@ pub(super) fn register_hooks(handlers: &Handlers) {
             let Some(file) = doc.path() else {
                 return Ok(());
             };
+            let file = file.to_path_buf();
 
             let Ok(cursor_line) = TryInto::<u32>::try_into(
                 text.char_to_line(selection.primary().cursor(doc.text().slice(..))),
@@ -90,7 +82,7 @@ pub(super) fn register_hooks(handlers: &Handlers) {
             send_blocking(
                 &tx,
                 BlameEvent::PostCommand {
-                    file: file.to_path_buf(),
+                    file,
                     cursor_line,
                     diff_providers: event.cx.editor.diff_providers.clone(),
                 },

From fc19b756eefa0f51eda56c2ae7463151629e974d Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 12:49:37 +0000
Subject: [PATCH 19/38] refactor: remove nesting

---
 helix-term/src/handlers/blame.rs | 49 +++++++++++++++++---------------
 1 file changed, 26 insertions(+), 23 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 8d7bf5b2..866ed466 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -64,31 +64,34 @@ impl helix_event::AsyncHook for BlameHandler {
 pub(super) fn register_hooks(handlers: &Handlers) {
     let tx = handlers.blame.clone();
     register_hook!(move |event: &mut PostCommand<'_, '_>| {
-        if event.cx.editor.config().vcs.blame {
-            let (view, doc) = current!(event.cx.editor);
-            let text = doc.text();
-            let selection = doc.selection(view.id);
-            let Some(file) = doc.path() else {
-                return Ok(());
-            };
-            let file = file.to_path_buf();
-
-            let Ok(cursor_line) = TryInto::<u32>::try_into(
-                text.char_to_line(selection.primary().cursor(doc.text().slice(..))),
-            ) else {
-                return Ok(());
-            };
-
-            send_blocking(
-                &tx,
-                BlameEvent::PostCommand {
-                    file,
-                    cursor_line,
-                    diff_providers: event.cx.editor.diff_providers.clone(),
-                },
-            );
+        if !event.cx.editor.config().vcs.blame {
+            return Ok(());
         }
 
+        let (view, doc) = current!(event.cx.editor);
+        let text = doc.text();
+        let selection = doc.selection(view.id);
+        let Some(file) = doc.path() else {
+            return Ok(());
+        };
+        let file = file.to_path_buf();
+
+        let Ok(cursor_line) =
+            u32::try_from(text.char_to_line(selection.primary().cursor(doc.text().slice(..))))
+        else {
+            return Ok(());
+        };
+
+        send_blocking(
+            &tx,
+            BlameEvent::PostCommand {
+                file,
+                cursor_line,
+                // ok to clone because diff_providers is very small
+                diff_providers: event.cx.editor.diff_providers.clone(),
+            },
+        );
+
         Ok(())
     });
 }

From 2e756a93487802498dd4ff314dc9586df8a3c132 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 12:51:13 +0000
Subject: [PATCH 20/38] feat: [editor.vcs] -> [editor.version-control]

---
 book/src/editor.md               | 6 ++----
 helix-term/src/handlers/blame.rs | 2 +-
 helix-term/src/ui/editor.rs      | 2 +-
 helix-view/src/editor.rs         | 5 ++---
 4 files changed, 6 insertions(+), 9 deletions(-)

diff --git a/book/src/editor.md b/book/src/editor.md
index a4b409d1..393a7b76 100644
--- a/book/src/editor.md
+++ b/book/src/editor.md
@@ -4,7 +4,7 @@
 - [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
 - [`[editor.statusline]` Section](#editorstatusline-section)
 - [`[editor.lsp]` Section](#editorlsp-section)
-- [`[editor.vcs]` Section](#editorvcs-section)
+- [`[editor.version-control]` Section](#editorversioncontrol-section)
 - [`[editor.cursor-shape]` Section](#editorcursor-shape-section)
 - [`[editor.file-picker]` Section](#editorfile-picker-section)
 - [`[editor.auto-pairs]` Section](#editorauto-pairs-section)
@@ -160,9 +160,7 @@ The following statusline elements can be configured:
 
 [^2]: You may also have to activate them in the language server config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
 
-### `[editor.vcs]` Section
-
-Version control config.
+### `[editor.version-control]` Section
 
 | Key     | Description                                | Default |
 | ------- | ------------------------------------------ | ------- |
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 866ed466..c4f80d0b 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -64,7 +64,7 @@ impl helix_event::AsyncHook for BlameHandler {
 pub(super) fn register_hooks(handlers: &Handlers) {
     let tx = handlers.blame.clone();
     register_hook!(move |event: &mut PostCommand<'_, '_>| {
-        if !event.cx.editor.config().vcs.blame {
+        if !event.cx.editor.config().version_control.blame {
             return Ok(());
         }
 
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 107dbce7..41225bbc 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -201,7 +201,7 @@ impl EditorView {
             inline_diagnostic_config,
             config.end_of_line_diagnostics,
         ));
-        if config.vcs.blame {
+        if config.version_control.blame {
             if let Some(blame) = &doc.blame {
                 decorations.add_decoration(text_decorations::blame::EolBlame::new(
                     doc,
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 62000910..89e87e68 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -373,8 +373,7 @@ pub struct Config {
     pub end_of_line_diagnostics: DiagnosticFilter,
     // Set to override the default clipboard provider
     pub clipboard_provider: ClipboardProvider,
-    /// Version control
-    pub vcs: VersionControlConfig,
+    pub version_control: VersionControlConfig,
 }
 
 #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
@@ -1018,7 +1017,7 @@ impl Default for Config {
             inline_diagnostics: InlineDiagnosticsConfig::default(),
             end_of_line_diagnostics: DiagnosticFilter::Disable,
             clipboard_provider: ClipboardProvider::default(),
-            vcs: VersionControlConfig::default(),
+            version_control: VersionControlConfig::default(),
         }
     }
 }

From 9278a681864bef1b9f32309f0a49c454de36b49a Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 15:08:58 +0000
Subject: [PATCH 21/38] fix: account for inserted and deleted lines

_
---
 helix-term/src/handlers/blame.rs | 48 ++++++++++++++++++++++++++++++--
 helix-view/src/handlers.rs       |  4 +++
 2 files changed, 50 insertions(+), 2 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index c4f80d0b..640e3a72 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -9,6 +9,7 @@ use crate::{events::PostCommand, job};
 #[derive(Default)]
 pub struct BlameHandler {
     worker: Option<JoinHandle<anyhow::Result<String>>>,
+    cursor_line: u32,
 }
 
 impl helix_event::AsyncHook for BlameHandler {
@@ -31,11 +32,32 @@ impl helix_event::AsyncHook for BlameHandler {
             file,
             cursor_line,
             diff_providers,
+            removed_lines_count,
+            added_lines_count,
         } = event;
 
+        self.cursor_line = cursor_line;
+
+        // convert 0-based line numbers into 1-based line numbers
+        let cursor_line = cursor_line + 1;
+
+        // the line for which we compute the blame
+        // Because gix_blame doesn't care about stuff that is not commited, we have to "normalize" the
+        // line number to account for uncommited code.
+        //
+        // You'll notice that blame_line can be 0 when, for instance we have:
+        // - removed 0 lines
+        // - added 10 lines
+        // - cursor_line is 8
+        //
+        // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
+        // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
+        // we can show to the user nothing at all
+        let blame_line = cursor_line.saturating_sub(added_lines_count) + removed_lines_count;
+
         let worker = tokio::spawn(async move {
             diff_providers
-                .blame_line(&file, cursor_line)
+                .blame_line(&file, blame_line)
                 .map(|s| s.to_string())
         });
         self.worker = Some(worker);
@@ -43,6 +65,7 @@ impl helix_event::AsyncHook for BlameHandler {
     }
 
     fn finish_debounce(&mut self) {
+        let cursor_line = self.cursor_line;
         if let Some(worker) = &self.worker {
             if worker.is_finished() {
                 let worker = self.worker.take().unwrap();
@@ -52,7 +75,14 @@ impl helix_event::AsyncHook for BlameHandler {
                     };
                     job::dispatch(move |editor, _| {
                         let doc = doc_mut!(editor);
-                        doc.blame = Some(outcome);
+                        // if we're on a line that hasn't been commited yet, just show nothing at all
+                        // in order to reduce visual noise.
+                        // Because the git hunks already imply this information
+                        let blame_text = doc
+                            .diff_handle()
+                            .is_some_and(|diff| diff.load().hunk_at(cursor_line, false).is_none())
+                            .then_some(outcome);
+                        doc.blame = blame_text;
                     })
                     .await;
                 });
@@ -82,11 +112,25 @@ pub(super) fn register_hooks(handlers: &Handlers) {
             return Ok(());
         };
 
+        let hunks = doc.diff_handle().unwrap().load();
+
+        let mut removed_lines_count: u32 = 0;
+        let mut added_lines_count: u32 = 0;
+        for hunk in hunks.hunks_intersecting_line_ranges(std::iter::once((0, cursor_line as usize)))
+        {
+            let lines_inserted = hunk.after.end - hunk.after.start;
+            let lines_removed = hunk.before.end - hunk.before.start;
+            added_lines_count += lines_inserted;
+            removed_lines_count += lines_removed;
+        }
+
         send_blocking(
             &tx,
             BlameEvent::PostCommand {
                 file,
                 cursor_line,
+                removed_lines_count,
+                added_lines_count,
                 // ok to clone because diff_providers is very small
                 diff_providers: event.cx.editor.diff_providers.clone(),
             },
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index 6090bf84..d596d94a 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -24,6 +24,10 @@ pub enum BlameEvent {
     PostCommand {
         file: PathBuf,
         cursor_line: u32,
+        /// How many lines were removed before cursor_line
+        removed_lines_count: u32,
+        /// How many lines were added before cursor_line
+        added_lines_count: u32,
         diff_providers: DiffProviderRegistry,
     },
 }

From 452da44dc7f167f5506071a1bb8a6bb6d501c791 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 15:43:07 +0000
Subject: [PATCH 22/38] refactor: extract into a `blame` module

---
 helix-term/src/handlers/blame.rs | 21 +++-----
 helix-vcs/src/git.rs             | 73 ++-----------------------
 helix-vcs/src/git/blame.rs       | 93 ++++++++++++++++++++++++++++++++
 helix-vcs/src/lib.rs             | 33 +++++++++---
 4 files changed, 130 insertions(+), 90 deletions(-)
 create mode 100644 helix-vcs/src/git/blame.rs

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 640e3a72..64cb7e08 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -41,23 +41,14 @@ impl helix_event::AsyncHook for BlameHandler {
         // convert 0-based line numbers into 1-based line numbers
         let cursor_line = cursor_line + 1;
 
-        // the line for which we compute the blame
-        // Because gix_blame doesn't care about stuff that is not commited, we have to "normalize" the
-        // line number to account for uncommited code.
-        //
-        // You'll notice that blame_line can be 0 when, for instance we have:
-        // - removed 0 lines
-        // - added 10 lines
-        // - cursor_line is 8
-        //
-        // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
-        // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
-        // we can show to the user nothing at all
-        let blame_line = cursor_line.saturating_sub(added_lines_count) + removed_lines_count;
-
         let worker = tokio::spawn(async move {
             diff_providers
-                .blame_line(&file, blame_line)
+                .blame(
+                    &file,
+                    cursor_line..cursor_line,
+                    added_lines_count,
+                    removed_lines_count,
+                )
                 .map(|s| s.to_string())
         });
         self.worker = Some(worker);
diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs
index b72bd0ae..b59aefb7 100644
--- a/helix-vcs/src/git.rs
+++ b/helix-vcs/src/git.rs
@@ -1,12 +1,11 @@
 use anyhow::{bail, Context, Result};
 use arc_swap::ArcSwap;
-use core::fmt;
+use gix::bstr::ByteSlice as _;
 use gix::filter::plumbing::driver::apply::Delay;
 use std::io::Read;
 use std::path::Path;
 use std::sync::Arc;
 
-use gix::bstr::{BStr, ByteSlice};
 use gix::diff::Rewrites;
 use gix::dir::entry::Status;
 use gix::objs::tree::EntryKind;
@@ -23,6 +22,9 @@ use crate::FileChange;
 #[cfg(test)]
 mod test;
 
+mod blame;
+pub use blame::*;
+
 #[inline]
 fn get_repo_dir(file: &Path) -> Result<&Path> {
     file.parent().context("file has no parent directory")
@@ -126,73 +128,6 @@ 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,
-}
-
-impl fmt::Display for BlameInformation {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(
-            f,
-            "{}, {} • {} • {}",
-            self.author_name, self.commit_date, self.commit_message, self.commit_hash
-        )
-    }
-}
-
-/// Emulates the result of running `git blame` from the command line.
-pub fn blame_line(file: &Path, line: 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 relative_path = file
-        .strip_prefix(
-            repo.path()
-                .parent()
-                .context("Could not get parent path of repository")?,
-        )
-        .unwrap_or(file)
-        .to_str()
-        .context("Could not convert path to string")?;
-
-    let traverse_all_commits = 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_all_commits,
-        &mut resource_cache,
-        BStr::new(relative_path),
-        Some(line..line.saturating_add(0)),
-    )?
-    .entries
-    .first()
-    .context("No commits found")?
-    .commit_id;
-
-    let commit = repo.find_commit(latest_commit_id)?;
-    let author = commit.author()?;
-
-    Ok(BlameInformation {
-        commit_hash: commit.short_id()?.to_string(),
-        author_name: author.name.to_string(),
-        commit_date: author.time.format(gix::date::time::format::SHORT),
-        commit_message: commit.message()?.title.to_string(),
-    })
-}
-
 /// 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/git/blame.rs b/helix-vcs/src/git/blame.rs
new file mode 100644
index 00000000..e0cd250b
--- /dev/null
+++ b/helix-vcs/src/git/blame.rs
@@ -0,0 +1,93 @@
+use anyhow::Context as _;
+use core::fmt;
+use gix::bstr::BStr;
+use std::{ops::Range, path::Path};
+
+use super::{get_repo_dir, open_repo};
+
+pub struct BlameInformation {
+    pub commit_hash: String,
+    pub author_name: String,
+    pub commit_date: String,
+    pub commit_message: String,
+}
+
+impl fmt::Display for BlameInformation {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{}, {} • {} • {}",
+            self.author_name, self.commit_date, self.commit_message, self.commit_hash
+        )
+    }
+}
+
+/// `git blame` a range in a file
+pub fn blame(
+    file: &Path,
+    range: Range<u32>,
+    added_lines_count: u32,
+    removed_lines_count: u32,
+) -> anyhow::Result<BlameInformation> {
+    // Because gix_blame doesn't care about stuff that is not commited, we have to "normalize" the
+    // line number to account for uncommited code.
+    //
+    // You'll notice that blame_line can be 0 when, for instance we have:
+    // - removed 0 lines
+    // - added 10 lines
+    // - cursor_line is 8
+    //
+    // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
+    // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
+    // we can show to the user nothing at all
+    let normalize = |line: u32| line.saturating_sub(added_lines_count) + removed_lines_count;
+
+    let blame_range = normalize(range.start)..normalize(range.end);
+
+    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 relative_path = file
+        .strip_prefix(
+            repo.path()
+                .parent()
+                .context("Could not get parent path of repository")?,
+        )
+        .unwrap_or(file)
+        .to_str()
+        .context("Could not convert path to string")?;
+
+    let traverse_all_commits = 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_all_commits,
+        &mut resource_cache,
+        BStr::new(relative_path),
+        Some(blame_range),
+    )?
+    .entries
+    .first()
+    .context("No commits found")?
+    .commit_id;
+
+    let commit = repo.find_commit(latest_commit_id)?;
+    let author = commit.author()?;
+
+    Ok(BlameInformation {
+        commit_hash: commit.short_id()?.to_string(),
+        author_name: author.name.to_string(),
+        commit_date: author.time.format(gix::date::time::format::SHORT),
+        commit_message: commit.message()?.title.to_string(),
+    })
+}
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index a513bda4..619d18ef 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -1,6 +1,8 @@
-use anyhow::{anyhow, bail, Context, Result};
+use anyhow::Context as _;
+use anyhow::{anyhow, bail, Result};
 use arc_swap::ArcSwap;
 use git::BlameInformation;
+use std::ops::Range;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
@@ -49,11 +51,24 @@ impl DiffProviderRegistry {
             })
     }
 
-    /// Blame line in a file. Lines are 1-indexed
-    pub fn blame_line(&self, file: &Path, line: u32) -> anyhow::Result<BlameInformation> {
+    /// Blame range of lines in a file. Lines are 1-indexed
+    pub fn blame(
+        &self,
+        file: &Path,
+        range: Range<u32>,
+        added_lines_count: u32,
+        removed_lines_count: u32,
+    ) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
-            .map(|provider| provider.blame_line(file, line))
+            .map(|provider| {
+                provider.blame(
+                    file,
+                    range.start..range.end,
+                    added_lines_count,
+                    removed_lines_count,
+                )
+            })
             .next()
             .context("No provider found")?
     }
@@ -118,10 +133,16 @@ impl DiffProvider {
         }
     }
 
-    fn blame_line(&self, file: &Path, line: u32) -> Result<BlameInformation> {
+    fn blame(
+        &self,
+        file: &Path,
+        range: Range<u32>,
+        added_lines_count: u32,
+        removed_lines_count: u32,
+    ) -> Result<BlameInformation> {
         match self {
             #[cfg(feature = "git")]
-            Self::Git => git::blame_line(file, line),
+            Self::Git => git::blame(file, range, added_lines_count, removed_lines_count),
             Self::None => bail!("No blame support compiled in"),
         }
     }

From 8b559da256b99459b0b1a6860790132e0af3a69c Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 16:16:15 +0000
Subject: [PATCH 23/38] feat: allow using custom commit format

---
 helix-term/src/handlers/blame.rs |  8 +++-
 helix-term/src/ui/editor.rs      |  2 +-
 helix-vcs/src/git/blame.rs       | 63 +++++++++++++++++++++++++++-----
 helix-view/src/editor.rs         | 14 ++++++-
 helix-view/src/handlers.rs       |  2 +
 5 files changed, 75 insertions(+), 14 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 64cb7e08..a5cf4037 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -34,6 +34,7 @@ impl helix_event::AsyncHook for BlameHandler {
             diff_providers,
             removed_lines_count,
             added_lines_count,
+            blame_format,
         } = event;
 
         self.cursor_line = cursor_line;
@@ -49,7 +50,7 @@ impl helix_event::AsyncHook for BlameHandler {
                     added_lines_count,
                     removed_lines_count,
                 )
-                .map(|s| s.to_string())
+                .map(|s| s.parse_format(&blame_format))
         });
         self.worker = Some(worker);
         Some(Instant::now() + Duration::from_millis(50))
@@ -85,7 +86,8 @@ impl helix_event::AsyncHook for BlameHandler {
 pub(super) fn register_hooks(handlers: &Handlers) {
     let tx = handlers.blame.clone();
     register_hook!(move |event: &mut PostCommand<'_, '_>| {
-        if !event.cx.editor.config().version_control.blame {
+        let version_control_config = &event.cx.editor.config().version_control;
+        if !version_control_config.inline_blame {
             return Ok(());
         }
 
@@ -124,6 +126,8 @@ pub(super) fn register_hooks(handlers: &Handlers) {
                 added_lines_count,
                 // ok to clone because diff_providers is very small
                 diff_providers: event.cx.editor.diff_providers.clone(),
+                // ok to clone because blame_format is likely to be about 30 characters or less
+                blame_format: version_control_config.inline_blame_format.clone(),
             },
         );
 
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 41225bbc..730df721 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -201,7 +201,7 @@ impl EditorView {
             inline_diagnostic_config,
             config.end_of_line_diagnostics,
         ));
-        if config.version_control.blame {
+        if config.version_control.inline_blame {
             if let Some(blame) = &doc.blame {
                 decorations.add_decoration(text_decorations::blame::EolBlame::new(
                     doc,
diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index e0cd250b..7debae99 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -1,24 +1,68 @@
 use anyhow::Context as _;
-use core::fmt;
 use gix::bstr::BStr;
-use std::{ops::Range, path::Path};
+use std::{collections::HashMap, ops::Range, path::Path};
 
 use super::{get_repo_dir, open_repo};
 
 pub struct BlameInformation {
     pub commit_hash: String,
     pub author_name: String,
+    pub author_email: String,
     pub commit_date: String,
     pub commit_message: String,
 }
 
-impl fmt::Display for BlameInformation {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(
-            f,
-            "{}, {} • {} • {}",
-            self.author_name, self.commit_date, self.commit_message, self.commit_hash
-        )
+impl BlameInformation {
+    /// Parse the user's blame format
+    pub fn parse_format(&self, format: &str) -> String {
+        let mut formatted = String::with_capacity(format.len() * 2);
+
+        let variables = HashMap::from([
+            ("commit", &self.commit_hash),
+            ("author", &self.author_name),
+            ("date", &self.commit_date),
+            ("message", &self.commit_message),
+            ("email", &self.author_email),
+        ]);
+
+        let mut chars = format.chars().peekable();
+        while let Some(ch) = chars.next() {
+            // "{{" => '{'
+            if ch == '{' && chars.next_if_eq(&'{').is_some() {
+                formatted.push('{');
+            }
+            // "}}" => '}'
+            else if ch == '}' && chars.next_if_eq(&'}').is_some() {
+                formatted.push('}');
+            } else if ch == '{' {
+                let mut variable = String::new();
+                // eat all characters until the end
+                while let Some(ch) = chars.next_if(|ch| *ch != '}') {
+                    variable.push(ch);
+                }
+                // eat the '}' if it was found
+                let has_closing = chars.next().is_some();
+                let res = variables
+                    .get(variable.as_str())
+                    .map(|s| s.to_string())
+                    .unwrap_or_else(|| {
+                        // Invalid variable. So just add whatever we parsed before
+                        let mut result = String::with_capacity(variable.len() + 2);
+                        result.push('{');
+                        result.push_str(variable.as_str());
+                        if has_closing {
+                            result.push('}');
+                        }
+                        result
+                    });
+
+                formatted.push_str(&res);
+            } else {
+                formatted.push(ch);
+            }
+        }
+
+        formatted
     }
 }
 
@@ -87,6 +131,7 @@ pub fn blame(
     Ok(BlameInformation {
         commit_hash: commit.short_id()?.to_string(),
         author_name: author.name.to_string(),
+        author_email: author.email.to_string(),
         commit_date: author.time.format(gix::date::time::format::SHORT),
         commit_message: commit.message()?.title.to_string(),
     })
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 89e87e68..e9a347f8 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -171,11 +171,21 @@ impl Default for GutterLineNumbersConfig {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
 pub struct VersionControlConfig {
     /// Whether to enable git blame
-    pub blame: bool,
+    pub inline_blame: bool,
+    pub inline_blame_format: String,
+}
+
+impl Default for VersionControlConfig {
+    fn default() -> Self {
+        Self {
+            inline_blame: false,
+            inline_blame_format: "{author}, {date} • {message} • {commit}".to_owned(),
+        }
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index d596d94a..99789102 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -29,6 +29,8 @@ pub enum BlameEvent {
         /// How many lines were added before cursor_line
         added_lines_count: u32,
         diff_providers: DiffProviderRegistry,
+        /// Format of the blame
+        blame_format: String,
     },
 }
 

From 548899d7b129c5466584a0a30dd597ce4744ffd9 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 17:22:21 +0000
Subject: [PATCH 24/38] feat: allow more customizability for inline blame

---
 book/src/editor.md         |  27 +++++++++-
 helix-vcs/src/git/blame.rs | 101 ++++++++++++++++++++++++++++---------
 2 files changed, 104 insertions(+), 24 deletions(-)

diff --git a/book/src/editor.md b/book/src/editor.md
index 393a7b76..8c38efbf 100644
--- a/book/src/editor.md
+++ b/book/src/editor.md
@@ -164,7 +164,32 @@ The following statusline elements can be configured:
 
 | Key     | Description                                | Default |
 | ------- | ------------------------------------------ | ------- |
-| `blame` | Show git blame output for the current line | `false` |
+| `inline-blame` | Show git blame output for the current line | `false` |
+| `inline-blame-format` | The format in which to show the inline blame | `"{author}, {date} • {message} • {commit}"` |
+
+For `inline-blame-format`, you can use specific variables like so: `{variable}`.
+
+These are the available variables:
+
+- `author`: The author of the commit
+- `date`: When the commit was made
+- `message`: The message of the commit, excluding the body
+- `body`: The body of the commit
+- `commit`: The short hex SHA1 hash of the commit
+- `email`: The email of the author of the commit
+
+Any of the variables can potentially be empty.
+In this case, the content before the variable will not be included in the string.
+If the variable is at the beginning of the string, the content after the variable will not be included.
+
+Some examples, using the default value `inline-blame-format` value:
+
+- If `author` is empty: `"{date} • {message} • {commit}"`
+- If `date` is empty: `"{author} • {message} • {commit}"`
+- If `message` is empty: `"{author}, {date} • {commit}"`
+- If `commit` is empty: `"{author}, {date} • {message}"`
+- If `date` and `message` is empty: `"{author} • {commit}"`
+- If `author` and `message` is empty: `"{date} • {commit}"`
 
 ### `[editor.cursor-shape]` Section
 
diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 7debae99..3e02c3cb 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -5,17 +5,19 @@ use std::{collections::HashMap, ops::Range, path::Path};
 use super::{get_repo_dir, open_repo};
 
 pub struct BlameInformation {
-    pub commit_hash: String,
-    pub author_name: String,
-    pub author_email: String,
-    pub commit_date: String,
-    pub commit_message: String,
+    pub commit_hash: Option<String>,
+    pub author_name: Option<String>,
+    pub author_email: Option<String>,
+    pub commit_date: Option<String>,
+    pub commit_message: Option<String>,
+    pub commit_body: Option<String>,
 }
 
 impl BlameInformation {
     /// Parse the user's blame format
     pub fn parse_format(&self, format: &str) -> String {
-        let mut formatted = String::with_capacity(format.len() * 2);
+        let mut formatted = String::new();
+        let mut content_before_variable = String::new();
 
         let variables = HashMap::from([
             ("commit", &self.commit_hash),
@@ -23,17 +25,21 @@ impl BlameInformation {
             ("date", &self.commit_date),
             ("message", &self.commit_message),
             ("email", &self.author_email),
+            ("body", &self.commit_body),
         ]);
 
         let mut chars = format.chars().peekable();
+        // in all cases, when any of the variables is empty we exclude the content before the variable
+        // However, if the variable is the first and it is empty - then exclude the content after the variable
+        let mut exclude_content_after_variable = false;
         while let Some(ch) = chars.next() {
             // "{{" => '{'
             if ch == '{' && chars.next_if_eq(&'{').is_some() {
-                formatted.push('{');
+                content_before_variable.push('{');
             }
             // "}}" => '}'
             else if ch == '}' && chars.next_if_eq(&'}').is_some() {
-                formatted.push('}');
+                content_before_variable.push('}');
             } else if ch == '{' {
                 let mut variable = String::new();
                 // eat all characters until the end
@@ -42,10 +48,16 @@ impl BlameInformation {
                 }
                 // eat the '}' if it was found
                 let has_closing = chars.next().is_some();
-                let res = variables
-                    .get(variable.as_str())
-                    .map(|s| s.to_string())
-                    .unwrap_or_else(|| {
+
+                #[derive(PartialEq, Eq, PartialOrd, Ord)]
+                enum Variable {
+                    Valid(String),
+                    Invalid(String),
+                    Empty,
+                }
+
+                let variable_value = variables.get(variable.as_str()).map_or_else(
+                    || {
                         // Invalid variable. So just add whatever we parsed before
                         let mut result = String::with_capacity(variable.len() + 2);
                         result.push('{');
@@ -53,12 +65,49 @@ impl BlameInformation {
                         if has_closing {
                             result.push('}');
                         }
-                        result
-                    });
+                        Variable::Invalid(result)
+                    },
+                    |s| {
+                        s.as_ref()
+                            .map(|s| Variable::Valid(s.to_string()))
+                            .unwrap_or(Variable::Empty)
+                    },
+                );
 
-                formatted.push_str(&res);
+                match variable_value {
+                    Variable::Valid(value) => {
+                        if exclude_content_after_variable {
+                            // don't push anything.
+                            exclude_content_after_variable = false;
+                        } else {
+                            formatted.push_str(&content_before_variable);
+                        }
+                        formatted.push_str(&value);
+                    }
+                    Variable::Invalid(value) => {
+                        if exclude_content_after_variable {
+                            // don't push anything.
+                            exclude_content_after_variable = false;
+                        } else {
+                            formatted.push_str(&content_before_variable);
+                        }
+                        formatted.push_str(&value);
+                    }
+                    Variable::Empty => {
+                        if formatted.is_empty() {
+                            // exclude content AFTER this variable (at next iteration of the loop,
+                            // we'll exclude the content before a valid variable)
+                            exclude_content_after_variable = true;
+                        } else {
+                            // exclude content BEFORE this variable
+                            // also just don't add anything.
+                        }
+                    }
+                }
+
+                content_before_variable.drain(..);
             } else {
-                formatted.push(ch);
+                content_before_variable.push(ch);
             }
         }
 
@@ -125,14 +174,20 @@ pub fn blame(
     .context("No commits found")?
     .commit_id;
 
-    let commit = repo.find_commit(latest_commit_id)?;
-    let author = commit.author()?;
+    let commit = repo.find_commit(latest_commit_id).ok();
+    let message = commit.as_ref().and_then(|c| c.message().ok());
+    let author = commit.as_ref().and_then(|c| c.author().ok());
 
     Ok(BlameInformation {
-        commit_hash: commit.short_id()?.to_string(),
-        author_name: author.name.to_string(),
-        author_email: author.email.to_string(),
-        commit_date: author.time.format(gix::date::time::format::SHORT),
-        commit_message: commit.message()?.title.to_string(),
+        commit_hash: commit
+            .as_ref()
+            .and_then(|c| c.short_id().map(|id| id.to_string()).ok()),
+        author_name: author.map(|a| a.name.to_string()),
+        author_email: author.map(|a| a.email.to_string()),
+        commit_date: author.map(|a| a.time.format(gix::date::time::format::SHORT)),
+        commit_message: message.as_ref().map(|msg| msg.title.to_string()),
+        commit_body: message
+            .as_ref()
+            .and_then(|msg| msg.body.map(|body| body.to_string())),
     })
 }

From 115492980fa58a3d0ad7a28002cbc45b1bef7956 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 17:40:22 +0000
Subject: [PATCH 25/38] test: add tests for custom inline commit parsser

---
 helix-vcs/src/git/blame.rs | 76 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 76 insertions(+)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 3e02c3cb..02fadfb6 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -4,6 +4,7 @@ use std::{collections::HashMap, ops::Range, path::Path};
 
 use super::{get_repo_dir, open_repo};
 
+#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)]
 pub struct BlameInformation {
     pub commit_hash: Option<String>,
     pub author_name: Option<String>,
@@ -191,3 +192,78 @@ pub fn blame(
             .and_then(|msg| msg.body.map(|body| body.to_string())),
     })
 }
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    pub fn inline_blame_parser() {
+        let bob = BlameInformation {
+            commit_hash: Some("f14ab1cf".to_owned()),
+            author_name: Some("Bob TheBuilder".to_owned()),
+            author_email: Some("bob@bob.com".to_owned()),
+            commit_date: Some("2028-01-10".to_owned()),
+            commit_message: Some("feat!: extend house".to_owned()),
+            commit_body: Some("BREAKING CHANGE: Removed door".to_owned()),
+        };
+
+        let default_values = "{author}, {date} • {message} • {commit}";
+
+        assert_eq!(
+            bob.parse_format(default_values),
+            "Bob TheBuilder, 2028-01-10 • feat!: extend house • f14ab1cf".to_owned()
+        );
+        assert_eq!(
+            BlameInformation {
+                author_name: None,
+                ..bob.clone()
+            }
+            .parse_format(default_values),
+            "2028-01-10 • feat!: extend house • f14ab1cf".to_owned()
+        );
+        assert_eq!(
+            BlameInformation {
+                commit_date: None,
+                ..bob.clone()
+            }
+            .parse_format(default_values),
+            "Bob TheBuilder • feat!: extend house • f14ab1cf".to_owned()
+        );
+        assert_eq!(
+            BlameInformation {
+                commit_message: None,
+                author_email: None,
+                ..bob.clone()
+            }
+            .parse_format(default_values),
+            "Bob TheBuilder, 2028-01-10 • f14ab1cf".to_owned()
+        );
+        assert_eq!(
+            BlameInformation {
+                commit_hash: None,
+                ..bob.clone()
+            }
+            .parse_format(default_values),
+            "Bob TheBuilder, 2028-01-10 • feat!: extend house".to_owned()
+        );
+        assert_eq!(
+            BlameInformation {
+                commit_date: None,
+                author_name: None,
+                ..bob.clone()
+            }
+            .parse_format(default_values),
+            "feat!: extend house • f14ab1cf".to_owned()
+        );
+        assert_eq!(
+            BlameInformation {
+                author_name: None,
+                commit_message: None,
+                ..bob.clone()
+            }
+            .parse_format(default_values),
+            "2028-01-10 • f14ab1cf".to_owned()
+        );
+    }
+}

From 97e1a4168d9fab4554251823ba37c6bc540194f0 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 17:42:28 +0000
Subject: [PATCH 26/38] refactor: rename `blame` -> `blame_line`

_

_
---
 helix-term/src/handlers/blame.rs |  7 +------
 helix-vcs/src/git/blame.rs       | 14 ++++++--------
 helix-vcs/src/lib.rs             | 16 ++++------------
 3 files changed, 11 insertions(+), 26 deletions(-)

diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index a5cf4037..0be7815b 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -44,12 +44,7 @@ impl helix_event::AsyncHook for BlameHandler {
 
         let worker = tokio::spawn(async move {
             diff_providers
-                .blame(
-                    &file,
-                    cursor_line..cursor_line,
-                    added_lines_count,
-                    removed_lines_count,
-                )
+                .blame(&file, cursor_line, added_lines_count, removed_lines_count)
                 .map(|s| s.parse_format(&blame_format))
         });
         self.worker = Some(worker);
diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 02fadfb6..0ee4deed 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -1,6 +1,6 @@
 use anyhow::Context as _;
 use gix::bstr::BStr;
-use std::{collections::HashMap, ops::Range, path::Path};
+use std::{collections::HashMap, path::Path};
 
 use super::{get_repo_dir, open_repo};
 
@@ -116,10 +116,10 @@ impl BlameInformation {
     }
 }
 
-/// `git blame` a range in a file
-pub fn blame(
+/// `git blame` a single line in a file
+pub fn blame_line(
     file: &Path,
-    range: Range<u32>,
+    line: u32,
     added_lines_count: u32,
     removed_lines_count: u32,
 ) -> anyhow::Result<BlameInformation> {
@@ -134,9 +134,7 @@ pub fn blame(
     // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
     // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
     // we can show to the user nothing at all
-    let normalize = |line: u32| line.saturating_sub(added_lines_count) + removed_lines_count;
-
-    let blame_range = normalize(range.start)..normalize(range.end);
+    let blame_line = line.saturating_sub(added_lines_count) + removed_lines_count;
 
     let repo_dir = get_repo_dir(file)?;
     let repo = open_repo(repo_dir)
@@ -168,7 +166,7 @@ pub fn blame(
         traverse_all_commits,
         &mut resource_cache,
         BStr::new(relative_path),
-        Some(blame_range),
+        Some(blame_line..blame_line),
     )?
     .entries
     .first()
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index 619d18ef..c63558c0 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -2,7 +2,6 @@ use anyhow::Context as _;
 use anyhow::{anyhow, bail, Result};
 use arc_swap::ArcSwap;
 use git::BlameInformation;
-use std::ops::Range;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
@@ -55,20 +54,13 @@ impl DiffProviderRegistry {
     pub fn blame(
         &self,
         file: &Path,
-        range: Range<u32>,
+        line: u32,
         added_lines_count: u32,
         removed_lines_count: u32,
     ) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
-            .map(|provider| {
-                provider.blame(
-                    file,
-                    range.start..range.end,
-                    added_lines_count,
-                    removed_lines_count,
-                )
-            })
+            .map(|provider| provider.blame(file, line, added_lines_count, removed_lines_count))
             .next()
             .context("No provider found")?
     }
@@ -136,13 +128,13 @@ impl DiffProvider {
     fn blame(
         &self,
         file: &Path,
-        range: Range<u32>,
+        line: u32,
         added_lines_count: u32,
         removed_lines_count: u32,
     ) -> Result<BlameInformation> {
         match self {
             #[cfg(feature = "git")]
-            Self::Git => git::blame(file, range, added_lines_count, removed_lines_count),
+            Self::Git => git::blame_line(file, line, added_lines_count, removed_lines_count),
             Self::None => bail!("No blame support compiled in"),
         }
     }

From c9873817d5c069d6420473d0ace0c0cf84855855 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 21:31:18 +0000
Subject: [PATCH 27/38] test: create helper macros for tests

---
 helix-vcs/src/git/blame.rs | 90 ++++++++++++++++++++++++++++++++------
 helix-vcs/src/git/test.rs  | 26 ++++++-----
 2 files changed, 92 insertions(+), 24 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 0ee4deed..340cea96 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -4,7 +4,7 @@ use std::{collections::HashMap, path::Path};
 
 use super::{get_repo_dir, open_repo};
 
-#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)]
+#[derive(Clone, PartialEq, PartialOrd, Ord, Eq, Debug)]
 pub struct BlameInformation {
     pub commit_hash: Option<String>,
     pub author_name: Option<String>,
@@ -133,7 +133,7 @@ pub fn blame_line(
     //
     // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
     // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
-    // we can show to the user nothing at all
+    // we can show to the user nothing at all. This is detected in the editor
     let blame_line = line.saturating_sub(added_lines_count) + removed_lines_count;
 
     let repo_dir = get_repo_dir(file)?;
@@ -193,29 +193,93 @@ pub fn blame_line(
 
 #[cfg(test)]
 mod test {
+    use std::fs::File;
+
+    use crate::git::test::{create_commit_with_message, empty_git_repo};
+
     use super::*;
 
-    #[test]
-    pub fn inline_blame_parser() {
-        let bob = BlameInformation {
+    macro_rules! assert_blamed_lines {
+        ($repo:ident, $file:ident @ $($commit_msg:literal => $($line:literal $expected:literal),+);+ $(;)?) => {{
+            use std::fs::OpenOptions;
+            use std::io::Write;
+
+            let write_file = |content: &str| {
+                let mut f = OpenOptions::new()
+                    .write(true)
+                    .truncate(true)
+                    .open(&$file)
+                    .unwrap();
+                f.write_all(content.as_bytes()).unwrap();
+            };
+
+            let commit = |msg| create_commit_with_message($repo.path(), true, msg);
+
+            $(
+                let file_content = concat!($($line, "\n"),*);
+                write_file(file_content);
+                commit(stringify!($commit_msg));
+
+                let mut line_number = 0;
+
+                $(
+                    line_number += 1;
+                    let blame_result = blame_line(&$file, line_number, 0, 0).unwrap().commit_message;
+                    assert_eq!(
+                        blame_result,
+                        Some(concat!(stringify!($expected), "\n").to_owned()),
+                        "Blame mismatch at line {}: expected '{}', got {:?}",
+                        line_number,
+                        stringify!($expected),
+                        blame_result
+                    );
+                )*
+            )*
+        }};
+    }
+
+    fn bob() -> BlameInformation {
+        BlameInformation {
             commit_hash: Some("f14ab1cf".to_owned()),
             author_name: Some("Bob TheBuilder".to_owned()),
             author_email: Some("bob@bob.com".to_owned()),
             commit_date: Some("2028-01-10".to_owned()),
             commit_message: Some("feat!: extend house".to_owned()),
             commit_body: Some("BREAKING CHANGE: Removed door".to_owned()),
-        };
+        }
+    }
 
+    #[test]
+    pub fn blame_lin() {
+        let repo = empty_git_repo();
+        let file = repo.path().join("file.txt");
+        File::create(&file).unwrap();
+
+        assert_blamed_lines! {
+            repo, file @
+            1 =>
+                "fn main() {" 1,
+                "" 1,
+                "}" 1;
+            2 =>
+                "fn main() {" 1,
+                "  lol" 2,
+                "}" 1;
+        };
+    }
+
+    #[test]
+    pub fn inline_blame_format_parser() {
         let default_values = "{author}, {date} • {message} • {commit}";
 
         assert_eq!(
-            bob.parse_format(default_values),
+            bob().parse_format(default_values),
             "Bob TheBuilder, 2028-01-10 • feat!: extend house • f14ab1cf".to_owned()
         );
         assert_eq!(
             BlameInformation {
                 author_name: None,
-                ..bob.clone()
+                ..bob()
             }
             .parse_format(default_values),
             "2028-01-10 • feat!: extend house • f14ab1cf".to_owned()
@@ -223,7 +287,7 @@ mod test {
         assert_eq!(
             BlameInformation {
                 commit_date: None,
-                ..bob.clone()
+                ..bob()
             }
             .parse_format(default_values),
             "Bob TheBuilder • feat!: extend house • f14ab1cf".to_owned()
@@ -232,7 +296,7 @@ mod test {
             BlameInformation {
                 commit_message: None,
                 author_email: None,
-                ..bob.clone()
+                ..bob()
             }
             .parse_format(default_values),
             "Bob TheBuilder, 2028-01-10 • f14ab1cf".to_owned()
@@ -240,7 +304,7 @@ mod test {
         assert_eq!(
             BlameInformation {
                 commit_hash: None,
-                ..bob.clone()
+                ..bob()
             }
             .parse_format(default_values),
             "Bob TheBuilder, 2028-01-10 • feat!: extend house".to_owned()
@@ -249,7 +313,7 @@ mod test {
             BlameInformation {
                 commit_date: None,
                 author_name: None,
-                ..bob.clone()
+                ..bob()
             }
             .parse_format(default_values),
             "feat!: extend house • f14ab1cf".to_owned()
@@ -258,7 +322,7 @@ mod test {
             BlameInformation {
                 author_name: None,
                 commit_message: None,
-                ..bob.clone()
+                ..bob()
             }
             .parse_format(default_values),
             "2028-01-10 • f14ab1cf".to_owned()
diff --git a/helix-vcs/src/git/test.rs b/helix-vcs/src/git/test.rs
index 164040f5..c758c80b 100644
--- a/helix-vcs/src/git/test.rs
+++ b/helix-vcs/src/git/test.rs
@@ -4,11 +4,11 @@ use tempfile::TempDir;
 
 use crate::git;
 
-fn exec_git_cmd(args: &str, git_dir: &Path) {
+pub fn exec_git_cmd(args: &[&str], git_dir: &Path) {
     let res = Command::new("git")
         .arg("-C")
         .arg(git_dir) // execute the git command in this directory
-        .args(args.split_whitespace())
+        .args(args)
         .env_remove("GIT_DIR")
         .env_remove("GIT_ASKPASS")
         .env_remove("SSH_ASKPASS")
@@ -25,26 +25,30 @@ fn exec_git_cmd(args: &str, git_dir: &Path) {
         .env("GIT_CONFIG_KEY_1", "init.defaultBranch")
         .env("GIT_CONFIG_VALUE_1", "main")
         .output()
-        .unwrap_or_else(|_| panic!("`git {args}` failed"));
+        .unwrap_or_else(|_| panic!("`git {args:?}` failed"));
     if !res.status.success() {
         println!("{}", String::from_utf8_lossy(&res.stdout));
         eprintln!("{}", String::from_utf8_lossy(&res.stderr));
-        panic!("`git {args}` failed (see output above)")
+        panic!("`git {args:?}` failed (see output above)")
     }
 }
 
-fn create_commit(repo: &Path, add_modified: bool) {
+pub fn create_commit(repo: &Path, add_modified: bool) {
+    create_commit_with_message(repo, add_modified, "commit")
+}
+
+pub fn create_commit_with_message(repo: &Path, add_modified: bool, message: &str) {
     if add_modified {
-        exec_git_cmd("add -A", repo);
+        exec_git_cmd(&["add", "-A"], repo);
     }
-    exec_git_cmd("commit -m message", repo);
+    exec_git_cmd(&["commit", "-m", message], repo);
 }
 
-fn empty_git_repo() -> TempDir {
+pub fn empty_git_repo() -> TempDir {
     let tmp = tempfile::tempdir().expect("create temp dir for git testing");
-    exec_git_cmd("init", tmp.path());
-    exec_git_cmd("config user.email test@helix.org", tmp.path());
-    exec_git_cmd("config user.name helix-test", tmp.path());
+    exec_git_cmd(&["init"], tmp.path());
+    exec_git_cmd(&["config", "user.email", "test@helix.org"], tmp.path());
+    exec_git_cmd(&["config", "user.name", "helix-test"], tmp.path());
     tmp
 }
 

From d553b81aa7d88d6f17e3ff037498564fb68a02fd Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 23:05:44 +0000
Subject: [PATCH 28/38] test: make test syntax more expressive. Allow
 specifying line numbers that just got added

---
 helix-vcs/src/git/blame.rs | 141 ++++++++++++++++++++++++++++---------
 1 file changed, 108 insertions(+), 33 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 340cea96..bb626db7 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -191,6 +191,12 @@ pub fn blame_line(
     })
 }
 
+// attributes on expressions are not allowed
+// however, in our macro its possible that sometimes the
+// assignment to the mutable variable will not be read.
+//
+// when the last line has no expected blame commit
+#[allow(unused_assignments)]
 #[cfg(test)]
 mod test {
     use std::fs::File;
@@ -199,45 +205,133 @@ mod test {
 
     use super::*;
 
-    macro_rules! assert_blamed_lines {
-        ($repo:ident, $file:ident @ $($commit_msg:literal => $($line:literal $expected:literal),+);+ $(;)?) => {{
+    macro_rules! no_commit_flag {
+        (no_commit, $commit_msg:literal) => {
+            false
+        };
+        (, $commit_msg:literal) => {
+            true
+        };
+        ($any:tt, $commit_msg:literal) => {
+            compile_error!(concat!(
+                "expected no_commit or nothing for commit ",
+                $commit_msg
+            ))
+        };
+    }
+
+    macro_rules! add_flag {
+        (add, $commit_msg:literal, $line:expr) => {
+            true
+        };
+        (, $commit_msg:literal, $line:expr) => {
+            false
+        };
+        ($any:tt, $commit_msg:literal, $line:expr) => {
+            compile_error!(concat!(
+                "expected no_commit or nothing for commit ",
+                $commit_msg,
+                " line ",
+                $line
+            ))
+        };
+    }
+
+    /// Helper macro to create a history of the same file being modified.
+    ///
+    /// Each $commit_msg is a unique identifier for a commit message.
+    /// Each $line is a string line of the file. These $lines are collected into a single String
+    /// which then becomes the new contents of the $file
+    ///
+    /// Each $line gets blamed using blame_line. The $expected is the commit identifier that we are expecting for that line.
+    macro_rules! assert_line_blame_progress {
+        ($($commit_msg:literal $($no_commit:ident)? => $($line:literal $($expected:literal)? $($added:ident)? ),+);+ $(;)?) => {{
             use std::fs::OpenOptions;
             use std::io::Write;
 
+            let repo = empty_git_repo();
+            let file = repo.path().join("file.txt");
+            File::create(&file).expect("could not create file");
+
             let write_file = |content: &str| {
                 let mut f = OpenOptions::new()
                     .write(true)
                     .truncate(true)
-                    .open(&$file)
+                    .open(&file)
                     .unwrap();
                 f.write_all(content.as_bytes()).unwrap();
             };
 
-            let commit = |msg| create_commit_with_message($repo.path(), true, msg);
+            let commit = |msg| create_commit_with_message(repo.path(), true, msg);
 
             $(
                 let file_content = concat!($($line, "\n"),*);
+                eprintln!("at commit {}:\n\n{file_content}", stringify!($commit_msg));
                 write_file(file_content);
-                commit(stringify!($commit_msg));
+
+                let should_commit = no_commit_flag!($($no_commit)?, $commit_msg);
+                if should_commit {
+                    commit(stringify!($commit_msg));
+                }
 
                 let mut line_number = 0;
+                let mut added_lines = 0;
 
                 $(
                     line_number += 1;
-                    let blame_result = blame_line(&$file, line_number, 0, 0).unwrap().commit_message;
-                    assert_eq!(
-                        blame_result,
-                        Some(concat!(stringify!($expected), "\n").to_owned()),
-                        "Blame mismatch at line {}: expected '{}', got {:?}",
-                        line_number,
-                        stringify!($expected),
-                        blame_result
-                    );
+                    let has_add_flag = add_flag!($($added)?, $commit_msg, $line);
+                    if has_add_flag {
+                        added_lines += 1;
+                    }
+                    // if there is no $expected, then we don't care what blame_line returns
+                    // because we won't show it to the user.
+                    $(
+
+                        let blame_result = blame_line(&file, line_number, added_lines, 0).unwrap().commit_message;
+                        assert_eq!(
+                            blame_result,
+                            Some(concat!(stringify!($expected), "\n").to_owned()),
+                            "Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
+                            $commit_msg,
+                            line_number,
+                            file_content.lines().nth(line_number.try_into().unwrap()).unwrap(),
+                            stringify!($expected),
+                            blame_result.as_ref().map(|blame| blame.trim_end()).unwrap_or("<no commit>")
+                        );
+                    )?
                 )*
             )*
         }};
     }
 
+    #[test]
+    pub fn blamed_lines() {
+        assert_line_blame_progress! {
+            1 =>
+                "fn main() {" 1,
+                "" 1,
+                "}" 1;
+            2 =>
+                "fn main() {" 1,
+                "  one" 2,
+                "}" 1;
+            3 =>
+                "fn main() {" 1,
+                "  one" 2,
+                "  two" 3,
+                "}" 1;
+            4 =>
+                "fn main() {" 1,
+                "  two" 3,
+                "}" 1;
+            5 no_commit =>
+                "fn main() {" 1,
+                "  hello world" add,
+                "  two" 3,
+                "}" 1;
+        };
+    }
+
     fn bob() -> BlameInformation {
         BlameInformation {
             commit_hash: Some("f14ab1cf".to_owned()),
@@ -249,25 +343,6 @@ mod test {
         }
     }
 
-    #[test]
-    pub fn blame_lin() {
-        let repo = empty_git_repo();
-        let file = repo.path().join("file.txt");
-        File::create(&file).unwrap();
-
-        assert_blamed_lines! {
-            repo, file @
-            1 =>
-                "fn main() {" 1,
-                "" 1,
-                "}" 1;
-            2 =>
-                "fn main() {" 1,
-                "  lol" 2,
-                "}" 1;
-        };
-    }
-
     #[test]
     pub fn inline_blame_format_parser() {
         let default_values = "{author}, {date} • {message} • {commit}";

From f830c76a76ca80db1a2df8e1517192f1c85b15f3 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 23:17:43 +0000
Subject: [PATCH 29/38] test: with interspersed lines

---
 helix-vcs/src/git/blame.rs | 32 ++++++++++++++++++++++++++------
 1 file changed, 26 insertions(+), 6 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index bb626db7..6e8ac480 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -214,14 +214,14 @@ mod test {
         };
         ($any:tt, $commit_msg:literal) => {
             compile_error!(concat!(
-                "expected no_commit or nothing for commit ",
+                "expected `no_commit` or nothing for commit ",
                 $commit_msg
             ))
         };
     }
 
-    macro_rules! add_flag {
-        (add, $commit_msg:literal, $line:expr) => {
+    macro_rules! insert_flag {
+        (insert, $commit_msg:literal, $line:expr) => {
             true
         };
         (, $commit_msg:literal, $line:expr) => {
@@ -229,7 +229,7 @@ mod test {
         };
         ($any:tt, $commit_msg:literal, $line:expr) => {
             compile_error!(concat!(
-                "expected no_commit or nothing for commit ",
+                "expected `insert` or nothing for commit ",
                 $commit_msg,
                 " line ",
                 $line
@@ -244,6 +244,8 @@ mod test {
     /// which then becomes the new contents of the $file
     ///
     /// Each $line gets blamed using blame_line. The $expected is the commit identifier that we are expecting for that line.
+    ///
+    /// $commit_msg can also have a `no_commit` ident next to it, in which case this block won't be committed
     macro_rules! assert_line_blame_progress {
         ($($commit_msg:literal $($no_commit:ident)? => $($line:literal $($expected:literal)? $($added:ident)? ),+);+ $(;)?) => {{
             use std::fs::OpenOptions;
@@ -279,7 +281,7 @@ mod test {
 
                 $(
                     line_number += 1;
-                    let has_add_flag = add_flag!($($added)?, $commit_msg, $line);
+                    let has_add_flag = insert_flag!($($added)?, $commit_msg, $line);
                     if has_add_flag {
                         added_lines += 1;
                     }
@@ -311,24 +313,42 @@ mod test {
                 "fn main() {" 1,
                 "" 1,
                 "}" 1;
+            // modifying a line works
             2 =>
                 "fn main() {" 1,
                 "  one" 2,
                 "}" 1;
+            // inserting a line works
             3 =>
                 "fn main() {" 1,
                 "  one" 2,
                 "  two" 3,
                 "}" 1;
+            // deleting a line works
             4 =>
                 "fn main() {" 1,
                 "  two" 3,
                 "}" 1;
+            // when a line is inserted in-between the blame order is preserved
             5 no_commit =>
                 "fn main() {" 1,
-                "  hello world" add,
+                "  hello world" insert,
                 "  two" 3,
                 "}" 1;
+            // Having a bunch of random lines interspersed should not change which lines
+            // have blame for which commits
+            6 no_commit =>
+                "  six" insert,
+                "  three" insert,
+                "fn main() {" 1,
+                "  five" insert,
+                "  four" insert,
+                "  two" 3,
+                "  five" insert,
+                "  four" insert,
+                "}" 1,
+                "  five" insert,
+                "  four" insert;
         };
     }
 

From c8eb9f289977837827303b2d5ec4afccc3a495a2 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 23:44:36 +0000
Subject: [PATCH 30/38] feat: add `line_blame` static command

_
---
 book/src/generated/static-cmd.md |  1 +
 helix-term/src/commands.rs       | 37 ++++++++++++++++++++++++++++++++
 helix-term/src/handlers/blame.rs | 30 +++++++-------------------
 helix-term/src/keymap/default.rs |  1 +
 helix-vcs/src/diff.rs            | 14 ++++++++++++
 helix-vcs/src/git/blame.rs       |  4 +++-
 helix-vcs/src/lib.rs             |  8 +++----
 helix-view/src/document.rs       |  7 ++++++
 helix-view/src/handlers.rs       |  4 ++--
 9 files changed, 77 insertions(+), 29 deletions(-)

diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md
index af7515b8..f1cba01d 100644
--- a/book/src/generated/static-cmd.md
+++ b/book/src/generated/static-cmd.md
@@ -173,6 +173,7 @@
 | `redo` | Redo change | normal: `` U ``, select: `` U `` |
 | `earlier` | Move backward in history | normal: `` <A-u> ``, select: `` <A-u> `` |
 | `later` | Move forward in history | normal: `` <A-U> ``, select: `` <A-U> `` |
+| `line_blame` | Blame for the current line | normal: `` <space>B ``, select: `` <space>B `` |
 | `commit_undo_checkpoint` | Commit changes to new checkpoint | insert: `` <C-s> `` |
 | `yank` | Yank selection | normal: `` y ``, select: `` y `` |
 | `yank_to_clipboard` | Yank selections to clipboard | normal: `` <space>y ``, select: `` <space>y `` |
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index a197792e..09c66961 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -472,6 +472,7 @@ impl MappableCommand {
         redo, "Redo change",
         earlier, "Move backward in history",
         later, "Move forward in history",
+        line_blame, "Blame for the current line",
         commit_undo_checkpoint, "Commit changes to new checkpoint",
         yank, "Yank selection",
         yank_to_clipboard, "Yank selections to clipboard",
@@ -6559,6 +6560,42 @@ fn goto_word(cx: &mut Context) {
     jump_to_word(cx, Movement::Move)
 }
 
+fn line_blame(cx: &mut Context) {
+    let (view, doc) = current!(cx.editor);
+    const BLAME_ERROR: &str = "No blame available for the current file";
+    let Some(diff) = doc.diff_handle() else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    let Some(path) = doc.path() else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    let cursor_line = doc.cursor_line(view.id);
+    let (inserted_lines_count, deleted_lines_count) =
+        diff.load().inserted_and_deleted_before_line(cursor_line);
+
+    let Ok(cursor_line) = u32::try_from(doc.cursor_line(view.id)) else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    let Ok(output) = cx.editor.diff_providers.blame_line(
+        path,
+        cursor_line,
+        inserted_lines_count,
+        deleted_lines_count,
+    ) else {
+        cx.editor.set_error(BLAME_ERROR);
+        return;
+    };
+
+    cx.editor
+        .set_status(output.parse_format(&cx.editor.config().version_control.inline_blame_format));
+}
+
 fn extend_to_word(cx: &mut Context) {
     jump_to_word(cx, Movement::Extend)
 }
diff --git a/helix-term/src/handlers/blame.rs b/helix-term/src/handlers/blame.rs
index 0be7815b..923ae74c 100644
--- a/helix-term/src/handlers/blame.rs
+++ b/helix-term/src/handlers/blame.rs
@@ -32,19 +32,16 @@ impl helix_event::AsyncHook for BlameHandler {
             file,
             cursor_line,
             diff_providers,
-            removed_lines_count,
-            added_lines_count,
+            deleted_lines_count: removed_lines_count,
+            inserted_lines_count: added_lines_count,
             blame_format,
         } = event;
 
         self.cursor_line = cursor_line;
 
-        // convert 0-based line numbers into 1-based line numbers
-        let cursor_line = cursor_line + 1;
-
         let worker = tokio::spawn(async move {
             diff_providers
-                .blame(&file, cursor_line, added_lines_count, removed_lines_count)
+                .blame_line(&file, cursor_line, added_lines_count, removed_lines_count)
                 .map(|s| s.parse_format(&blame_format))
         });
         self.worker = Some(worker);
@@ -87,38 +84,27 @@ pub(super) fn register_hooks(handlers: &Handlers) {
         }
 
         let (view, doc) = current!(event.cx.editor);
-        let text = doc.text();
-        let selection = doc.selection(view.id);
         let Some(file) = doc.path() else {
             return Ok(());
         };
         let file = file.to_path_buf();
 
-        let Ok(cursor_line) =
-            u32::try_from(text.char_to_line(selection.primary().cursor(doc.text().slice(..))))
-        else {
+        let Ok(cursor_line) = u32::try_from(doc.cursor_line(view.id)) else {
             return Ok(());
         };
 
         let hunks = doc.diff_handle().unwrap().load();
 
-        let mut removed_lines_count: u32 = 0;
-        let mut added_lines_count: u32 = 0;
-        for hunk in hunks.hunks_intersecting_line_ranges(std::iter::once((0, cursor_line as usize)))
-        {
-            let lines_inserted = hunk.after.end - hunk.after.start;
-            let lines_removed = hunk.before.end - hunk.before.start;
-            added_lines_count += lines_inserted;
-            removed_lines_count += lines_removed;
-        }
+        let (inserted_lines_count, deleted_lines_count) =
+            hunks.inserted_and_deleted_before_line(cursor_line as usize);
 
         send_blocking(
             &tx,
             BlameEvent::PostCommand {
                 file,
                 cursor_line,
-                removed_lines_count,
-                added_lines_count,
+                deleted_lines_count,
+                inserted_lines_count,
                 // ok to clone because diff_providers is very small
                 diff_providers: event.cx.editor.diff_providers.clone(),
                 // ok to clone because blame_format is likely to be about 30 characters or less
diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs
index e160b224..4e24624c 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -232,6 +232,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
             "D" => workspace_diagnostics_picker,
             "g" => changed_file_picker,
             "a" => code_action,
+            "B" => line_blame,
             "'" => last_picker,
             "G" => { "Debug (experimental)" sticky=true
                 "l" => dap_launch,
diff --git a/helix-vcs/src/diff.rs b/helix-vcs/src/diff.rs
index e49e171d..80c861d0 100644
--- a/helix-vcs/src/diff.rs
+++ b/helix-vcs/src/diff.rs
@@ -177,6 +177,20 @@ impl Diff<'_> {
         }
     }
 
+    /// Get the amount of lines inserted and deleted before a given line
+    pub fn inserted_and_deleted_before_line(&self, cursor_line: usize) -> (u32, u32) {
+        let mut inserted_lines_count: u32 = 0;
+        let mut deleted_lines_count: u32 = 0;
+        for hunk in self.hunks_intersecting_line_ranges(std::iter::once((0, cursor_line))) {
+            let lines_inserted = hunk.after.end - hunk.after.start;
+            let lines_removed = hunk.before.end - hunk.before.start;
+            inserted_lines_count += lines_inserted;
+            deleted_lines_count += lines_removed;
+        }
+
+        (inserted_lines_count, deleted_lines_count)
+    }
+
     pub fn doc(&self) -> &Rope {
         if self.inverted {
             &self.diff.diff_base
diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 6e8ac480..a56b361c 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -134,7 +134,9 @@ pub fn blame_line(
     // So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
     // the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
     // we can show to the user nothing at all. This is detected in the editor
-    let blame_line = line.saturating_sub(added_lines_count) + removed_lines_count;
+    //
+    // Add 1 to convert 0-based line numbers into 1-based
+    let blame_line = line.saturating_sub(added_lines_count) + removed_lines_count + 1;
 
     let repo_dir = get_repo_dir(file)?;
     let repo = open_repo(repo_dir)
diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs
index c63558c0..6f8e3b0f 100644
--- a/helix-vcs/src/lib.rs
+++ b/helix-vcs/src/lib.rs
@@ -50,8 +50,8 @@ impl DiffProviderRegistry {
             })
     }
 
-    /// Blame range of lines in a file. Lines are 1-indexed
-    pub fn blame(
+    /// Blame a line in a file
+    pub fn blame_line(
         &self,
         file: &Path,
         line: u32,
@@ -60,7 +60,7 @@ impl DiffProviderRegistry {
     ) -> anyhow::Result<BlameInformation> {
         self.providers
             .iter()
-            .map(|provider| provider.blame(file, line, added_lines_count, removed_lines_count))
+            .map(|provider| provider.blame_line(file, line, added_lines_count, removed_lines_count))
             .next()
             .context("No provider found")?
     }
@@ -125,7 +125,7 @@ impl DiffProvider {
         }
     }
 
-    fn blame(
+    fn blame_line(
         &self,
         file: &Path,
         line: u32,
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 482cd1df..0ed23bad 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -1262,6 +1262,13 @@ impl Document {
         Range::new(0, 1).grapheme_aligned(self.text().slice(..))
     }
 
+    /// Get the line of cursor for the primary selection
+    pub fn cursor_line(&self, view_id: ViewId) -> usize {
+        let text = self.text();
+        let selection = self.selection(view_id);
+        text.char_to_line(selection.primary().cursor(text.slice(..)))
+    }
+
     /// Reset the view's selection on this document to the
     /// [origin](Document::origin) cursor.
     pub fn reset_selection(&mut self, view_id: ViewId) {
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index 99789102..7926ec81 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -25,9 +25,9 @@ pub enum BlameEvent {
         file: PathBuf,
         cursor_line: u32,
         /// How many lines were removed before cursor_line
-        removed_lines_count: u32,
+        deleted_lines_count: u32,
         /// How many lines were added before cursor_line
-        added_lines_count: u32,
+        inserted_lines_count: u32,
         diff_providers: DiffProviderRegistry,
         /// Format of the blame
         blame_format: String,

From 29c49d3d3652d6c07d881ad7df123ac3b09dd496 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 00:10:50 +0000
Subject: [PATCH 31/38] test: add an extra test case

---
 helix-vcs/src/git/blame.rs | 21 ++++++++++++++++++---
 1 file changed, 18 insertions(+), 3 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index a56b361c..465c36dc 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -282,7 +282,6 @@ mod test {
                 let mut added_lines = 0;
 
                 $(
-                    line_number += 1;
                     let has_add_flag = insert_flag!($($added)?, $commit_msg, $line);
                     if has_add_flag {
                         added_lines += 1;
@@ -303,6 +302,7 @@ mod test {
                             blame_result.as_ref().map(|blame| blame.trim_end()).unwrap_or("<no commit>")
                         );
                     )?
+                    line_number += 1;
                 )*
             )*
         }};
@@ -332,14 +332,14 @@ mod test {
                 "  two" 3,
                 "}" 1;
             // when a line is inserted in-between the blame order is preserved
-            5 no_commit =>
+            0 no_commit =>
                 "fn main() {" 1,
                 "  hello world" insert,
                 "  two" 3,
                 "}" 1;
             // Having a bunch of random lines interspersed should not change which lines
             // have blame for which commits
-            6 no_commit =>
+            0 no_commit =>
                 "  six" insert,
                 "  three" insert,
                 "fn main() {" 1,
@@ -351,6 +351,21 @@ mod test {
                 "}" 1,
                 "  five" insert,
                 "  four" insert;
+            // committing all of those insertions should recognize that they are
+            // from the current commit, while still keeping the information about
+            // previous commits
+            5 =>
+                "  six" 5,
+                "  three" 5,
+                "fn main() {" 1,
+                "  five" 5,
+                "  four" 5,
+                "  two" 3,
+                "  five" 5,
+                "  four" 5,
+                "}" 1,
+                "  five" 5,
+                "  four" 5;
         };
     }
 

From 983b0a14dab7498410e38e599220aa7bf724acf7 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 00:44:32 +0000
Subject: [PATCH 32/38] test: add ability to have `delete`d lines

---
 helix-vcs/src/git/blame.rs | 90 ++++++++++++++++++++++++++++----------
 1 file changed, 68 insertions(+), 22 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 465c36dc..184ea174 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -222,16 +222,48 @@ mod test {
         };
     }
 
-    macro_rules! insert_flag {
+    #[derive(PartialEq, PartialOrd, Ord, Eq)]
+    enum LineDiff {
+        Insert,
+        Delete,
+        None,
+    }
+
+    macro_rules! line_diff_flag {
         (insert, $commit_msg:literal, $line:expr) => {
-            true
+            LineDiff::Insert
+        };
+        (delete, $commit_msg:literal, $line:expr) => {
+            LineDiff::Delete
         };
         (, $commit_msg:literal, $line:expr) => {
-            false
+            LineDiff::None
         };
         ($any:tt, $commit_msg:literal, $line:expr) => {
             compile_error!(concat!(
-                "expected `insert` or nothing for commit ",
+                "expected `insert`, `delete` or nothing for commit ",
+                $commit_msg,
+                " line ",
+                $line
+            ))
+        };
+    }
+
+    /// this utility macro exists because we can't pass a `match` statement into `concat!`
+    /// we wouldl like to exclude any lines that are "delete"
+    macro_rules! line_diff_flag_str {
+        (insert, $commit_msg:literal, $line:expr) => {
+            concat!($line, "\n")
+        };
+        (delete, $commit_msg:literal, $line:expr) => {
+            ""
+        };
+        (, $commit_msg:literal, $line:expr) => {
+            concat!($line, "\n")
+        };
+        ($any:tt, $commit_msg:literal, $line:expr) => {
+            compile_error!(concat!(
+                "expected `insert`, `delete` or nothing for commit ",
                 $commit_msg,
                 " line ",
                 $line
@@ -249,7 +281,7 @@ mod test {
     ///
     /// $commit_msg can also have a `no_commit` ident next to it, in which case this block won't be committed
     macro_rules! assert_line_blame_progress {
-        ($($commit_msg:literal $($no_commit:ident)? => $($line:literal $($expected:literal)? $($added:ident)? ),+);+ $(;)?) => {{
+        ($($commit_msg:literal $($no_commit:ident)? => $($line:literal $($expected:literal)? $($line_diff:ident)? ),+);+ $(;)?) => {{
             use std::fs::OpenOptions;
             use std::io::Write;
 
@@ -257,40 +289,42 @@ mod test {
             let file = repo.path().join("file.txt");
             File::create(&file).expect("could not create file");
 
-            let write_file = |content: &str| {
+            $(
+                let file_content = concat!(
+                    $(
+                        line_diff_flag_str!($($line_diff)?, $commit_msg, $line),
+                    )*
+                );
+                eprintln!("at commit {}:\n\n{file_content}", stringify!($commit_msg));
+
                 let mut f = OpenOptions::new()
                     .write(true)
                     .truncate(true)
                     .open(&file)
                     .unwrap();
-                f.write_all(content.as_bytes()).unwrap();
-            };
-
-            let commit = |msg| create_commit_with_message(repo.path(), true, msg);
-
-            $(
-                let file_content = concat!($($line, "\n"),*);
-                eprintln!("at commit {}:\n\n{file_content}", stringify!($commit_msg));
-                write_file(file_content);
+                f.write_all(file_content.as_bytes()).unwrap();
 
                 let should_commit = no_commit_flag!($($no_commit)?, $commit_msg);
                 if should_commit {
-                    commit(stringify!($commit_msg));
+                    create_commit_with_message(repo.path(), true, stringify!($commit_msg));
                 }
 
                 let mut line_number = 0;
                 let mut added_lines = 0;
+                let mut removed_lines = 0;
 
                 $(
-                    let has_add_flag = insert_flag!($($added)?, $commit_msg, $line);
-                    if has_add_flag {
-                        added_lines += 1;
+                    let line_diff_flag = line_diff_flag!($($line_diff)?, $commit_msg, $line);
+                    match line_diff_flag {
+                        LineDiff::Insert => added_lines += 1,
+                        LineDiff::Delete => removed_lines += 1,
+                        LineDiff::None => ()
                     }
                     // if there is no $expected, then we don't care what blame_line returns
                     // because we won't show it to the user.
                     $(
 
-                        let blame_result = blame_line(&file, line_number, added_lines, 0).unwrap().commit_message;
+                        let blame_result = blame_line(&file, line_number, added_lines, removed_lines).unwrap().commit_message;
                         assert_eq!(
                             blame_result,
                             Some(concat!(stringify!($expected), "\n").to_owned()),
@@ -332,14 +366,14 @@ mod test {
                 "  two" 3,
                 "}" 1;
             // when a line is inserted in-between the blame order is preserved
-            0 no_commit =>
+            4 no_commit =>
                 "fn main() {" 1,
                 "  hello world" insert,
                 "  two" 3,
                 "}" 1;
             // Having a bunch of random lines interspersed should not change which lines
             // have blame for which commits
-            0 no_commit =>
+            4 no_commit =>
                 "  six" insert,
                 "  three" insert,
                 "fn main() {" 1,
@@ -366,6 +400,18 @@ mod test {
                 "}" 1,
                 "  five" 5,
                 "  four" 5;
+            // 5 no_commit =>
+            //     "  six" 5,
+            //     "  three" 5,
+            //     "fn main() {" delete,
+            //     "  five" delete,
+            //     "  four" delete,
+            //     "  two" delete,
+            //     "  five" delete,
+            //     "  four" 5,
+            //     "}" 1,
+            //     "  five" 5,
+            //     "  four" 5;
         };
     }
 

From bfdd67f62512e808790e4f4c26d1a7abc3c239ab Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 01:21:24 +0000
Subject: [PATCH 33/38] test: fix on windows (?)

---
 helix-vcs/src/git/blame.rs | 20 ++++++++++++++++++--
 1 file changed, 18 insertions(+), 2 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 184ea174..918a759a 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -271,6 +271,21 @@ mod test {
         };
     }
 
+    /// We need to use it in `concat!` so we can't use std::path::MAIN_SEPARATOR_STR
+    /// Also, attributes on expressions are experimental so we have to use a whole macro for this
+    #[cfg(windows)]
+    macro_rules! path_separator_literal {
+        () => {
+            "\r\n"
+        };
+    }
+    #[cfg(not(windows))]
+    macro_rules! path_separator_literal {
+        () => {
+            "\n"
+        };
+    }
+
     /// Helper macro to create a history of the same file being modified.
     ///
     /// Each $commit_msg is a unique identifier for a commit message.
@@ -320,6 +335,7 @@ mod test {
                         LineDiff::Delete => removed_lines += 1,
                         LineDiff::None => ()
                     }
+                    dbg!(added_lines, removed_lines, line_number);
                     // if there is no $expected, then we don't care what blame_line returns
                     // because we won't show it to the user.
                     $(
@@ -327,7 +343,7 @@ mod test {
                         let blame_result = blame_line(&file, line_number, added_lines, removed_lines).unwrap().commit_message;
                         assert_eq!(
                             blame_result,
-                            Some(concat!(stringify!($expected), "\n").to_owned()),
+                            Some(concat!(stringify!($expected), path_separator_literal!()).to_owned()),
                             "Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
                             $commit_msg,
                             line_number,
@@ -408,7 +424,7 @@ mod test {
             //     "  four" delete,
             //     "  two" delete,
             //     "  five" delete,
-            //     "  four" 5,
+            //     "  four",
             //     "}" 1,
             //     "  five" 5,
             //     "  four" 5;

From 3db6fd157c9db4a85bff5ce7544ffeb211626d48 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 02:00:52 +0000
Subject: [PATCH 34/38] test: `delete` test case

---
 helix-vcs/src/git/blame.rs | 63 +++++++++++++++++++-------------------
 1 file changed, 32 insertions(+), 31 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 918a759a..d2d4416c 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -253,13 +253,13 @@ mod test {
     /// we wouldl like to exclude any lines that are "delete"
     macro_rules! line_diff_flag_str {
         (insert, $commit_msg:literal, $line:expr) => {
-            concat!($line, "\n")
+            concat!($line, path_separator_literal!())
         };
         (delete, $commit_msg:literal, $line:expr) => {
             ""
         };
         (, $commit_msg:literal, $line:expr) => {
-            concat!($line, "\n")
+            concat!($line, path_separator_literal!())
         };
         ($any:tt, $commit_msg:literal, $line:expr) => {
             compile_error!(concat!(
@@ -335,24 +335,25 @@ mod test {
                         LineDiff::Delete => removed_lines += 1,
                         LineDiff::None => ()
                     }
-                    dbg!(added_lines, removed_lines, line_number);
-                    // if there is no $expected, then we don't care what blame_line returns
-                    // because we won't show it to the user.
-                    $(
+                    if line_diff_flag != LineDiff::Delete {
+                        // if there is no $expected, then we don't care what blame_line returns
+                        // because we won't show it to the user.
+                        $(
 
-                        let blame_result = blame_line(&file, line_number, added_lines, removed_lines).unwrap().commit_message;
-                        assert_eq!(
-                            blame_result,
-                            Some(concat!(stringify!($expected), path_separator_literal!()).to_owned()),
-                            "Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
-                            $commit_msg,
-                            line_number,
-                            file_content.lines().nth(line_number.try_into().unwrap()).unwrap(),
-                            stringify!($expected),
-                            blame_result.as_ref().map(|blame| blame.trim_end()).unwrap_or("<no commit>")
-                        );
-                    )?
-                    line_number += 1;
+                            let blame_result = blame_line(&file, line_number, added_lines, removed_lines).unwrap().commit_message;
+                            assert_eq!(
+                                blame_result,
+                                Some(concat!(stringify!($expected), path_separator_literal!()).to_owned()),
+                                "Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
+                                $commit_msg,
+                                line_number,
+                                file_content.lines().nth(line_number.try_into().unwrap()).unwrap(),
+                                stringify!($expected),
+                                blame_result.as_ref().map(|blame| blame.trim_end()).unwrap_or("<no commit>")
+                            );
+                        )?
+                        line_number += 1;
+                    }
                 )*
             )*
         }};
@@ -416,18 +417,18 @@ mod test {
                 "}" 1,
                 "  five" 5,
                 "  four" 5;
-            // 5 no_commit =>
-            //     "  six" 5,
-            //     "  three" 5,
-            //     "fn main() {" delete,
-            //     "  five" delete,
-            //     "  four" delete,
-            //     "  two" delete,
-            //     "  five" delete,
-            //     "  four",
-            //     "}" 1,
-            //     "  five" 5,
-            //     "  four" 5;
+            5 no_commit =>
+                "  six" 5,
+                "  three" 5,
+                "fn main() {" delete,
+                "  five" delete,
+                "  four" delete,
+                "  two" delete,
+                "  five" delete,
+                "  four",
+                "}" 1,
+                "  five" 5,
+                "  four" 5;
         };
     }
 

From 9a82b5b3a62382487f06f32aac94a22df9770031 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 02:04:40 +0000
Subject: [PATCH 35/38] test: add extra step to test case

---
 helix-vcs/src/git/blame.rs | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index d2d4416c..c8ec987b 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -417,6 +417,7 @@ mod test {
                 "}" 1,
                 "  five" 5,
                 "  four" 5;
+            // several lines deleted
             5 no_commit =>
                 "  six" 5,
                 "  three" 5,
@@ -429,6 +430,14 @@ mod test {
                 "}" 1,
                 "  five" 5,
                 "  four" 5;
+            // committing the deleted changes
+            6 =>
+                "  six" 5,
+                "  three" 5,
+                "  four" 5,
+                "}" 1,
+                "  five" 5,
+                "  four" 5;
         };
     }
 

From 2d17365ed3ff9feaac27bfccaa3a07a844072507 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 02:18:03 +0000
Subject: [PATCH 36/38] test: add documentation for macro

---
 helix-vcs/src/git/blame.rs | 74 ++++++++++++++++++++++++++------------
 1 file changed, 51 insertions(+), 23 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index c8ec987b..6342fc21 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -207,6 +207,18 @@ mod test {
 
     use super::*;
 
+    /// describes how a line was modified
+    #[derive(PartialEq, PartialOrd, Ord, Eq)]
+    enum LineDiff {
+        /// this line is added
+        Insert,
+        /// this line is deleted
+        Delete,
+        /// no changes for this line
+        None,
+    }
+
+    /// checks if the first argument is `no_commit` or not
     macro_rules! no_commit_flag {
         (no_commit, $commit_msg:literal) => {
             false
@@ -222,13 +234,7 @@ mod test {
         };
     }
 
-    #[derive(PartialEq, PartialOrd, Ord, Eq)]
-    enum LineDiff {
-        Insert,
-        Delete,
-        None,
-    }
-
+    /// checks if the first argument is `insert` or `delete`
     macro_rules! line_diff_flag {
         (insert, $commit_msg:literal, $line:expr) => {
             LineDiff::Insert
@@ -249,8 +255,8 @@ mod test {
         };
     }
 
-    /// this utility macro exists because we can't pass a `match` statement into `concat!`
-    /// we wouldl like to exclude any lines that are "delete"
+    /// This macro exists because we can't pass a `match` statement into `concat!`
+    /// we would like to exclude any lines that are `delete`
     macro_rules! line_diff_flag_str {
         (insert, $commit_msg:literal, $line:expr) => {
             concat!($line, path_separator_literal!())
@@ -271,8 +277,7 @@ mod test {
         };
     }
 
-    /// We need to use it in `concat!` so we can't use std::path::MAIN_SEPARATOR_STR
-    /// Also, attributes on expressions are experimental so we have to use a whole macro for this
+    /// Attributes on expressions are experimental so we have to use a whole macro for this
     #[cfg(windows)]
     macro_rules! path_separator_literal {
         () => {
@@ -287,16 +292,29 @@ mod test {
     }
 
     /// Helper macro to create a history of the same file being modified.
-    ///
-    /// Each $commit_msg is a unique identifier for a commit message.
-    /// Each $line is a string line of the file. These $lines are collected into a single String
-    /// which then becomes the new contents of the $file
-    ///
-    /// Each $line gets blamed using blame_line. The $expected is the commit identifier that we are expecting for that line.
-    ///
-    /// $commit_msg can also have a `no_commit` ident next to it, in which case this block won't be committed
     macro_rules! assert_line_blame_progress {
-        ($($commit_msg:literal $($no_commit:ident)? => $($line:literal $($expected:literal)? $($line_diff:ident)? ),+);+ $(;)?) => {{
+        (
+            $(
+                // a unique identifier for the commit, other commits must not use this
+                // If `no_commit` option is used, use the identifier of the previous commit
+                $commit_msg:literal
+                // must be `no_commit` if exists.
+                // If exists, this block won't be committed
+                $($no_commit:ident)? =>
+                $(
+                    // contents of a line in the file
+                    $line:literal
+                    // what commit identifier we are expecting for this line
+                    $($expected:literal)?
+                    // must be `insert` or `delete` if exists
+                    // if exists, must be used with `no_commit`
+                    // - `insert`: this line is added
+                    // - `delete`: this line is deleted
+                    $($line_diff:ident)?
+                ),+
+            );+
+            $(;)?
+        ) => {{
             use std::fs::OpenOptions;
             use std::io::Write;
 
@@ -317,6 +335,7 @@ mod test {
                     .truncate(true)
                     .open(&file)
                     .unwrap();
+
                 f.write_all(file_content.as_bytes()).unwrap();
 
                 let should_commit = no_commit_flag!($($no_commit)?, $commit_msg);
@@ -340,16 +359,25 @@ mod test {
                         // because we won't show it to the user.
                         $(
 
-                            let blame_result = blame_line(&file, line_number, added_lines, removed_lines).unwrap().commit_message;
+                            let blame_result =
+                                blame_line(&file, line_number, added_lines, removed_lines)
+                                    .unwrap()
+                                    .commit_message;
                             assert_eq!(
                                 blame_result,
                                 Some(concat!(stringify!($expected), path_separator_literal!()).to_owned()),
                                 "Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
                                 $commit_msg,
                                 line_number,
-                                file_content.lines().nth(line_number.try_into().unwrap()).unwrap(),
+                                file_content
+                                    .lines()
+                                    .nth(line_number.try_into().unwrap())
+                                    .unwrap(),
                                 stringify!($expected),
-                                blame_result.as_ref().map(|blame| blame.trim_end()).unwrap_or("<no commit>")
+                                blame_result
+                                    .as_ref()
+                                    .map(|blame| blame.trim_end())
+                                    .unwrap_or("<no commit>")
                             );
                         )?
                         line_number += 1;

From 992801d1052a7e51c92344741da809c762ddc9b2 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 02:34:24 +0000
Subject: [PATCH 37/38] refactor: use `hashmap!` macro

---
 helix-vcs/src/git/blame.rs | 29 ++++++++++++++++-------------
 1 file changed, 16 insertions(+), 13 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 6342fc21..607f06bc 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -1,5 +1,6 @@
 use anyhow::Context as _;
 use gix::bstr::BStr;
+use helix_core::hashmap;
 use std::{collections::HashMap, path::Path};
 
 use super::{get_repo_dir, open_repo};
@@ -20,14 +21,14 @@ impl BlameInformation {
         let mut formatted = String::new();
         let mut content_before_variable = String::new();
 
-        let variables = HashMap::from([
-            ("commit", &self.commit_hash),
-            ("author", &self.author_name),
-            ("date", &self.commit_date),
-            ("message", &self.commit_message),
-            ("email", &self.author_email),
-            ("body", &self.commit_body),
-        ]);
+        let variables = hashmap! {
+            "commit" => &self.commit_hash,
+            "author" => &self.author_name,
+            "date" => &self.commit_date,
+            "message" => &self.commit_message,
+            "email" => &self.author_email,
+            "body" => &self.commit_body,
+        };
 
         let mut chars = format.chars().peekable();
         // in all cases, when any of the variables is empty we exclude the content before the variable
@@ -259,13 +260,13 @@ mod test {
     /// we would like to exclude any lines that are `delete`
     macro_rules! line_diff_flag_str {
         (insert, $commit_msg:literal, $line:expr) => {
-            concat!($line, path_separator_literal!())
+            concat!($line, newline_literal!())
         };
         (delete, $commit_msg:literal, $line:expr) => {
             ""
         };
         (, $commit_msg:literal, $line:expr) => {
-            concat!($line, path_separator_literal!())
+            concat!($line, newline_literal!())
         };
         ($any:tt, $commit_msg:literal, $line:expr) => {
             compile_error!(concat!(
@@ -279,13 +280,13 @@ mod test {
 
     /// Attributes on expressions are experimental so we have to use a whole macro for this
     #[cfg(windows)]
-    macro_rules! path_separator_literal {
+    macro_rules! newline_literal {
         () => {
             "\r\n"
         };
     }
     #[cfg(not(windows))]
-    macro_rules! path_separator_literal {
+    macro_rules! newline_literal {
         () => {
             "\n"
         };
@@ -354,6 +355,7 @@ mod test {
                         LineDiff::Delete => removed_lines += 1,
                         LineDiff::None => ()
                     }
+                    // completely skip lines that are marked as `delete`
                     if line_diff_flag != LineDiff::Delete {
                         // if there is no $expected, then we don't care what blame_line returns
                         // because we won't show it to the user.
@@ -365,7 +367,7 @@ mod test {
                                     .commit_message;
                             assert_eq!(
                                 blame_result,
-                                Some(concat!(stringify!($expected), path_separator_literal!()).to_owned()),
+                                Some(concat!(stringify!($expected), newline_literal!()).to_owned()),
                                 "Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
                                 $commit_msg,
                                 line_number,
@@ -390,6 +392,7 @@ mod test {
     #[test]
     pub fn blamed_lines() {
         assert_line_blame_progress! {
+            // initialize
             1 =>
                 "fn main() {" 1,
                 "" 1,

From 74e08a67adfe597b80abc09ba596eb9955558556 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Thu, 20 Mar 2025 02:36:01 +0000
Subject: [PATCH 38/38] refactor: collapse match arm

---
 helix-vcs/src/git/blame.rs | 13 ++-----------
 1 file changed, 2 insertions(+), 11 deletions(-)

diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 607f06bc..dbf44679 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -1,7 +1,7 @@
 use anyhow::Context as _;
 use gix::bstr::BStr;
 use helix_core::hashmap;
-use std::{collections::HashMap, path::Path};
+use std::path::Path;
 
 use super::{get_repo_dir, open_repo};
 
@@ -77,16 +77,7 @@ impl BlameInformation {
                 );
 
                 match variable_value {
-                    Variable::Valid(value) => {
-                        if exclude_content_after_variable {
-                            // don't push anything.
-                            exclude_content_after_variable = false;
-                        } else {
-                            formatted.push_str(&content_before_variable);
-                        }
-                        formatted.push_str(&value);
-                    }
-                    Variable::Invalid(value) => {
+                    Variable::Valid(value) | Variable::Invalid(value) => {
                         if exclude_content_after_variable {
                             // don't push anything.
                             exclude_content_after_variable = false;