From ce9b212594246ca9f8beb4b36b314e5293b60f93 Mon Sep 17 00:00:00 2001 From: Bark Date: Fri, 9 Feb 2024 14:37:21 +0200 Subject: [PATCH] feat: Add git-blame style. * Added a new decorator. * Added required changes in `config` in order to recognize 2 new CLI options/arguments. * Added a simple format ability with the placeholders from [git's pretty-formats](https://git-scm.com/docs/pretty-formats) * Updated doc tests to take into consideration new options. --- doc/long-help.txt | 7 +++++ doc/short-help.txt | 4 ++- src/bin/bat/app.rs | 6 ++++ src/bin/bat/clap_app.rs | 24 +++++++++++++-- src/config.rs | 4 +++ src/controller.rs | 32 ++++++++++++++++---- src/decorations.rs | 59 +++++++++++++++++++++++++++++++++++- src/diff.rs | 66 +++++++++++++++++++++++++++++++++++++++-- src/printer.rs | 23 ++++++++++++-- src/style.rs | 11 +++++++ 10 files changed, 223 insertions(+), 13 deletions(-) diff --git a/doc/long-help.txt b/doc/long-help.txt index 247120fb..2553e3da 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -138,6 +138,7 @@ Options: * rule: horizontal lines to delimit files. * numbers: show line numbers in the side bar. * snip: draw separation lines between distinct line ranges. + * blame: show Git blame information. -r, --line-range Only print the specified range of lines for each file. For example: @@ -154,6 +155,12 @@ Options: This option exists for POSIX-compliance reasons ('u' is for 'unbuffered'). The output is always unbuffered - this option is simply ignored. + --blame-format + Set the format for the Git blame output. The format string can contain placeholders like + '%h' (abbreviated commit hash), '%an' (author name), '%H' (full commit hash), '%s' + (summary), '%d' (ref names), '%m' (message), '%ae' (author email), '%cn' (committer name), + '%ce' (committer email) + --diagnostic Show diagnostic information for bug reports. diff --git a/doc/short-help.txt b/doc/short-help.txt index 118dbce2..d77b1f8f 100644 --- a/doc/short-help.txt +++ b/doc/short-help.txt @@ -45,11 +45,13 @@ Options: Display all supported highlighting themes. --style Comma-separated list of style elements to display (*default*, auto, full, plain, changes, - header, header-filename, header-filesize, grid, rule, numbers, snip). + header, header-filename, header-filesize, grid, rule, numbers, snip, blame). -r, --line-range Only print the lines from N to M. -L, --list-languages Display all supported languages. + --blame-format + Specify a format for the git-blame content. -h, --help Print help (see more with '--help') -V, --version diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index a2c09770..4fd8ea3a 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -289,6 +289,12 @@ impl App { use_custom_assets: !self.matches.get_flag("no-custom-assets"), #[cfg(feature = "lessopen")] use_lessopen: self.matches.get_flag("lessopen"), + #[cfg(feature = "git")] + blame_format: self + .matches + .get_one::("blame-format") + .map(String::from) + .unwrap_or_else(|| String::from("%h: %an <%ae>")), }) } diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index e8222a1d..0d5df14b 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -411,6 +411,8 @@ pub fn build_app(interactive_output: bool) -> Command { "snip", #[cfg(feature = "git")] "changes", + #[cfg(feature = "git")] + "blame", ].contains(style) }); @@ -422,7 +424,7 @@ pub fn build_app(interactive_output: bool) -> Command { }) .help( "Comma-separated list of style elements to display \ - (*default*, auto, full, plain, changes, header, header-filename, header-filesize, grid, rule, numbers, snip).", + (*default*, auto, full, plain, changes, header, header-filename, header-filesize, grid, rule, numbers, snip, blame).", ) .long_help( "Configure which elements (line numbers, file headers, grid \ @@ -445,7 +447,8 @@ pub fn build_app(interactive_output: bool) -> Command { and the header from the content.\n \ * rule: horizontal lines to delimit files.\n \ * numbers: show line numbers in the side bar.\n \ - * snip: draw separation lines between distinct line ranges.", + * snip: draw separation lines between distinct line ranges.\n \ + * blame: show Git blame information.", ), ) .arg( @@ -520,6 +523,23 @@ pub fn build_app(interactive_output: bool) -> Command { ) } + #[cfg(feature = "git")] + { + app = app + .arg( + Arg::new("blame-format") + .long("blame-format") + .value_name("format") + .help("Specify a format for the git-blame content.") + .long_help( + "Set the format for the Git blame output. The format string \ + can contain placeholders like '%h' (abbreviated commit hash), '%an' (author name), \ + '%H' (full commit hash), '%s' (summary), '%d' (ref names), '%m' (message), \ + '%ae' (author email), '%cn' (committer name), '%ce' (committer email)", + ), + ) + } + app = app .arg( Arg::new("config-file") diff --git a/src/config.rs b/src/config.rs index 83acc7df..d954b602 100644 --- a/src/config.rs +++ b/src/config.rs @@ -94,6 +94,10 @@ pub struct Config<'a> { // Whether or not to use $LESSOPEN if set #[cfg(feature = "lessopen")] pub use_lessopen: bool, + + // Format for the git blame column + #[cfg(feature = "git")] + pub blame_format: String, } #[cfg(all(feature = "minimal-application", feature = "paging"))] diff --git a/src/controller.rs b/src/controller.rs index ffc5dd5b..94b37fe1 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -1,9 +1,8 @@ use std::io::{self, BufRead, Write}; - use crate::assets::HighlightingAssets; use crate::config::{Config, VisibleLines}; #[cfg(feature = "git")] -use crate::diff::{get_git_diff, LineChanges}; +use crate::diff::{get_git_diff, LineChanges, get_blame_file}; use crate::error::*; use crate::input::{Input, InputReader, OpenedInput}; #[cfg(feature = "lessopen")] @@ -174,6 +173,30 @@ impl<'b> Controller<'b> { None }; + #[cfg(feature = "git")] + let line_blames = if !self.config.loop_through && self.config.style_components.blame() + { + match opened_input.kind { + crate::input::OpenedInputKind::OrdinaryFile(ref path) => { + let blame_format = self.config.blame_format.clone(); + let blames = get_blame_file(path, &blame_format); + + // Skip files without Git modifications + if blames + .as_ref() + .map(|changes| changes.is_empty()) + .unwrap_or(false) + { + return Ok(()); + } + blames + } + _ => None, + } + } else { + None + }; + let mut printer: Box = if self.config.loop_through { Box::new(SimplePrinter::new(self.config)) } else { @@ -183,6 +206,8 @@ impl<'b> Controller<'b> { &mut opened_input, #[cfg(feature = "git")] &line_changes, + #[cfg(feature = "git")] + &line_blames, )?) }; @@ -222,11 +247,9 @@ impl<'b> Controller<'b> { .push(LineRange::new(line.saturating_sub(context), line + context)); } } - LineRanges::from(line_ranges) } }; - self.print_file_ranges(printer, writer, &mut input.reader, &line_ranges)?; } printer.print_footer(writer, input)?; @@ -268,7 +291,6 @@ impl<'b> Controller<'b> { printer.print_snip(writer)?; } } - printer.print_line(false, writer, line_number, &line_buffer)?; } RangeCheckResult::AfterLastRange => { diff --git a/src/decorations.rs b/src/decorations.rs index d3ed9b34..0841f76b 100644 --- a/src/decorations.rs +++ b/src/decorations.rs @@ -1,5 +1,5 @@ #[cfg(feature = "git")] -use crate::diff::LineChange; +use crate::diff::{LineChange}; use crate::printer::{Colors, InteractivePrinter}; use nu_ansi_term::Style; @@ -127,6 +127,63 @@ impl Decoration for LineChangesDecoration { } } +#[cfg(feature = "git")] +pub(crate) struct LineBlamesDecoration { + color: Style, + max_length: usize, +} + +#[cfg(feature = "git")] +impl LineBlamesDecoration { + #[inline] + fn generate_cached(style: Style, text: &str, length: usize) -> DecorationText { + DecorationText { + text: style.paint(text).to_string(), + width: length, + } + } + + pub(crate) fn new(colors: &Colors, max_length: usize) -> Self { + LineBlamesDecoration { + color: colors.git_blame, + max_length: max_length, + } + } +} + +#[cfg(feature = "git")] +impl Decoration for LineBlamesDecoration { + fn generate( + &self, + line_number: usize, + continuation: bool, + printer: &InteractivePrinter, + ) -> DecorationText { + if !continuation { + if let Some(ref changes) = printer.line_blames { + let result = changes.get(&(line_number as u32)); + if let Some(result) = result { + let length = self.width(); + if result.len() < length { + return Self::generate_cached( + self.color, + format!("{: usize { + self.max_length + } +} + pub(crate) struct GridBorderDecoration { cached: DecorationText, } diff --git a/src/diff.rs b/src/diff.rs index 78d20c30..95fe87e8 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -3,8 +3,7 @@ use std::collections::HashMap; use std::fs; use std::path::Path; - -use git2::{DiffOptions, IntoCString, Repository}; +use git2::{Commit, BlameHunk, Blame, BlameOptions, DiffOptions, IntoCString, Repository}; #[derive(Copy, Clone, Debug)] pub enum LineChange { @@ -15,6 +14,7 @@ pub enum LineChange { } pub type LineChanges = HashMap; +pub type LineBlames = HashMap; pub fn get_git_diff(filename: &Path) -> Option { let repo = Repository::discover(filename).ok()?; @@ -81,3 +81,65 @@ pub fn get_git_diff(filename: &Path) -> Option { Some(line_changes) } + +pub fn get_blame_line(blame: &Blame, filename: &Path, line: u32, blame_format: &str) -> Option { + let repo = Repository::discover(filename).ok()?; + let default_return = "Unknown".to_string(); + let diff = get_git_diff(filename).unwrap(); + if diff.contains_key(&line) { + return Some(format!("{} <{}>", default_return, default_return)); + } + if let Some(line_blame) = blame.get_line(line as usize) { + let signature = line_blame.final_signature(); + let name = signature.name().unwrap_or(default_return.as_str()); + let email = signature.email().unwrap_or(default_return.as_str()); + if blame_format.is_empty() { + return Some(format!("{} <{}>", name, email)); + } + let commit_id = line_blame.final_commit_id(); + let commit = repo.find_commit(commit_id).ok()?; + + return Some(format_blame(&line_blame, &commit, blame_format)); + } + Some(default_return) +} + + +pub fn format_blame(blame_hunk: &BlameHunk, commit: &Commit, blame_format: &str) -> String { + let mut result = String::from(blame_format); + let abbreviated_id_buf = commit.as_object().short_id(); + let abbreviated_id = abbreviated_id_buf.as_ref().ok().map(|id| id.as_str()).unwrap_or(Some("")); + let signature = blame_hunk.final_signature(); + result = result.replace("%an", signature.name().unwrap_or("Unknown")); + result = result.replace("%ae", signature.email().unwrap_or("Unknown")); + result = result.replace("%H", commit.id().to_string().as_str()); + result = result.replace("%h", abbreviated_id.unwrap()); + result = result.replace("%s", commit.summary().unwrap_or("Unknown")); + result = result.replace("%cn", commit.author().name().unwrap_or("Unknown")); + result = result.replace("%ce", commit.author().email().unwrap_or("Unknown")); + result = result.replace("%b", commit.message().unwrap_or("Unknown")); + result = result.replace("%N", commit.parents().len().to_string().as_str()); + + result +} + +pub fn get_blame_file(filename: &Path, blame_format: &str) -> Option { + let lines_in_file = fs::read_to_string(filename).ok()?.lines().count(); + let mut result = LineBlames::new(); + let mut blame_options = BlameOptions::new(); + let repo = Repository::discover(filename).ok()?; + let repo_path_absolute = fs::canonicalize(repo.workdir()?).ok()?; + let filepath_absolute = fs::canonicalize(filename).ok()?; + let filepath_relative_to_repo = filepath_absolute.strip_prefix(&repo_path_absolute).ok()?; + + let blame = repo.blame_file( + filepath_relative_to_repo, + Some(&mut blame_options), + ).ok()?; + for i in 0..lines_in_file { + if let Some(str_result) = get_blame_line(&blame, filename, i as u32, blame_format) { + result.insert(i as u32, str_result); + } + } + Some(result) +} \ No newline at end of file diff --git a/src/printer.rs b/src/printer.rs index 257cc766..86ff6886 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -23,10 +23,11 @@ use unicode_width::UnicodeWidthChar; use crate::assets::{HighlightingAssets, SyntaxReferenceInSet}; use crate::config::Config; #[cfg(feature = "git")] -use crate::decorations::LineChangesDecoration; +use crate::decorations::{LineChangesDecoration, LineBlamesDecoration}; +#[cfg(feature = "git")] use crate::decorations::{Decoration, GridBorderDecoration, LineNumberDecoration}; #[cfg(feature = "git")] -use crate::diff::LineChanges; +use crate::diff::{LineChanges, LineBlames}; use crate::error::*; use crate::input::OpenedInput; use crate::line_range::RangeCheckResult; @@ -156,6 +157,8 @@ pub(crate) struct InteractivePrinter<'a> { content_type: Option, #[cfg(feature = "git")] pub line_changes: &'a Option, + #[cfg(feature = "git")] + pub line_blames: &'a Option, highlighter_from_set: Option>, background_color_highlight: Option, } @@ -166,6 +169,7 @@ impl<'a> InteractivePrinter<'a> { assets: &'a HighlightingAssets, input: &mut OpenedInput, #[cfg(feature = "git")] line_changes: &'a Option, + #[cfg(feature = "git")] line_blames: &'a Option, ) -> Result { let theme = assets.get_theme(&config.theme); @@ -180,6 +184,17 @@ impl<'a> InteractivePrinter<'a> { // Create decorations. let mut decorations: Vec> = Vec::new(); + #[cfg(feature = "git")] + { + if config.style_components.blame() { + let longest_key = line_blames + .as_ref() + .map(|blames| blames.values().map(|s| s.len()).max().unwrap_or(0)) + .unwrap_or(0); + decorations.push(Box::new(LineBlamesDecoration::new(&colors, longest_key))); + } + } + if config.style_components.numbers() { decorations.push(Box::new(LineNumberDecoration::new(&colors))); } @@ -239,6 +254,8 @@ impl<'a> InteractivePrinter<'a> { ansi_style: AnsiStyle::new(), #[cfg(feature = "git")] line_changes, + #[cfg(feature = "git")] + line_blames, highlighter_from_set, background_color_highlight, }) @@ -762,6 +779,7 @@ pub struct Colors { pub git_added: Style, pub git_removed: Style, pub git_modified: Style, + pub git_blame: Style, pub line_number: Style, } @@ -791,6 +809,7 @@ impl Colors { git_added: Green.normal(), git_removed: Red.normal(), git_modified: Yellow.normal(), + git_blame: Red.normal(), line_number: gutter_style, } } diff --git a/src/style.rs b/src/style.rs index 0f83d1bc..7a68aa4e 100644 --- a/src/style.rs +++ b/src/style.rs @@ -9,6 +9,8 @@ pub enum StyleComponent { Auto, #[cfg(feature = "git")] Changes, + #[cfg(feature = "git")] + Blame, Grid, Rule, Header, @@ -33,6 +35,8 @@ impl StyleComponent { } #[cfg(feature = "git")] StyleComponent::Changes => &[StyleComponent::Changes], + #[cfg(feature = "git")] + StyleComponent::Blame => &[StyleComponent::Blame], StyleComponent::Grid => &[StyleComponent::Grid], StyleComponent::Rule => &[StyleComponent::Rule], StyleComponent::Header => &[StyleComponent::HeaderFilename], @@ -70,6 +74,8 @@ impl FromStr for StyleComponent { "auto" => Ok(StyleComponent::Auto), #[cfg(feature = "git")] "changes" => Ok(StyleComponent::Changes), + #[cfg(feature = "git")] + "blame" => Ok(StyleComponent::Blame), "grid" => Ok(StyleComponent::Grid), "rule" => Ok(StyleComponent::Rule), "header" => Ok(StyleComponent::Header), @@ -98,6 +104,11 @@ impl StyleComponents { self.0.contains(&StyleComponent::Changes) } + #[cfg(feature = "git")] + pub fn blame(&self) -> bool { + self.0.contains(&StyleComponent::Blame) + } + pub fn grid(&self) -> bool { self.0.contains(&StyleComponent::Grid) }