Replace gix with configurable libgit2 diffs

This commit is contained in:
Georg Bauer
2026-07-28 18:25:24 +02:00
parent ed2226bc60
commit 31f6426eef
9 changed files with 410 additions and 1295 deletions

View File

@@ -1,5 +1,7 @@
use super::*;
use std::collections::BTreeMap;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum GitChangeKind {
@@ -181,7 +183,7 @@ impl App {
self.error = Some("The selected Git file is no longer available.".into());
return;
};
match load_diff(&worktree.root, file) {
match load_diff(&worktree.root, file, &self.config.git) {
Ok(diff) => {
self.git_diff = Some(diff);
self.error = None;
@@ -420,25 +422,22 @@ impl App {
}
fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
let repository = gix::discover(project_path).map_err(|error| error.to_string())?;
let repository = git2::Repository::discover(project_path).map_err(|error| error.to_string())?;
let root = repository
.workdir()
.ok_or_else(|| "bare repositories do not have a worktree".to_owned())?
.to_path_buf();
let dirwalk = repository
.dirwalk_options()
.map_err(|error| error.to_string())?;
let mut options = git2::StatusOptions::new();
options.include_untracked(true).recurse_untracked_dirs(true);
let mut files = BTreeMap::<PathBuf, GitFile>::new();
let changes = repository
.status(gix::progress::Discard)
.map_err(|error| error.to_string())?
.index_worktree_options_mut(|options| options.dirwalk_options = Some(dirwalk))
.untracked_files(gix::status::UntrackedFiles::Files)
.into_iter(Vec::<gix::bstr::BString>::new())
let statuses = repository
.statuses(Some(&mut options))
.map_err(|error| error.to_string())?;
for change in changes {
let change = change.map_err(|error| error.to_string())?;
let path = gix::path::from_bstr(change.location()).into_owned();
for entry in statuses.iter() {
#[cfg(unix)]
let path = PathBuf::from(std::ffi::OsString::from_vec(entry.path_bytes().to_vec()));
#[cfg(not(unix))]
let path = PathBuf::from(String::from_utf8_lossy(entry.path_bytes()).into_owned());
let display_path = path.to_string_lossy().into_owned();
let file = files.entry(path.clone()).or_insert_with(|| GitFile {
path,
@@ -446,18 +445,30 @@ fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
staged_kind: None,
worktree_kind: None,
});
match change {
gix::status::Item::IndexWorktree(change) => {
file.worktree_kind = change.summary().map(worktree_kind);
}
gix::status::Item::TreeIndex(change) => {
file.staged_kind = Some(match change {
gix::diff::index::Change::Addition { .. } => GitChangeKind::Added,
gix::diff::index::Change::Deletion { .. } => GitChangeKind::Deleted,
gix::diff::index::Change::Modification { .. }
| gix::diff::index::Change::Rewrite { .. } => GitChangeKind::Modified,
});
}
let status = entry.status();
if status.intersects(git2::Status::INDEX_NEW) {
file.staged_kind = Some(GitChangeKind::Added);
} else if status.intersects(git2::Status::INDEX_DELETED) {
file.staged_kind = Some(GitChangeKind::Deleted);
} else if status.intersects(
git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE,
) {
file.staged_kind = Some(GitChangeKind::Modified);
}
if status.intersects(git2::Status::WT_NEW) {
file.worktree_kind = Some(GitChangeKind::Added);
} else if status.intersects(git2::Status::WT_DELETED) {
file.worktree_kind = Some(GitChangeKind::Deleted);
} else if status.intersects(
git2::Status::WT_MODIFIED | git2::Status::WT_RENAMED | git2::Status::WT_TYPECHANGE,
) {
file.worktree_kind = Some(GitChangeKind::Modified);
}
if status.intersects(git2::Status::CONFLICTED) {
file.staged_kind = Some(GitChangeKind::Modified);
file.worktree_kind = Some(GitChangeKind::Modified);
}
}
files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some());
@@ -467,76 +478,29 @@ fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
})
}
fn worktree_kind(summary: gix::status::index_worktree::iter::Summary) -> GitChangeKind {
use gix::status::index_worktree::iter::Summary;
match summary {
Summary::Added | Summary::IntentToAdd => GitChangeKind::Added,
Summary::Removed => GitChangeKind::Deleted,
Summary::Modified
| Summary::TypeChange
| Summary::Renamed
| Summary::Copied
| Summary::Conflict => GitChangeKind::Modified,
}
}
fn load_diff(root: &Path, file: &GitFile) -> Result<GitDiff, String> {
let repository = gix::discover(root).map_err(|error| error.to_string())?;
let index = repository
.index_or_empty()
.map_err(|error| error.to_string())?;
let index_path =
gix::path::to_unix_separators_on_windows(gix::path::into_bstr(file.path.as_path()));
let index_blob = index
.entry_by_path(index_path.as_ref())
.filter(|entry| {
entry
.mode
.to_tree_entry_mode()
.is_some_and(|mode| mode.is_blob_or_symlink())
})
.map(|entry| {
repository
.find_blob(entry.id)
.map(|mut blob| blob.take_data())
.map_err(|error| error.to_string())
})
.transpose()?;
fn load_diff(root: &Path, file: &GitFile, settings: &GitConfig) -> Result<GitDiff, String> {
let repository = git2::Repository::discover(root).map_err(|error| error.to_string())?;
let index = repository.index().map_err(|error| error.to_string())?;
let mut lines = Vec::new();
if file.staged_kind.is_some() {
let tree = repository
.find_tree(
repository
.head_tree_id_or_empty()
.map_err(|error| error.to_string())?,
)
let head_tree = repository
.head()
.ok()
.and_then(|head| head.peel_to_tree().ok());
let mut options = diff_options(file, settings, false);
let diff = repository
.diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut options))
.map_err(|error| error.to_string())?;
let head_blob = tree
.lookup_entry_by_path(&file.path)
.map_err(|error| error.to_string())?
.filter(|entry| entry.mode().is_blob_or_symlink())
.map(|entry| {
repository
.find_blob(entry.object_id())
.map(|mut blob| blob.take_data())
.map_err(|error| error.to_string())
})
.transpose()?;
lines.push(diff_section("Staged changes"));
lines.extend(diff_blobs(
head_blob.as_deref(),
index_blob.as_deref(),
&file.display_path,
)?);
lines.extend(render_diff(&diff)?);
}
if file.worktree_kind.is_some() {
let worktree_blob = read_worktree_blob(&root.join(&file.path))?;
let mut options = diff_options(file, settings, true);
let diff = repository
.diff_index_to_workdir(Some(&index), Some(&mut options))
.map_err(|error| error.to_string())?;
lines.push(diff_section("Worktree changes"));
lines.extend(diff_blobs(
index_blob.as_deref(),
worktree_blob.as_deref(),
&file.display_path,
)?);
lines.extend(render_diff(&diff)?);
}
if lines.is_empty() {
lines.push(diff_message("No textual diff is available."));
@@ -547,6 +511,67 @@ fn load_diff(root: &Path, file: &GitFile) -> Result<GitDiff, String> {
})
}
fn diff_options(file: &GitFile, settings: &GitConfig, worktree: bool) -> git2::DiffOptions {
let mut options = git2::DiffOptions::new();
options
.pathspec(&file.path)
.context_lines(settings.context_lines)
.interhunk_lines(settings.interhunk_lines)
.indent_heuristic(settings.indent_heuristic)
.ignore_blank_lines(settings.ignore_blank_lines);
match settings.diff_algorithm {
GitDiffAlgorithm::Default => {}
GitDiffAlgorithm::Patience => {
options.patience(true);
}
GitDiffAlgorithm::Minimal => {
options.minimal(true);
}
}
match settings.whitespace {
GitDiffWhitespace::ShowAll => {}
GitDiffWhitespace::IgnoreAll => {
options.ignore_whitespace(true);
}
GitDiffWhitespace::IgnoreChanges => {
options.ignore_whitespace_change(true);
}
GitDiffWhitespace::IgnoreEndOfLine => {
options.ignore_whitespace_eol(true);
}
}
if worktree {
options
.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true);
}
options
}
fn render_diff(diff: &git2::Diff<'_>) -> Result<Vec<GitDiffLine>, String> {
let mut output = Vec::new();
diff.print(git2::DiffFormat::Patch, |_, _, line| {
if matches!(
line.origin_value(),
git2::DiffLineType::Context
| git2::DiffLineType::Addition
| git2::DiffLineType::Deletion
) {
output.push(line.origin() as u8);
}
output.extend_from_slice(line.content());
true
})
.map_err(|error| error.to_string())?;
let lines = parse_unified_diff(&String::from_utf8_lossy(&output));
Ok(if lines.is_empty() {
vec![diff_message("No textual diff is available.")]
} else {
lines
})
}
fn diff_section(text: &str) -> GitDiffLine {
GitDiffLine {
kind: GitDiffLineKind::Section,
@@ -565,58 +590,6 @@ fn diff_message(text: &str) -> GitDiffLine {
}
}
fn read_worktree_blob(path: &Path) -> Result<Option<Vec<u8>>, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => fs::read_link(path)
.map(|target| Some(target.as_os_str().as_encoded_bytes().to_vec()))
.map_err(|error| error.to_string()),
Ok(metadata) if metadata.is_file() => {
fs::read(path).map(Some).map_err(|error| error.to_string())
}
Ok(_) => Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.to_string()),
}
}
fn diff_blobs(
before: Option<&[u8]>,
after: Option<&[u8]>,
path: &str,
) -> Result<Vec<GitDiffLine>, String> {
let before_label = before.map_or_else(|| "/dev/null".into(), |_| format!("a/{path}"));
let after_label = after.map_or_else(|| "/dev/null".into(), |_| format!("b/{path}"));
let before = before.unwrap_or_default();
let after = after.unwrap_or_default();
let mut lines = vec![
diff_message(&format!("--- {before_label}")),
diff_message(&format!("+++ {after_label}")),
];
if before == after {
lines.push(diff_message("No textual diff is available."));
return Ok(lines);
}
if before.iter().take(8_000).any(|byte| *byte == 0)
|| after.iter().take(8_000).any(|byte| *byte == 0)
{
lines.push(diff_message("Binary file changed."));
return Ok(lines);
}
let input = gix::diff::blob::InternedInput::new(before, after);
let diff =
gix::diff::blob::diff_with_slider_heuristics(gix::diff::blob::Algorithm::Histogram, &input);
let output = gix::diff::blob::UnifiedDiff::new(
&diff,
&input,
gix::diff::blob::unified_diff::ConsumeBinaryHunk::new(Vec::new(), "\n"),
Default::default(),
)
.consume()
.map_err(|error| error.to_string())?;
lines.extend(parse_unified_diff(&String::from_utf8_lossy(&output)));
Ok(lines)
}
fn parse_unified_diff(diff: &str) -> Vec<GitDiffLine> {
let mut old_number = None;
let mut new_number = None;
@@ -924,7 +897,7 @@ mod tests {
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
#[test]
fn gitoxide_status_merges_staged_and_worktree_changes() {
fn libgit2_status_merges_staged_and_worktree_changes() {
let directory = repository_fixture();
std::fs::write(directory.join("added.txt"), "added\n").unwrap();
git2::Repository::open(&directory)
@@ -952,7 +925,7 @@ mod tests {
.unwrap();
assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified));
assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified));
let diff = load_diff(&worktree.root, changed).unwrap();
let diff = load_diff(&worktree.root, changed, &GitConfig::default()).unwrap();
assert_eq!(
diff.lines
.iter()
@@ -1118,6 +1091,55 @@ mod tests {
)));
}
#[test]
fn configured_git_diff_options_change_the_rendered_patch() {
let directory = empty_repository_fixture();
std::fs::write(
directory.join("changed.txt"),
"first\nsecond\nthird\nfourth\nfifth\n",
)
.unwrap();
stage_paths(&directory, &["changed.txt".into()]).unwrap();
commit_index(&directory, "Initial").unwrap();
std::fs::write(
directory.join("changed.txt"),
"first\nsecond changed\nthird\nfourth changed\nfifth\n",
)
.unwrap();
let worktree = read_worktree(&directory).unwrap();
let changed = worktree
.files
.iter()
.find(|file| file.display_path == "changed.txt")
.unwrap();
let settings = GitConfig {
diff_algorithm: GitDiffAlgorithm::Patience,
context_lines: 0,
interhunk_lines: 0,
indent_heuristic: true,
whitespace: GitDiffWhitespace::ShowAll,
ignore_blank_lines: false,
};
let diff = load_diff(&worktree.root, changed, &settings).unwrap();
assert_eq!(
diff.lines
.iter()
.filter(|line| line.kind == GitDiffLineKind::Context)
.count(),
0
);
assert_eq!(
diff.lines
.iter()
.filter(|line| line.kind == GitDiffLineKind::Hunk)
.count(),
2
);
std::fs::remove_dir_all(directory).unwrap();
}
fn repository_fixture() -> PathBuf {
let directory = empty_repository_fixture();
std::fs::write(directory.join("changed.txt"), "original\n").unwrap();