use git2::{Delta, Diff, DiffOptions, Repository};
use std::path::PathBuf;
use crate::error::{Result, TuicrError};
use crate::model::{DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin};
use crate::syntax::SyntaxHighlighter;
pub fn get_working_tree_diff(
repo: &Repository,
highlighter: &SyntaxHighlighter,
) -> Result<Vec<DiffFile>> {
let head = repo.head()?.peel_to_tree()?;
let mut opts = DiffOptions::new();
opts.include_untracked(true);
opts.show_untracked_content(true);
opts.recurse_untracked_dirs(true);
let diff = repo.diff_tree_to_workdir_with_index(Some(&head), Some(&mut opts))?;
parse_diff(&diff, highlighter)
}
pub fn get_commit_range_diff(
repo: &Repository,
commit_ids: &[String],
highlighter: &SyntaxHighlighter,
) -> Result<Vec<DiffFile>> {
if commit_ids.is_empty() {
return Err(TuicrError::NoChanges);
}
let oldest_id = git2::Oid::from_str(&commit_ids[0])?;
let oldest_commit = repo.find_commit(oldest_id)?;
let newest_id = git2::Oid::from_str(commit_ids.last().unwrap())?;
let newest_commit = repo.find_commit(newest_id)?;
let old_tree = if oldest_commit.parent_count() > 0 {
Some(oldest_commit.parent(0)?.tree()?)
} else {
None
};
let new_tree = newest_commit.tree()?;
let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?;
parse_diff(&diff, highlighter)
}
pub fn get_working_tree_with_commits_diff(
repo: &Repository,
commit_ids: &[String],
highlighter: &SyntaxHighlighter,
) -> Result<Vec<DiffFile>> {
if commit_ids.is_empty() {
return Err(TuicrError::NoChanges);
}
let oldest_id = git2::Oid::from_str(&commit_ids[0])?;
let oldest_commit = repo.find_commit(oldest_id)?;
let old_tree = if oldest_commit.parent_count() > 0 {
Some(oldest_commit.parent(0)?.tree()?)
} else {
None
};
let mut opts = DiffOptions::new();
opts.include_untracked(true);
opts.show_untracked_content(true);
opts.recurse_untracked_dirs(true);
let diff = repo.diff_tree_to_workdir_with_index(old_tree.as_ref(), Some(&mut opts))?;
parse_diff(&diff, highlighter)
}
fn parse_diff(diff: &Diff, highlighter: &SyntaxHighlighter) -> Result<Vec<DiffFile>> {
let mut files: Vec<DiffFile> = Vec::new();
for (delta_idx, delta) in diff.deltas().enumerate() {
let status = match delta.status() {
Delta::Added | Delta::Untracked => FileStatus::Added,
Delta::Deleted => FileStatus::Deleted,
Delta::Modified => FileStatus::Modified,
Delta::Renamed => FileStatus::Renamed,
Delta::Copied => FileStatus::Copied,
_ => FileStatus::Modified,
};
let old_path = delta.old_file().path().map(PathBuf::from);
let new_path = delta.new_file().path().map(PathBuf::from);
let is_binary = delta.old_file().is_binary() || delta.new_file().is_binary();
let file_path = new_path.as_ref().or(old_path.as_ref());
let hunks = if is_binary {
Vec::new()
} else {
parse_hunks(diff, delta_idx, file_path, highlighter)?
};
files.push(DiffFile {
old_path,
new_path,
status,
hunks,
is_binary,
});
}
if files.is_empty() {
return Err(TuicrError::NoChanges);
}
Ok(files)
}
fn parse_hunks(
diff: &Diff,
delta_idx: usize,
file_path: Option<&PathBuf>,
highlighter: &SyntaxHighlighter,
) -> Result<Vec<DiffHunk>> {
let mut hunks: Vec<DiffHunk> = Vec::new();
let patch = git2::Patch::from_diff(diff, delta_idx)?;
if let Some(patch) = patch {
for hunk_idx in 0..patch.num_hunks() {
let (hunk, _) = patch.hunk(hunk_idx)?;
let header = String::from_utf8_lossy(hunk.header()).trim().to_string();
let old_start = hunk.old_start();
let old_count = hunk.old_lines();
let new_start = hunk.new_start();
let new_count = hunk.new_lines();
let mut lines: Vec<DiffLine> = Vec::new();
let mut line_contents: Vec<String> = Vec::new();
let mut line_origins: Vec<LineOrigin> = Vec::new();
for line_idx in 0..patch.num_lines_in_hunk(hunk_idx)? {
let line = patch.line_in_hunk(hunk_idx, line_idx)?;
let origin = match line.origin() {
'+' => LineOrigin::Addition,
'-' => LineOrigin::Deletion,
' ' => LineOrigin::Context,
_ => LineOrigin::Context,
};
let content = String::from_utf8_lossy(line.content())
.trim_end_matches('\n')
.trim_end_matches('\r')
.replace('\t', " ")
.to_string();
line_contents.push(content);
line_origins.push(origin);
}
let highlight_sequences =
SyntaxHighlighter::split_diff_lines_for_highlighting(&line_contents, &line_origins);
let (old_highlighted_lines, new_highlighted_lines) = if let Some(path) = file_path {
(
highlighter.highlight_file_lines(path, &highlight_sequences.old_lines),
highlighter.highlight_file_lines(path, &highlight_sequences.new_lines),
)
} else {
(None, None)
};
for line_idx in 0..patch.num_lines_in_hunk(hunk_idx)? {
let line = patch.line_in_hunk(hunk_idx, line_idx)?;
let old_lineno = line.old_lineno();
let new_lineno = line.new_lineno();
let content = line_contents[line_idx].clone();
let origin = line_origins[line_idx];
let highlighted_spans = highlighter.highlighted_line_for_diff_with_background(
old_highlighted_lines.as_deref(),
new_highlighted_lines.as_deref(),
highlight_sequences.old_line_indices[line_idx],
highlight_sequences.new_line_indices[line_idx],
origin,
);
lines.push(DiffLine {
origin,
content,
old_lineno,
new_lineno,
highlighted_spans,
});
}
hunks.push(DiffHunk {
header,
lines,
old_start,
old_count,
new_start,
new_count,
});
}
}
Ok(hunks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_return_no_changes_for_clean_repo() {
let repo = Repository::discover(".").unwrap();
let head = repo.head().unwrap().peel_to_tree().unwrap();
let diff = repo
.diff_tree_to_tree(Some(&head), Some(&head), None)
.unwrap();
let highlighter = SyntaxHighlighter::default();
let result = parse_diff(&diff, &highlighter);
assert!(matches!(result, Err(TuicrError::NoChanges)));
}
}