Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Git Blame style option. #2851

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/long-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion doc/short-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/bin/bat/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>")),
})
}

Expand Down
24 changes: 22 additions & 2 deletions src/bin/bat/clap_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ pub fn build_app(interactive_output: bool) -> Command {
"snip",
#[cfg(feature = "git")]
"changes",
#[cfg(feature = "git")]
"blame",
].contains(style)
});

Expand All @@ -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 \
Expand All @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down
33 changes: 27 additions & 6 deletions src/controller.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
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_blame_file, get_git_diff, LineChanges};
use crate::error::*;
use crate::input::{Input, InputReader, OpenedInput};
#[cfg(feature = "lessopen")]
Expand All @@ -15,6 +13,7 @@ use crate::output::OutputType;
#[cfg(feature = "paging")]
use crate::paging::PagingMode;
use crate::printer::{InteractivePrinter, OutputHandle, Printer, SimplePrinter};
use std::io::{self, BufRead, Write};

use clircle::{Clircle, Identifier};

Expand Down Expand Up @@ -174,6 +173,29 @@ 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 {
Expand All @@ -183,6 +205,8 @@ impl<'b> Controller<'b> {
&mut opened_input,
#[cfg(feature = "git")]
&line_changes,
#[cfg(feature = "git")]
&line_blames,
)?)
};

Expand Down Expand Up @@ -222,11 +246,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)?;
Expand Down Expand Up @@ -268,7 +290,6 @@ impl<'b> Controller<'b> {
printer.print_snip(writer)?;
}
}

printer.print_line(false, writer, line_number, &line_buffer)?;
}
RangeCheckResult::AfterLastRange => {
Expand Down
57 changes: 57 additions & 0 deletions src/decorations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
73 changes: 71 additions & 2 deletions src/diff.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
#![cfg(feature = "git")]

use git2::{Blame, BlameHunk, BlameOptions, Commit, DiffOptions, IntoCString, Repository};
use std::collections::HashMap;
use std::fs;
use std::path::Path;

use git2::{DiffOptions, IntoCString, Repository};

#[derive(Copy, Clone, Debug)]
pub enum LineChange {
Added,
Expand All @@ -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()?;
Expand Down Expand Up @@ -81,3 +81,72 @@ 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)
}