diff --git a/Cargo.lock b/Cargo.lock
index 65955aeba..0c081ac2b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -145,8 +145,20 @@ version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
+ "libc",
"num-integer",
"num-traits",
+ "time",
+ "winapi",
+]
+
+[[package]]
+name = "chrono-humanize"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eddc119501d583fd930cb92144e605f44e0252c38dd89d9247fffa1993375cb"
+dependencies = [
+ "chrono",
]
[[package]]
@@ -332,6 +344,8 @@ dependencies = [
"bitflags",
"box_drawing",
"bytelines",
+ "chrono",
+ "chrono-humanize",
"console",
"ctrlc",
"dirs-next",
@@ -933,6 +947,17 @@ dependencies = [
"unicode-width",
]
+[[package]]
+name = "time"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
+dependencies = [
+ "libc",
+ "wasi 0.10.0+wasi-snapshot-preview1",
+ "winapi",
+]
+
[[package]]
name = "tinyvec"
version = "1.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 1f0d7c7de..e7181ddcb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,8 @@ name = "delta"
path = "src/main.rs"
[dependencies]
+chrono = "0.4.19"
+chrono-humanize = "0.2.1"
ansi_colours = "1.0.4"
ansi_term = "0.12.1"
atty = "0.2.14"
diff --git a/README.md b/README.md
index e2f8f8a91..ff16c1854 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@
diff = delta
show = delta
log = delta
+ blame = delta
reflog = delta
[interactive]
@@ -63,11 +64,13 @@ Code evolves, and we all spend time studying diffs. Delta aims to make this both
- [Choosing colors (styles)](#choosing-colors-styles)
- [Line numbers](#line-numbers)
- [Side-by-side view](#side-by-side-view)
+ - [git blame](#git-blame)
- ["Features": named groups of settings](#features-named-groups-of-settings)
- [Custom themes](#custom-themes)
- [diff-highlight and diff-so-fancy emulation](#diff-highlight-and-diff-so-fancy-emulation)
- [--color-moved support](#--color-moved-support)
- [Navigation keybindings for large diffs](#navigation-keybindings-for-large-diffs)
+ - [Git blame](#git-blame-1)
- [24 bit color (truecolor)](#24-bit-color-truecolor)
- [Using Delta with GNU Screen](#using-delta-with-gnu-screen)
- [Using Delta on Windows](#using-delta-on-windows)
@@ -152,6 +155,7 @@ Here's what `git show` can look like with git configured to use delta:
- `diff-highlight` and `diff-so-fancy` emulation modes
- Stylable box/line decorations to draw attention to commit, file and hunk header sections.
- Support for Git's `--color-moved` feature.
+- Customizable `git blame` with syntax highlighting (`--hyperlinks` formats commits as links to GitHub/GitLab/Bitbucket etc)
- Code can be copied directly from the diff (`-/+` markers are removed by default).
- `n` and `N` keybindings to move between files in large diffs, and between diffs in `log -p` views (`--navigate`)
- Commit hashes can be formatted as terminal [hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) to the GitHub/GitLab/Bitbucket page (`--hyperlinks`).
@@ -407,6 +411,10 @@ In contrast, the long replacement line in the right panel overflows by almost an
For control over the details of line wrapping, see `--wrap-max-lines`, `--wrap-left-symbol`, `--wrap-right-symbol`, `--wrap-right-percent`, `--wrap-right-prefix-symbol`, `--inline-hint-style`.
Line wrapping was implemented by @th1000s.
+### git blame
+
+Set delta as the pager for `blame` in the `[pager]` section of your gitconfig. See the example at the [top of the page](#get-started).
+
### "Features": named groups of settings
All delta options can go under the `[delta]` section in your git config file. However, you can also use named "features" to keep things organized: these are sections in git config like `[delta "my-feature"]`. Here's an example using two custom features:
@@ -493,6 +501,12 @@ In order to support this feature, Delta has to look at the raw colors it receive
Use the `navigate` feature to activate navigation keybindings. In this mode, pressing `n` will jump forward to the next file in the diff, and `N` will jump backwards. If you are viewing multiple commits (e.g. via `git log -p`) then navigation will also visit commit boundaries.
+### Git blame
+
+Delta will render `git blame` output in its own way, if you have `pager.blame = delta` set in your `gitconfig`. Example:
+
+
|
+
### 24 bit color (truecolor)
Delta looks best if your terminal application supports 24 bit colors. See https://github.com/termstandard/colors#readme. For example, on MacOS, iTerm2 supports 24-bit colors but Terminal.app does not.
diff --git a/src/cli.rs b/src/cli.rs
index 9c35f68b0..9ae6130fd 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -420,6 +420,27 @@ pub struct Opt {
/// (underline), 'ol' (overline), or the combination 'ul ol'.
pub hunk_header_decoration_style: String,
+ /// Format string for git blame commit metadata. Available placeholders are
+ /// "{timestamp}", "{author}", and "{commit}".
+ #[structopt(
+ long = "blame-format",
+ default_value = "{timestamp:<15} {author:<15.14} {commit:<8} │ "
+ )]
+ pub blame_format: String,
+
+ /// Background colors used for git blame lines (space-separated string).
+ /// Lines added by the same commit are painted with the same color; colors
+ /// are recycled as needed.
+ #[structopt(long = "blame-palette")]
+ pub blame_palette: Option,
+
+ /// Format of `git blame` timestamp in raw git output received by delta.
+ #[structopt(
+ long = "blame-timestamp-format",
+ default_value = "%Y-%m-%d %H:%M:%S %z"
+ )]
+ pub blame_timestamp_format: String,
+
/// Default language used for syntax highlighting when this cannot be
/// inferred from a filename. It will typically make sense to set this in
/// per-repository git config (.git/config)
diff --git a/src/color.rs b/src/color.rs
index dfa86fa7c..8140f9c6b 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -162,3 +162,9 @@ const DARK_THEME_PLUS_COLOR_256: Color = Color::Fixed(22);
const DARK_THEME_PLUS_EMPH_COLOR: Color = Color::RGB(0x00, 0x60, 0x00);
const DARK_THEME_PLUS_EMPH_COLOR_256: Color = Color::Fixed(28);
+
+// blame
+
+pub const LIGHT_THEME_BLAME_PALETTE: &[&str] = &["#FFFFFF", "#DDDDDD", "#BBBBBB"];
+
+pub const DARK_THEME_BLAME_PALETTE: &[&str] = &["#000000", "#222222", "#444444"];
diff --git a/src/config.rs b/src/config.rs
index 770a9eb61..3cba6ba3a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -57,6 +57,9 @@ fn adapt_wrap_max_lines_argument(arg: String) -> usize {
pub struct Config {
pub available_terminal_width: usize,
pub background_color_extends_to_terminal_width: bool,
+ pub blame_format: String,
+ pub blame_palette: Vec,
+ pub blame_timestamp_format: String,
pub color_only: bool,
pub commit_regex: Regex,
pub commit_style: Style,
@@ -213,6 +216,8 @@ impl From for Config {
_ => *style::GIT_DEFAULT_PLUS_STYLE,
};
+ let blame_palette = make_blame_palette(opt.blame_palette, opt.computed.is_light_mode);
+
let file_added_label = opt.file_added_label;
let file_copied_label = opt.file_copied_label;
let file_modified_label = opt.file_modified_label;
@@ -257,6 +262,9 @@ impl From for Config {
background_color_extends_to_terminal_width: opt
.computed
.background_color_extends_to_terminal_width,
+ blame_format: opt.blame_format,
+ blame_palette,
+ blame_timestamp_format: opt.blame_timestamp_format,
commit_style,
color_only: opt.color_only,
commit_regex,
@@ -603,6 +611,23 @@ fn make_commit_file_hunk_header_styles(opt: &cli::Opt) -> (Style, Style, Style,
)
}
+fn make_blame_palette(blame_palette: Option, is_light_mode: bool) -> Vec {
+ match (blame_palette, is_light_mode) {
+ (Some(string), _) => string
+ .split_whitespace()
+ .map(|s| s.to_owned())
+ .collect::>(),
+ (None, true) => color::LIGHT_THEME_BLAME_PALETTE
+ .iter()
+ .map(|s| s.to_string())
+ .collect::>(),
+ (None, false) => color::DARK_THEME_BLAME_PALETTE
+ .iter()
+ .map(|s| s.to_string())
+ .collect::>(),
+ }
+}
+
/// Did the user supply `option` on the command line?
pub fn user_supplied_option(option: &str, arg_matches: &clap::ArgMatches) -> bool {
arg_matches.occurrences_of(option) > 0
diff --git a/src/delta.rs b/src/delta.rs
index 71b96bffb..fbf45c53d 100644
--- a/src/delta.rs
+++ b/src/delta.rs
@@ -1,4 +1,5 @@
use std::borrow::Cow;
+use std::collections::HashMap;
use std::io::BufRead;
use std::io::Write;
@@ -13,7 +14,7 @@ use crate::style::DecorationStyle;
#[derive(Clone, Debug, PartialEq)]
pub enum State {
- CommitMeta, // In commit metadata section
+ CommitMeta, // In commit metadata section
FileMeta, // In diff metadata section, between (possible) commit metadata and first hunk
HunkHeader(String, String), // In hunk metadata line (line, raw_line)
HunkZero, // In hunk; unchanged line
@@ -21,6 +22,7 @@ pub enum State {
HunkPlus(Option), // In hunk; added line (raw_line)
SubmoduleLog, // In a submodule section, with gitconfig diff.submodule = log
SubmoduleShort(String), // In a submodule section, with gitconfig diff.submodule = short
+ Blame(String, Option), // In a line of `git blame` output (commit, repeat_blame_line).
Unknown,
// The following elements are created when a line is wrapped to display it:
HunkZeroWrapped, // Wrapped unchanged line
@@ -67,6 +69,7 @@ pub struct StateMachine<'a> {
// avoid emitting the file meta header line twice (#245).
pub current_file_pair: Option<(String, String)>,
pub handled_file_meta_header_line_file_pair: Option<(String, String)>,
+ pub blame_commit_colors: HashMap,
}
pub fn delta(lines: ByteLines, writer: &mut dyn Write, config: &Config) -> std::io::Result<()>
@@ -92,6 +95,7 @@ impl<'a> StateMachine<'a> {
handled_file_meta_header_line_file_pair: None,
painter: Painter::new(writer, config),
config,
+ blame_commit_colors: HashMap::new(),
}
}
@@ -116,6 +120,7 @@ impl<'a> StateMachine<'a> {
|| self.handle_submodule_log_line()?
|| self.handle_submodule_short_line()?
|| self.handle_hunk_line()?
+ || self.handle_blame_line()?
|| self.should_skip_line()
|| self.emit_line_unchanged()?;
}
diff --git a/src/features/line_numbers.rs b/src/features/line_numbers.rs
index 0cc4ebfb8..7082d05db 100644
--- a/src/features/line_numbers.rs
+++ b/src/features/line_numbers.rs
@@ -266,14 +266,22 @@ fn format_and_paint_line_number_field<'a>(
.as_ref()
.unwrap_or(&Align::Center);
match placeholder.placeholder {
- Some(Placeholder::NumberMinus) => ansi_strings.push(styles[Minus].paint(
- format_line_number(line_numbers[Minus], alignment_spec, width, None, config),
- )),
+ Some(Placeholder::NumberMinus) => {
+ ansi_strings.push(styles[Minus].paint(format_line_number(
+ line_numbers[Minus],
+ alignment_spec,
+ width,
+ placeholder.precision,
+ None,
+ config,
+ )))
+ }
Some(Placeholder::NumberPlus) => {
ansi_strings.push(styles[Plus].paint(format_line_number(
line_numbers[Plus],
alignment_spec,
width,
+ placeholder.precision,
Some(plus_file),
config,
)))
@@ -292,10 +300,11 @@ fn format_line_number(
line_number: Option,
alignment: &Align,
width: usize,
+ precision: Option,
plus_file: Option<&str>,
config: &config::Config,
) -> String {
- let pad = |n| format::pad(n, width, alignment);
+ let pad = |n| format::pad(n, width, alignment, precision);
match (line_number, config.hyperlinks, plus_file) {
(None, _, _) => pad(""),
(Some(n), true, Some(file)) => {
@@ -332,6 +341,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberMinus),
alignment_spec: None,
width: None,
+ precision: None,
suffix: "".into(),
prefix_len: 0,
suffix_len: 0,
@@ -348,6 +358,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberPlus),
alignment_spec: None,
width: Some(4),
+ precision: None,
suffix: "".into(),
prefix_len: 0,
suffix_len: 0,
@@ -364,6 +375,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberPlus),
alignment_spec: Some(Align::Right),
width: Some(4),
+ precision: None,
suffix: "".into(),
prefix_len: 0,
suffix_len: 0,
@@ -380,6 +392,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberPlus),
alignment_spec: Some(Align::Right),
width: Some(4),
+ precision: None,
suffix: "".into(),
prefix_len: 0,
suffix_len: 0,
@@ -396,6 +409,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberPlus),
alignment_spec: Some(Align::Right),
width: Some(4),
+ precision: None,
suffix: "@@".into(),
prefix_len: 2,
suffix_len: 2,
@@ -413,6 +427,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberMinus),
alignment_spec: Some(Align::Left),
width: Some(3),
+ precision: None,
suffix: "@@---{np:_>4}**".into(),
prefix_len: 2,
suffix_len: 15,
@@ -422,6 +437,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberPlus),
alignment_spec: Some(Align::Right),
width: Some(4),
+ precision: None,
suffix: "**".into(),
prefix_len: 5,
suffix_len: 2,
@@ -439,6 +455,7 @@ pub mod tests {
placeholder: None,
alignment_spec: None,
width: None,
+ precision: None,
suffix: "__@@---**".into(),
prefix_len: 0,
suffix_len: 9,
@@ -455,12 +472,14 @@ pub mod tests {
placeholder: Some(Placeholder::NumberMinus),
alignment_spec: Some(Align::Left),
width: Some(4),
+ precision: None,
suffix: "|".into(),
prefix_len: 2,
suffix_len: 1,
}]
);
}
+
#[test]
fn test_line_number_format_odd_width_two() {
assert_eq!(
@@ -475,6 +494,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberMinus),
alignment_spec: Some(Align::Left),
width: Some(4),
+ precision: None,
suffix: "+{np:<4}|".into(),
prefix_len: 2,
suffix_len: 9,
@@ -484,6 +504,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberPlus),
alignment_spec: Some(Align::Left),
width: Some(4),
+ precision: None,
suffix: "|".into(),
prefix_len: 1,
suffix_len: 1,
@@ -500,6 +521,7 @@ pub mod tests {
placeholder: None,
alignment_spec: None,
width: None,
+ precision: None,
suffix: "|++|".into(),
prefix_len: 1,
suffix_len: 4,
@@ -522,6 +544,7 @@ pub mod tests {
placeholder: Some(Placeholder::NumberMinus),
alignment_spec: None,
width: None,
+ precision: None,
suffix: long.into(),
suffix_len: long.len(),
},]
diff --git a/src/format.rs b/src/format.rs
index bbc6e9f5a..ab0e856fc 100644
--- a/src/format.rs
+++ b/src/format.rs
@@ -55,6 +55,7 @@ pub struct FormatStringPlaceholderData<'a> {
pub placeholder: Option>,
pub alignment_spec: Option,
pub width: Option,
+ pub precision: Option,
pub suffix: SmolStr,
pub suffix_len: usize,
}
@@ -90,6 +91,9 @@ pub fn make_placeholder_regex(labels: &[&str]) -> Regex {
([<^>]) # 3: Alignment spec
)? #
(\d+) # 4: Width
+ (?: # Start optional precision (non-capturing)
+ \.(\d+) # 5: Precision
+ )? #
)? #
\}}
",
@@ -134,6 +138,11 @@ pub fn parse_line_number_format<'a>(
.parse()
.unwrap_or_else(|_| panic!("Invalid width in format string: {}", format_string))
}),
+ precision: captures.get(5).map(|m| {
+ m.as_str().parse().unwrap_or_else(|_| {
+ panic!("Invalid precision in format string: {}", format_string)
+ })
+ }),
suffix,
suffix_len,
});
@@ -156,10 +165,42 @@ pub fn parse_line_number_format<'a>(
format_data
}
-pub fn pad(s: &str, width: usize, alignment: &Align) -> String {
- match alignment {
- Align::Left => format!("{0:<1$}", s, width),
- Align::Center => format!("{0:^1$}", s, width),
- Align::Right => format!("{0:>1$}", s, width),
+// Note that in this case of a string `s`, `precision` means "max width".
+// See https://doc.rust-lang.org/std/fmt/index.html
+pub fn pad(s: &str, width: usize, alignment: &Align, precision: Option) -> String {
+ match precision {
+ None => match alignment {
+ Align::Left => format!("{0:<1$}", s, width),
+ Align::Center => format!("{0:^1$}", s, width),
+ Align::Right => format!("{0:>1$}", s, width),
+ },
+ Some(precision) => match alignment {
+ Align::Left => format!("{0:<1$.2$}", s, width, precision),
+ Align::Center => format!("{0:^1$.2$}", s, width, precision),
+ Align::Right => format!("{0:>1$.2$}", s, width, precision),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_placeholder_regex() {
+ let regex = make_placeholder_regex(&["placeholder"]);
+ assert_eq!(
+ parse_line_number_format("prefix {placeholder:<15.14} suffix", ®ex, false),
+ vec![FormatStringPlaceholderData {
+ prefix: "prefix ".into(),
+ placeholder: Some(Placeholder::Str("placeholder")),
+ alignment_spec: Some(Align::Left),
+ width: Some(15),
+ precision: Some(14),
+ suffix: " suffix".into(),
+ prefix_len: 7,
+ suffix_len: 7,
+ }]
+ );
}
}
diff --git a/src/handlers/blame.rs b/src/handlers/blame.rs
new file mode 100644
index 000000000..fad87b4d4
--- /dev/null
+++ b/src/handlers/blame.rs
@@ -0,0 +1,365 @@
+use chrono::{DateTime, FixedOffset};
+use lazy_static::lazy_static;
+use regex::Regex;
+use std::borrow::Cow;
+
+use crate::ansi::measure_text_width;
+use crate::color;
+use crate::config;
+use crate::config::delta_unreachable;
+use crate::delta::{self, State, StateMachine};
+use crate::format::{self, Placeholder};
+use crate::paint::BgShouldFill;
+use crate::style::Style;
+use crate::utils;
+
+impl<'a> StateMachine<'a> {
+ /// If this is a line of git blame output then render it accordingly. If
+ /// this is the first blame line, then set the syntax-highlighter language
+ /// according to delta.default-language.
+ pub fn handle_blame_line(&mut self) -> std::io::Result {
+ // TODO: It should be possible to eliminate some of the .clone()s and
+ // .to_owned()s.
+ let mut handled_line = false;
+ self.painter.emit()?;
+ let (previous_commit, mut repeat_blame_line, try_parse) = match &self.state {
+ State::Blame(commit, repeat_blame_line) => {
+ (Some(commit.as_str()), repeat_blame_line.clone(), true)
+ }
+ State::Unknown => (None, None, true),
+ _ => (None, None, false),
+ };
+ if try_parse {
+ if let Some(blame) =
+ parse_git_blame_line(&self.line, &self.config.blame_timestamp_format)
+ {
+ let is_repeat = previous_commit == Some(blame.commit);
+ let color = self.get_color(blame.commit, previous_commit, is_repeat);
+ let mut style = Style::from_colors(None, color::parse_color(&color, true));
+ // TODO: This will often be pointlessly updating a key with the
+ // value it already has. It might be nicer to do this (and
+ // compute the style) in get_color(), but as things stand the
+ // borrow checker won't permit that.
+ self.blame_commit_colors
+ .insert(blame.commit.to_owned(), color);
+
+ style.is_syntax_highlighted = true;
+
+ // Construct commit metadata, paint, and emit
+ let format_data = format::parse_line_number_format(
+ &self.config.blame_format,
+ &*BLAME_PLACEHOLDER_REGEX,
+ false,
+ );
+ let blame_line = match (is_repeat, &repeat_blame_line) {
+ (false, _) => Cow::from(format_blame_metadata(
+ &format_data,
+ &blame,
+ false,
+ self.config,
+ )),
+ (true, None) => {
+ repeat_blame_line = Some(format_blame_metadata(
+ &format_data,
+ &blame,
+ true,
+ self.config,
+ ));
+ Cow::from(repeat_blame_line.as_ref().unwrap())
+ }
+ (true, Some(repeat_blame_line)) => Cow::from(repeat_blame_line),
+ };
+ write!(self.painter.writer, "{}", style.paint(blame_line))?;
+
+ // Emit syntax-highlighted code
+ if matches!(self.state, State::Unknown) {
+ if let Some(lang) = utils::git_blame_filename_extension()
+ .as_ref()
+ .or_else(|| self.config.default_language.as_ref())
+ {
+ self.painter.set_syntax(Some(lang));
+ self.painter.set_highlighter();
+ }
+ }
+ self.state = State::Blame(blame.commit.to_owned(), repeat_blame_line.to_owned());
+ self.painter.syntax_highlight_and_paint_line(
+ &format!("{}\n", blame.code),
+ style,
+ self.state.clone(),
+ BgShouldFill::default(),
+ );
+ handled_line = true
+ }
+ }
+ Ok(handled_line)
+ }
+
+ fn get_color(
+ &self,
+ this_commit: &str,
+ previous_commit: Option<&str>,
+ is_repeat: bool,
+ ) -> String {
+ // Determine color for this line
+ let previous_commit_color = match previous_commit {
+ Some(previous_commit) => self.blame_commit_colors.get(previous_commit),
+ None => None,
+ };
+
+ match (
+ self.blame_commit_colors.get(this_commit),
+ previous_commit_color,
+ is_repeat,
+ ) {
+ (Some(commit_color), Some(previous_commit_color), true) => {
+ debug_assert!(commit_color == previous_commit_color);
+ // Repeated commit: assign same color
+ commit_color.to_owned()
+ }
+ (None, Some(previous_commit_color), false) => {
+ // The commit has no color: assign the next color that differs
+ // from previous commit.
+ self.get_next_color(Some(previous_commit_color))
+ }
+ (None, None, false) => {
+ // The commit has no color, and there is no previous commit:
+ // Just assign the next color. is_repeat is necessarily false.
+ self.get_next_color(None)
+ }
+ (Some(commit_color), Some(previous_commit_color), false) => {
+ if commit_color != previous_commit_color {
+ // Consecutive commits differ without a collision
+ commit_color.to_owned()
+ } else {
+ // Consecutive commits differ; prevent color collision
+ self.get_next_color(Some(commit_color))
+ }
+ }
+ (None, _, true) => {
+ delta_unreachable("is_repeat cannot be true when commit has no color.")
+ }
+ (Some(_), None, _) => {
+ delta_unreachable("There must be a previous commit if the commit has a color.")
+ }
+ }
+ }
+
+ fn get_next_color(&self, other_than_color: Option<&str>) -> String {
+ let n_commits = self.blame_commit_colors.len();
+ let n_colors = self.config.blame_palette.len();
+ let color = self.config.blame_palette[n_commits % n_colors].clone();
+ if Some(color.as_str()) != other_than_color {
+ color
+ } else {
+ self.config.blame_palette[(n_commits + 1) % n_colors].clone()
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct BlameLine<'a> {
+ pub commit: &'a str,
+ pub author: &'a str,
+ pub time: DateTime,
+ pub line_number: usize,
+ pub code: &'a str,
+}
+
+// E.g.
+//ea82f2d0 (Dan Davison 2021-08-22 18:20:19 -0700 120) let mut handled_line = self.handle_commit_meta_header_line()?
+
+lazy_static! {
+ static ref BLAME_LINE_REGEX: Regex = Regex::new(
+ r"(?x)
+^
+(
+ \^?[0-9a-f]{4,40} # commit hash (^ is 'boundary commit' marker)
+)
+(?: .+)? # optional file name (unused; present if file has been renamed; TODO: inefficient?)
+[\ ]
+\( # open (
+(
+ [^\ ].*[^\ ] # author name
+)
+[\ ]+
+( # timestamp
+ [0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}\ [-+][0-9]{4}
+)
+[\ ]+
+(
+ [0-9]+ # line number
+)
+\) # close )
+(
+ .* # code, with leading space
+)
+$
+"
+ )
+ .unwrap();
+}
+
+pub fn parse_git_blame_line<'a>(line: &'a str, timestamp_format: &str) -> Option> {
+ let caps = BLAME_LINE_REGEX.captures(line)?;
+
+ let commit = caps.get(1).unwrap().as_str();
+ let author = caps.get(2).unwrap().as_str();
+ let timestamp = caps.get(3).unwrap().as_str();
+
+ let time = DateTime::parse_from_str(timestamp, timestamp_format).ok()?;
+
+ let line_number = caps.get(4).unwrap().as_str().parse::().ok()?;
+
+ let code = caps.get(5).unwrap().as_str();
+
+ Some(BlameLine {
+ commit,
+ author,
+ time,
+ line_number,
+ code,
+ })
+}
+
+lazy_static! {
+ pub static ref BLAME_PLACEHOLDER_REGEX: Regex =
+ format::make_placeholder_regex(&["timestamp", "author", "commit"]);
+}
+
+pub fn format_blame_metadata(
+ format_data: &[format::FormatStringPlaceholderData],
+ blame: &BlameLine,
+ is_repeat: bool,
+ config: &config::Config,
+) -> String {
+ let mut s = String::new();
+ let mut suffix = "";
+ for placeholder in format_data {
+ s.push_str(placeholder.prefix.as_str());
+
+ let alignment_spec = placeholder
+ .alignment_spec
+ .as_ref()
+ .unwrap_or(&format::Align::Left);
+ let width = placeholder.width.unwrap_or(15);
+
+ let pad = |s| format::pad(s, width, alignment_spec, placeholder.precision);
+ let field = match placeholder.placeholder {
+ Some(Placeholder::Str("timestamp")) => Some(Cow::from(
+ chrono_humanize::HumanTime::from(blame.time).to_string(),
+ )),
+ Some(Placeholder::Str("author")) => Some(Cow::from(blame.author)),
+ Some(Placeholder::Str("commit")) => Some(delta::format_raw_line(blame.commit, config)),
+ None => None,
+ _ => unreachable!("Unexpected `git blame` input"),
+ };
+ if let Some(field) = field {
+ let field = pad(&field);
+ if is_repeat {
+ s.push_str(&" ".repeat(measure_text_width(&field)));
+ } else {
+ s.push_str(&field)
+ }
+ }
+ suffix = placeholder.suffix.as_str();
+ }
+ s.push_str(suffix);
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use itertools::Itertools;
+ use std::{collections::HashMap, io::Cursor};
+
+ use crate::tests::integration_test_utils;
+
+ use super::*;
+
+ #[test]
+ fn test_blame_line_regex() {
+ for line in &[
+ "ea82f2d0 (Dan Davison 2021-08-22 18:20:19 -0700 120) let mut handled_line = self.handle_commit_meta_header_line()?",
+ "b2257cfa (Dan Davison 2020-07-18 15:34:43 -0400 1) use std::borrow::Cow;",
+ "^35876eaa (Nicholas Marriott 2009-06-01 22:58:49 +0000 38) /* Default grid cell data. */",
+ ] {
+ let caps = BLAME_LINE_REGEX.captures(line);
+ assert!(caps.is_some());
+ assert!(parse_git_blame_line(line, "%Y-%m-%d %H:%M:%S %z").is_some());
+ }
+ }
+
+ #[test]
+ fn test_color_assignment() {
+ let mut writer = Cursor::new(vec![0; 512]);
+ let config = integration_test_utils::make_config_from_args(&["--blame-palette", "1 2"]);
+ let mut machine = StateMachine::new(&mut writer, &config);
+
+ let blame_lines: HashMap<&str, &str> = vec![
+ (
+ "A",
+ "aaaaaaa (Dan Davison 2021-08-22 18:20:19 -0700 120) A",
+ ),
+ (
+ "B",
+ "bbbbbbb (Dan Davison 2020-07-18 15:34:43 -0400 1) B",
+ ),
+ (
+ "C",
+ "ccccccc (Dan Davison 2020-07-18 15:34:43 -0400 1) C",
+ ),
+ ]
+ .into_iter()
+ .collect();
+
+ // First commit gets first color
+ machine.line = blame_lines["A"].into();
+ machine.handle_blame_line().unwrap();
+ assert_eq!(
+ hashmap_items(&machine.blame_commit_colors),
+ &[("aaaaaaa", "1")]
+ );
+
+ // Repeat commit: same color
+ machine.line = blame_lines["A"].into();
+ machine.handle_blame_line().unwrap();
+ assert_eq!(
+ hashmap_items(&machine.blame_commit_colors),
+ &[("aaaaaaa", "1")]
+ );
+
+ // Second distinct commit gets second color
+ machine.line = blame_lines["B"].into();
+ machine.handle_blame_line().unwrap();
+ assert_eq!(
+ hashmap_items(&machine.blame_commit_colors),
+ &[("aaaaaaa", "1"), ("bbbbbbb", "2")]
+ );
+
+ // Third distinct commit gets first color (we only have 2 colors)
+ machine.line = blame_lines["C"].into();
+ machine.handle_blame_line().unwrap();
+ assert_eq!(
+ hashmap_items(&machine.blame_commit_colors),
+ &[("aaaaaaa", "1"), ("bbbbbbb", "2"), ("ccccccc", "1")]
+ );
+
+ // Now the first commit appears again. It would get the first color, but
+ // that would be a consecutive-commit-color-collision. So it gets the
+ // second color.
+ machine.line = blame_lines["A"].into();
+ machine.handle_blame_line().unwrap();
+ assert_eq!(
+ hashmap_items(&machine.blame_commit_colors),
+ &[("aaaaaaa", "2"), ("bbbbbbb", "2"), ("ccccccc", "1")]
+ );
+ }
+
+ fn hashmap_items(hashmap: &HashMap) -> Vec<(&str, &str)> {
+ hashmap
+ .iter()
+ .sorted()
+ .map(|(k, v)| (k.as_str(), v.as_str()))
+ .collect()
+ }
+}
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index 6302c7396..db27e28fc 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -1,5 +1,6 @@
/// This module contains functions handling input lines encountered during the
/// main `StateMachine::consume()` loop.
+pub mod blame;
pub mod commit_meta;
pub mod diff_stat;
pub mod draw;
diff --git a/src/options/set.rs b/src/options/set.rs
index 05946d43f..656918412 100644
--- a/src/options/set.rs
+++ b/src/options/set.rs
@@ -122,6 +122,9 @@ pub fn set_options(
set_options!(
[
+ blame_format,
+ blame_palette,
+ blame_timestamp_format,
color_only,
commit_decoration_style,
commit_regex,
diff --git a/src/paint.rs b/src/paint.rs
index 264cecdce..02854a5c8 100644
--- a/src/paint.rs
+++ b/src/paint.rs
@@ -476,6 +476,7 @@ impl<'p> Painter<'p> {
(config.plus_style, config.plus_non_emph_style)
}
}
+ State::Blame(_, _) => (diff_sections[0].0, diff_sections[0].0),
_ => (config.null_style, config.null_style),
};
let fill_style = if style_sections_contain_more_than_one_style(diff_sections) {
@@ -621,6 +622,7 @@ impl<'p> Painter<'p> {
}
State::HunkHeader(_, _) => true,
State::HunkMinus(Some(_)) | State::HunkPlus(Some(_)) => false,
+ State::Blame(_, _) => true,
_ => panic!(
"should_compute_syntax_highlighting is undefined for state {:?}",
state
diff --git a/src/parse_style.rs b/src/parse_style.rs
index 49299fa9a..820cc036d 100644
--- a/src/parse_style.rs
+++ b/src/parse_style.rs
@@ -327,8 +327,6 @@ fn _extract_special_decoration_attributes(
mod tests {
use super::*;
- use ansi_term;
-
#[test]
fn test_parse_ansi_term_style() {
assert_eq!(
diff --git a/src/style.rs b/src/style.rs
index 693583e76..8ce78875b 100644
--- a/src/style.rs
+++ b/src/style.rs
@@ -127,6 +127,22 @@ impl Style {
}
}
+/// Interpret `color_string` as a color specifier and return it painted accordingly.
+pub fn paint_color_string(
+ color_string: &str,
+ true_color: bool,
+) -> ansi_term::ANSIGenericString {
+ if let Some(color) = color::parse_color(color_string, true_color) {
+ let style = ansi_term::Style {
+ background: Some(color),
+ ..ansi_term::Style::default()
+ };
+ style.paint(color_string)
+ } else {
+ ansi_term::ANSIGenericString::from(color_string)
+ }
+}
+
impl fmt::Display for Style {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.is_raw {
diff --git a/src/subcommands/show_config.rs b/src/subcommands/show_config.rs
index e6e6f2467..a9c8db2d7 100644
--- a/src/subcommands/show_config.rs
+++ b/src/subcommands/show_config.rs
@@ -1,11 +1,14 @@
use std::io::Write;
+use itertools::Itertools;
+
use crate::bat_utils::output::PagingMode;
use crate::cli;
use crate::config;
use crate::features::side_by_side::{Left, Right};
use crate::minusplus::*;
use crate::paint::BgFillMethod;
+use crate::style;
pub fn show_config(config: &config::Config, writer: &mut dyn Write) -> std::io::Result<()> {
// styles first
@@ -23,7 +26,13 @@ pub fn show_config(config: &config::Config, writer: &mut dyn Write) -> std::io::
plus-non-emph-style = {plus_non_emph_style}
plus-emph-style = {plus_emph_style}
plus-empty-line-marker-style = {plus_empty_line_marker_style}
- whitespace-error-style = {whitespace_error_style}",
+ whitespace-error-style = {whitespace_error_style}
+ blame-palette = {blame_palette}",
+ blame_palette = config
+ .blame_palette
+ .iter()
+ .map(|s| style::paint_color_string(s, config.true_color))
+ .join(" "),
commit_style = config.commit_style.to_painted_string(),
file_style = config.file_style.to_painted_string(),
hunk_header_style = config.hunk_header_style.to_painted_string(),
diff --git a/src/utils.rs b/src/utils.rs
index 6f997d932..844f9f31c 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -295,6 +295,10 @@ mod tests {
assert!(parent.is_some());
// Tests that caller is something like "cargo test"
- assert!(parent.unwrap().cmd().iter().any(|a| a == "test"));
+ assert!(parent
+ .unwrap()
+ .cmd()
+ .iter()
+ .any(|a| a == "test" || a == "tarpaulin"));
}
}