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.
This commit is contained in:
Bark 2024-02-09 14:37:21 +02:00
parent f7bea6de5b
commit ce9b212594
10 changed files with 223 additions and 13 deletions

View File

@ -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 <N:M>
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 <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.

View File

@ -45,11 +45,13 @@ Options:
Display all supported highlighting themes.
--style <components>
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 <N:M>
Only print the lines from N to M.
-L, --list-languages
Display all supported languages.
--blame-format <format>
Specify a format for the git-blame content.
-h, --help
Print help (see more with '--help')
-V, --version

View File

@ -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::<String>("blame-format")
.map(String::from)
.unwrap_or_else(|| String::from("%h: %an <%ae>")),
})
}

View File

@ -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")

View File

@ -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"))]

View File

@ -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<dyn Printer> = 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 => {

View File

@ -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!("{: <width$}", result, width = length).as_str(),
length,
);
}
return Self::generate_cached(self.color, result, length);
}
}
}
Self::generate_cached(Style::default(), " ", self.width())
}
fn width(&self) -> usize {
self.max_length
}
}
pub(crate) struct GridBorderDecoration {
cached: DecorationText,
}

View File

@ -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<u32, LineChange>;
pub type LineBlames = HashMap<u32, String>;
pub fn get_git_diff(filename: &Path) -> Option<LineChanges> {
let repo = Repository::discover(filename).ok()?;
@ -81,3 +81,65 @@ pub fn get_git_diff(filename: &Path) -> Option<LineChanges> {
Some(line_changes)
}
pub fn get_blame_line(blame: &Blame, filename: &Path, line: u32, blame_format: &str) -> Option<String> {
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<LineBlames> {
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)
}

View File

@ -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<ContentType>,
#[cfg(feature = "git")]
pub line_changes: &'a Option<LineChanges>,
#[cfg(feature = "git")]
pub line_blames: &'a Option<LineBlames>,
highlighter_from_set: Option<HighlighterFromSet<'a>>,
background_color_highlight: Option<Color>,
}
@ -166,6 +169,7 @@ impl<'a> InteractivePrinter<'a> {
assets: &'a HighlightingAssets,
input: &mut OpenedInput,
#[cfg(feature = "git")] line_changes: &'a Option<LineChanges>,
#[cfg(feature = "git")] line_blames: &'a Option<LineBlames>,
) -> Result<Self> {
let theme = assets.get_theme(&config.theme);
@ -180,6 +184,17 @@ impl<'a> InteractivePrinter<'a> {
// Create decorations.
let mut decorations: Vec<Box<dyn Decoration>> = 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,
}
}

View File

@ -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)
}