use super::{Items, Screen, streamed_items};
use crate::style::{Modifier, Style};
use crate::{
Res,
calling_process::{GitCommand, Stat},
config::{Config, FileHeader},
error::Error,
git::diff::{Diff, DiffType},
gitu_diff,
item_data::ItemData,
items::{self, Item, RenderParams},
stream::{Input, Snapshot, Stream, Streams},
};
use git2::{Oid, Repository};
use regex::Regex;
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub struct Paged {
pub text: String,
pub git_argv: Option<Vec<String>>,
pub prefix: String,
pub truncated: bool,
}
pub(crate) fn create(
config: Arc<Config>,
repo: Rc<Repository>,
params: RenderParams,
paged: Paged,
) -> Res<Screen> {
let source = Source::of(paged, &config.general.hide);
let git_command = source.git_command();
let mut screen = Screen::streamed(
Arc::clone(&config),
params,
Box::new(move |params: RenderParams| {
if params.streaming {
source.first_items(&config, &repo, ¶ms)
} else {
source.items(&config, &repo, ¶ms).map(Items::from)
}
}),
)?;
screen.git_command = git_command;
if !matches!(screen.get_selected_item().data, ItemData::Hunk { .. }) {
screen.select_matching(|data| matches!(data, ItemData::Delta { .. }));
}
Ok(screen)
}
pub(crate) fn show(
config: Arc<Config>,
repo: Rc<Repository>,
params: RenderParams,
reference: &str,
) -> Res<Screen> {
let git_argv = ["git", "show"]
.into_iter()
.chain(config.general.show_args.iter().map(String::as_str))
.chain([reference])
.map(String::from)
.collect();
live(config, repo, params, git_argv)
}
pub(crate) fn log(
config: Arc<Config>,
repo: Rc<Repository>,
params: RenderParams,
limit: usize,
rev: Option<Oid>,
msg_regex: Option<&Regex>,
) -> Res<Screen> {
let mut git_argv: Vec<String> = ["git", "log"]
.into_iter()
.map(String::from)
.chain(config.general.log_args.iter().cloned())
.collect();
if let Ok(limit) = i32::try_from(limit) {
git_argv.push(format!("-n{limit}"));
}
if let Some(regex) = msg_regex {
git_argv.push("--extended-regexp".into());
git_argv.push(format!("--grep={regex}"));
}
git_argv.extend(rev.map(|oid| oid.to_string()));
live(config, repo, params, git_argv)
}
pub(crate) fn live(
config: Arc<Config>,
repo: Rc<Repository>,
params: RenderParams,
git_argv: Vec<String>,
) -> Res<Screen> {
live_in(config, repo, params, git_argv, String::new())
}
pub(crate) fn live_in(
config: Arc<Config>,
repo: Rc<Repository>,
params: RenderParams,
git_argv: Vec<String>,
prefix: String,
) -> Res<Screen> {
create(
config,
repo,
params,
Paged {
text: String::new(),
git_argv: Some(git_argv),
prefix,
truncated: false,
},
)
}
enum Source {
Live { git: Rc<RefCell<GitCommand>> },
Patch { text: String, truncated: bool },
Unstructured { text: String, truncated: bool },
}
impl Source {
fn of(paged: Paged, hide: &[String]) -> Self {
if let Some(git) = paged.git_argv.and_then(GitCommand::of) {
let git = git.run_in(paged.prefix);
let mut patterns = git.file_patterns();
patterns.extend(hide.iter().map(|glob| format!("!{glob}")));
return Source::Live {
git: Rc::new(RefCell::new(git.asking_for(&patterns))),
};
}
let file_diffs = gitu_diff::Parser::new(&crate::diff_renderer::strip_ansi(&paged.text))
.parse_diff()
.unwrap_or_default();
if file_diffs.is_empty() {
return Source::Unstructured {
text: paged.text,
truncated: paged.truncated,
};
}
Source::Patch {
text: paged.text,
truncated: paged.truncated,
}
}
fn git_command(&self) -> Option<Rc<RefCell<GitCommand>>> {
match self {
Source::Live { git, .. } => Some(Rc::clone(git)),
Source::Patch { .. } | Source::Unstructured { .. } => None,
}
}
fn items(&self, config: &Config, repo: &Repository, params: &RenderParams) -> Res<Vec<Item>> {
let (stream, shape) = self.start(config, repo, params)?;
stream.wait_for(usize::MAX);
built(config, repo, params, &shape, stream.snapshot())
}
fn first_items(
&self,
config: &Arc<Config>,
repo: &Rc<Repository>,
params: &RenderParams,
) -> Res<Items> {
let (stream, shape) = self.start(config, repo, params)?;
let (config, repo, built_params) = (Arc::clone(config), Rc::clone(repo), params.clone());
streamed_items(Streams(vec![stream]), params, move |mut snapshots| {
built(&config, &repo, &built_params, &shape, snapshots.remove(0))
})
}
fn start(
&self,
config: &Config,
repo: &Repository,
params: &RenderParams,
) -> Res<(Stream, Shape)> {
let (input, shape) = match self {
Source::Live { git } => {
let git = git.borrow();
let stat = git.stat();
let mut command = match stat {
Stat::No => git.command(params.context.as_deref()),
Stat::Git => git.command(None),
Stat::WithPatch | Stat::Folded => {
git.as_patch().command(params.context.as_deref())
}
};
let workdir = repo.workdir().ok_or(Error::NoRepoWorkdir)?;
command.current_dir(workdir.join(git.prefix()));
let shape = Shape::Diff {
diff_type: git.diff_type(),
named_commit: true,
stat,
truncated: false,
};
(Input::Command(command), shape)
}
Source::Patch { text, truncated } => {
let shape = Shape::Diff {
diff_type: DiffType::TreeToTree,
named_commit: false,
stat: Stat::No,
truncated: *truncated,
};
(Input::Text(text.clone()), shape)
}
Source::Unstructured { text, truncated } => (
Input::Text(text.clone()),
Shape::Rows {
truncated: *truncated,
},
),
};
let renderer = (!config.general.diff_renderer.command.is_empty())
.then(|| {
crate::diff_renderer::renderer_command(
&config.general.diff_renderer.command,
params,
None,
)
})
.flatten();
let stream = Stream::start(input, renderer, config.general.max_input_bytes)
.map_err(Error::GitDiff)?;
Ok((stream, shape))
}
}
enum Shape {
Diff {
diff_type: DiffType,
named_commit: bool,
stat: Stat,
truncated: bool,
},
Rows { truncated: bool },
}
fn built(
config: &Config,
repo: &Repository,
params: &RenderParams,
shape: &Shape,
snapshot: Snapshot,
) -> Res<Vec<Item>> {
if let Some(refused) = snapshot.refused {
return Err(Error::GitRefusedTheCommand(refused));
}
let limit = config.general.max_input_bytes;
let drawn = snapshot.rendered.as_deref().unwrap_or(&snapshot.text);
let (mut items, truncated) = match shape {
Shape::Rows { truncated } => (rows(repo, drawn), *truncated),
Shape::Diff {
diff_type,
named_commit,
stat,
truncated,
} => {
let mut items = Vec::new();
let headed_by_stat =
stat.is_counted() || config.general.file_header == FileHeader::Stat;
let stat_folded =
*stat == Stat::Folded || (config.general.start_folded && headed_by_stat);
let input = if snapshot.done || stat_folded {
&snapshot.text
} else {
snapshot.taken()
};
let text = crate::diff_renderer::strip_ansi(input);
let file_diffs = gitu_diff::Parser::new(&text)
.parse_patches()
.unwrap_or_default();
let fold = *stat == Stat::Folded || config.general.start_folded;
let patches = items::patches_of(&text, &file_diffs);
if file_diffs.is_empty() {
items.extend(rows(repo, drawn));
} else if patches.len() > 1 {
items.extend(
snapshot
.rendered
.as_deref()
.and_then(|rendered| {
items::log_of_patches_items(
config,
params,
repo,
patches,
rendered,
snapshot.done,
diff_type,
stat.is_counted(),
fold,
)
})
.unwrap_or_else(|| rows(repo, drawn)),
);
} else {
let diff = Rc::new(Diff {
file_diffs,
diff_type: diff_type.clone(),
commit: named_commit.then(|| commit_named_by(&text)).flatten(),
text,
});
let folded_to_stat_rows = fold && headed_by_stat;
let drawn = items::drawn_diff_items(
config,
&diff,
snapshot.rendered.as_deref(),
!snapshot.done && !folded_to_stat_rows,
0,
fold,
None,
);
if let Some(preamble) = preamble(&snapshot.text, &diff.text) {
items.extend(items::plain_rows(&render(config, params, &preamble)));
}
items.extend(if headed_by_stat {
items::headed_by_counted_stat(drawn, params.width(), true)
} else {
drawn
});
}
(items, *truncated)
}
};
items.extend(truncated_rows(truncated || snapshot.truncated, limit));
Ok(items)
}
fn truncated_rows(truncated: bool, limit: u64) -> Vec<Item> {
if !truncated {
return Vec::new();
}
vec![dim_row(format!(
"input truncated at {limit} bytes (general.max_input_bytes)"
))]
}
fn dim_row(text: String) -> Item {
Item {
unselectable: true,
rendered: Some(Rc::new(vec![(
text,
Style {
add_modifier: Modifier::DIM,
..Style::new()
},
)])),
..Default::default()
}
}
fn rows(repo: &Repository, drawn: &str) -> Vec<Item> {
items::rendered_log_items(repo, drawn).unwrap_or_else(|| items::plain_rows(drawn))
}
fn commit_named_by(text: &str) -> Option<String> {
let id = text
.lines()
.next()?
.strip_prefix("commit ")?
.split_whitespace()
.next()?;
id.chars()
.all(|c| c.is_ascii_hexdigit())
.then(|| id.to_owned())
}
fn preamble(coloured: &str, plain: &str) -> Option<String> {
let end = match plain.find("\ndiff --git ") {
Some(i) if !plain.starts_with("diff --git ") => i + 1,
_ => return None,
};
let lines = plain[..end].lines().count();
Some(coloured.split_inclusive('\n').take(lines).collect())
}
fn render(config: &Config, params: &RenderParams, text: &str) -> String {
if config.general.diff_renderer.command.is_empty() {
return text.to_owned();
}
crate::diff_renderer::run(
&config.general.diff_renderer.command,
Some(text),
params,
None,
)
.unwrap_or_else(|| text.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::item_data::ItemData;
use crate::repo_setup_clone;
use crate::tests::helpers::{RepoTestContext, run};
use stdext::function_name;
const PATCH: &str = "diff --git a/f.rs b/f.rs\n\
index 1..2 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1,3 +1,3 @@\n\
\x20keep\n\
-gone\n\
+added\n";
fn paged(text: &str, git_argv: Option<Vec<String>>) -> Paged {
Paged {
text: text.to_owned(),
git_argv,
prefix: String::new(),
truncated: false,
}
}
fn items_of(repo: &Repository, patch: &str) -> Vec<ItemData> {
let config = config::init_test_config().unwrap();
Source::of(paged(patch, None), &[])
.items(&config, repo, &Default::default())
.unwrap()
.into_iter()
.map(|item| item.data)
.collect()
}
fn screen_of(ctx: &RepoTestContext, patch: &str) -> Screen {
create(
Arc::new(config::init_test_config().unwrap()),
Rc::new(Repository::open(&ctx.dir).unwrap()),
RenderParams {
size: (80, 20),
features: Rc::from([]),
context: None,
streaming: false,
},
paged(patch, None),
)
.unwrap()
}
#[test]
fn a_piped_patch_becomes_a_file_a_hunk_and_its_lines() {
let ctx = repo_setup_clone!();
let items = items_of(&ctx.local_repo, PATCH);
assert!(
matches!(items.first(), Some(ItemData::Delta { .. })),
"got {items:?}"
);
assert!(items.iter().any(|d| matches!(d, ItemData::Hunk { .. })));
assert_eq!(
items
.iter()
.filter(|d| matches!(d, ItemData::HunkLine { .. }))
.count(),
3
);
}
#[test]
fn a_coloured_patch_parses_the_same_as_a_plain_one() {
let ctx = repo_setup_clone!();
let coloured = "\x1b[1mdiff --git a/f.rs b/f.rs\x1b[m\n\
\x1b[1mindex 1..2 100644\x1b[m\n\
\x1b[1m--- a/f.rs\x1b[m\n\
\x1b[1m+++ b/f.rs\x1b[m\n\
\x1b[36m@@ -1,3 +1,3 @@\x1b[m\n\
\x20keep\n\
\x1b[31m-gone\x1b[m\n\
\x1b[32m+added\x1b[m\n";
assert_eq!(
items_of(&ctx.local_repo, coloured).len(),
items_of(&ctx.local_repo, PATCH).len()
);
}
#[test]
fn a_commit_patch_keeps_its_message_above_the_diff() {
let ctx = repo_setup_clone!();
let patch = format!(
"commit 0123456789abcdef\n\
Author: Author Name <author@email.com>\n\
\n\
\x20 add initial-file\n\
\n\
{PATCH}"
);
let screen = screen_of(&ctx, &patch);
let rows = screen.row_texts();
assert!(
rows.starts_with(&[
"commit 0123456789abcdef".to_string(),
"Author: Author Name <author@email.com>".to_string(),
String::new(),
" add initial-file".to_string(),
String::new(),
]),
"got {rows:?}"
);
assert!(
items_of(&ctx.local_repo, &patch)
.iter()
.any(|data| matches!(data, ItemData::HunkLine { .. })),
"the diff below it is still a diff"
);
}
#[test]
fn a_patch_of_several_files_shows_each_of_them_once() {
let ctx = repo_setup_clone!();
let patch = format!("{PATCH}{}", PATCH.replace("f.rs", "g.rs"));
let items = items_of(&ctx.local_repo, &patch);
assert!(
matches!(items.first(), Some(ItemData::Delta { .. })),
"got {items:?}"
);
assert_eq!(
items
.iter()
.filter(|d| matches!(d, ItemData::Delta { .. }))
.count(),
2
);
assert_eq!(
items
.iter()
.filter(|d| matches!(d, ItemData::HunkLine { .. }))
.count(),
6
);
}
#[test]
fn the_cursor_moves_between_unstructured_rows() {
let ctx = repo_setup_clone!();
let mut screen = screen_of(&ctx, "one\ntwo\nthree\n");
assert_eq!(screen.row_texts().len(), 3, "{:?}", screen.row_texts());
screen.select_next(crate::screen::NavMode::IncludeSubLines);
assert_eq!(selected_row(&screen), "two");
}
fn selected_row(screen: &Screen) -> String {
screen
.get_selected_item()
.rendered
.as_ref()
.map(|row| row.iter().map(|(text, _)| text.as_str()).collect())
.unwrap_or_default()
}
#[test]
fn output_that_is_not_a_patch_yields_no_files() {
let ctx = repo_setup_clone!();
assert!(
!items_of(&ctx.local_repo, "this is not a diff\nnor is this\n")
.iter()
.any(|data| matches!(
data,
ItemData::Delta { .. } | ItemData::Hunk { .. } | ItemData::HunkLine { .. }
))
);
assert!(items_of(&ctx.local_repo, "").is_empty());
}
#[test]
fn output_that_is_not_a_patch_is_still_readable() {
let ctx = repo_setup_clone!();
let grep = "m.rs:2: let alpha = 1;\nm.rs:3: let gamma = 42;\n";
let screen = screen_of(&ctx, grep);
assert_eq!(
screen.row_texts(),
["m.rs:2: let alpha = 1;", "m.rs:3: let gamma = 42;"]
);
}
#[test]
fn the_context_of_a_diff_against_a_rev_can_be_widened() {
let ctx = repo_setup_clone!();
let lines = (1..=21).map(|n| format!("line {n}\n")).collect::<String>();
write(&ctx, &lines);
run(&ctx.dir, &["git", "add", "f.txt"]);
run(&ctx.dir, &["git", "commit", "-m", "add f.txt"]);
run(&ctx.dir, &["git", "checkout", "-b", "topic"]);
write(&ctx, &lines.replace("line 11\n", "LINE 11\n"));
run(&ctx.dir, &["git", "commit", "-am", "change f.txt"]);
let patch = run(&ctx.dir, &["git", "diff", "main"]);
let argv = ["git", "diff", "main"].map(String::from).to_vec();
assert!(
hunk_lines(&ctx, &patch, &argv, Some("-U5")) > hunk_lines(&ctx, &patch, &argv, None)
);
}
#[test]
fn an_edit_that_asks_for_a_diff_gets_one() {
let ctx = repo_setup_clone!();
let config = config::init_test_config().unwrap();
let repo = Repository::open(&ctx.dir).unwrap();
let source = Source::of(
paged(
&run(&ctx.dir, &["git", "log"]),
Some(["git", "log"].map(String::from).to_vec()),
),
&[],
);
let items = |source: &Source| {
source
.items(&config, &repo, &Default::default())
.unwrap()
.into_iter()
.map(|item| item.data)
.collect::<Vec<_>>()
};
assert!(
!items(&source)
.iter()
.any(|data| matches!(data, ItemData::HunkLine { .. })),
"a log has no diff in it"
);
let git = source.git_command().expect("the command was found");
let edited = git.borrow().edited("git log -p").unwrap();
*git.borrow_mut() = edited;
assert!(
items(&source)
.iter()
.any(|data| matches!(data, ItemData::HunkLine { .. })),
"a log with patches does"
);
}
#[test]
fn the_files_config_hides_are_gone_from_the_first_ask() {
let ctx = repo_setup_clone!();
let config = config::init_test_config().unwrap();
let repo = Repository::open(&ctx.dir).unwrap();
for file in ["a.rs", "a.pb.rs"] {
std::fs::write(ctx.dir.join(file), "generated\n").unwrap();
}
run(&ctx.dir, &["git", "add", "."]);
let source = Source::of(
paged(
&run(&ctx.dir, &["git", "diff", "--cached"]),
Some(["git", "diff", "--cached"].map(String::from).to_vec()),
),
&["*.pb.rs".to_owned()],
);
let files = source
.items(&config, &repo, &Default::default())
.unwrap()
.iter()
.filter_map(|item| match &item.data {
ItemData::Delta { diff, file_i, .. } => Some(
diff.file_diffs[*file_i]
.header
.new_file
.fmt(&diff.text)
.into_owned(),
),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(files, ["a.rs"]);
}
#[test]
fn a_stat_shows_a_row_per_file_as_git_prints_it() {
let ctx = repo_setup_clone!();
let screen = stat_screen(&ctx);
assert_eq!(
screen.shown_row_texts(),
run(&ctx.dir, &["git", "diff", "--stat"])
.lines()
.collect::<Vec<_>>()
);
assert_eq!(
screen
.visible_items()
.filter(|&i| matches!(screen.items()[i].data, ItemData::Delta { .. }))
.count(),
2
);
}
#[test]
fn a_file_in_a_stat_unfolds_to_its_diff() {
let ctx = repo_setup_clone!();
let mut screen = stat_screen(&ctx);
let shows_a_line = |screen: &Screen| {
screen
.visible_items()
.any(|i| matches!(screen.items()[i].data, ItemData::HunkLine { .. }))
};
assert!(!shows_a_line(&screen));
screen.toggle_section().unwrap();
assert!(shows_a_line(&screen));
}
#[test]
fn an_unfolded_stat_file_keeps_its_stat_row() {
let ctx = repo_setup_clone!();
let mut screen = stat_screen(&ctx);
let stat_row = screen.shown_row_texts()[0].clone();
screen.toggle_section().unwrap();
assert_eq!(screen.shown_row_texts()[0], stat_row);
assert_eq!(
screen.get_selected_item().header.as_deref().map(|row| row
.iter()
.map(|(text, _)| text.as_str())
.collect::<String>()),
Some(stat_row)
);
}
#[test]
fn the_cursor_moves_down_a_stat_file_by_file() {
let ctx = repo_setup_clone!();
let mut screen = stat_screen(&ctx);
let first = screen.get_selected_item().data.clone();
screen.select_next(crate::screen::NavMode::Normal);
assert!(
matches!(first, ItemData::Delta { file_i: 0, .. }),
"{first:?}"
);
assert!(
matches!(
screen.get_selected_item().data,
ItemData::Delta { file_i: 1, .. }
),
"{:?}",
screen.get_selected_item().data
);
}
#[test]
fn folding_all_from_within_a_stat_file_keeps_the_cursor_on_it() {
let ctx = repo_setup_clone!();
for file_i in 0..2 {
let mut screen = stat_screen(&ctx);
for _ in 0..file_i {
screen.select_next(crate::screen::NavMode::Normal);
}
screen.toggle_section().unwrap();
for _ in 0..3 {
screen.select_next(crate::screen::NavMode::IncludeSubLines);
}
assert!(
matches!(screen.get_selected_item().data, ItemData::HunkLine { .. }),
"{:?}",
screen.get_selected_item().data
);
screen.toggle_all_sections();
assert!(
matches!(
screen.get_selected_item().data,
ItemData::Delta { file_i: i, .. } if i == file_i
),
"file {file_i}: {:?}",
screen.get_selected_item().data
);
}
}
fn stat_screen(ctx: &RepoTestContext) -> Screen {
std::fs::write(ctx.dir.join("initial-file"), "hello\nworld\n").unwrap();
std::fs::write(ctx.dir.join("second-file"), "one\n").unwrap();
run(&ctx.dir, &["git", "add", "-N", "second-file"]);
let argv = ["git", "diff", "--stat"].map(String::from).to_vec();
create(
Arc::new(config::init_test_config().unwrap()),
Rc::new(Repository::open(&ctx.dir).unwrap()),
RenderParams {
size: (80, 20),
features: Rc::from([]),
context: None,
streaming: false,
},
paged(&run(&ctx.dir, &["git", "diff", "--stat"]), Some(argv)),
)
.unwrap()
}
fn write(ctx: &RepoTestContext, content: &str) {
std::fs::write(ctx.dir.join("f.txt"), content).unwrap();
}
fn hunk_lines(
ctx: &RepoTestContext,
patch: &str,
argv: &[String],
context: Option<&str>,
) -> usize {
let config = config::init_test_config().unwrap();
let repo = Repository::open(&ctx.dir).unwrap();
Source::of(paged(patch, Some(argv.to_vec())), &[])
.items(
&config,
&repo,
&RenderParams {
context: context.map(Rc::from),
..Default::default()
},
)
.unwrap()
.iter()
.filter(|item| matches!(item.data, ItemData::HunkLine { .. }))
.count()
}
}