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_staged_diff(
repo: &Repository,
highlighter: &SyntaxHighlighter,
) -> Result<Vec<DiffFile>> {
let head = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
let index = repo.index()?;
let diff = repo.diff_tree_to_index(head.as_ref(), Some(&index), None)?;
parse_diff(&diff, highlighter)
}
pub fn get_unstaged_diff(
repo: &Repository,
highlighter: &SyntaxHighlighter,
) -> Result<Vec<DiffFile>> {
let index = repo.index()?;
let mut opts = DiffOptions::new();
opts.include_untracked(true);
opts.show_untracked_content(true);
opts.recurse_untracked_dirs(true);
let diff = repo.diff_index_to_workdir(Some(&index), 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();
const MAX_UNTRACKED_FILE_SIZE: u64 = 10 * 1_024 * 1_024;
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 is_too_large =
delta.status() == Delta::Untracked && delta.new_file().size() > MAX_UNTRACKED_FILE_SIZE;
let file_path = new_path.as_ref().or(old_path.as_ref());
let hunks = if is_binary || is_too_large {
Vec::new()
} else {
parse_hunks(diff, delta_idx, file_path, highlighter)?
};
files.push(DiffFile {
old_path,
new_path,
status,
hunks,
is_binary,
is_too_large,
is_commit_message: false,
});
}
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::*;
use std::fs;
use std::path::Path;
fn create_initial_commit(repo: &Repository, file_name: &str, content: &str) {
fs::write(repo.workdir().unwrap().join(file_name), content)
.expect("failed to write initial file");
let mut index = repo.index().expect("failed to open index");
index
.add_path(Path::new(file_name))
.expect("failed to add file to index");
index.write().expect("failed to write index");
let tree_id = index.write_tree().expect("failed to write tree");
let tree = repo.find_tree(tree_id).expect("failed to find tree");
let sig = git2::Signature::now("Test User", "test@example.com")
.expect("failed to create signature");
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.expect("failed to create commit");
}
#[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)));
}
#[test]
fn should_expand_tabs_to_spaces_in_git_hunks() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
create_initial_commit(
&repo, "file.txt", r#"old
"#,
);
fs::write(
temp_dir.path().join("file.txt"),
r#" new
"#,
)
.expect("failed to update file");
let files = get_working_tree_diff(&repo, &SyntaxHighlighter::default())
.expect("failed to get diff");
assert_eq!(files.len(), 1);
let lines = &files[0].hunks[0].lines;
assert!(
lines.iter().any(|l| l.content == " new"),
"expected tab-expanded content in git diff lines"
);
assert!(lines.iter().all(|l| !l.content.contains('\t')));
}
#[test]
fn should_separate_staged_and_unstaged_diffs() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
create_initial_commit(&repo, "file.txt", "base\n");
fs::write(temp_dir.path().join("file.txt"), "unstaged\n").expect("failed to update file");
let highlighter = SyntaxHighlighter::default();
let unstaged = get_unstaged_diff(&repo, &highlighter).expect("unstaged diff failed");
assert_eq!(unstaged.len(), 1);
assert!(matches!(
get_staged_diff(&repo, &highlighter),
Err(TuicrError::NoChanges)
));
let mut index = repo.index().expect("failed to open index");
index
.add_path(Path::new("file.txt"))
.expect("failed to add file to index");
index.write().expect("failed to write index");
let staged = get_staged_diff(&repo, &highlighter).expect("staged diff failed");
assert_eq!(staged.len(), 1);
assert!(matches!(
get_unstaged_diff(&repo, &highlighter),
Err(TuicrError::NoChanges)
));
}
}