gitkraft_core/features/diff/
ops.rs1use anyhow::{Context, Result};
4use git2::{Diff, DiffFormat, DiffOptions, Repository};
5
6use super::types::{DiffHunk, DiffInfo, DiffLine, FileStatus};
7
8pub fn get_working_dir_diff(repo: &Repository) -> Result<Vec<DiffInfo>> {
14 let mut opts = DiffOptions::new();
15 opts.include_untracked(true);
16 opts.recurse_untracked_dirs(true);
17
18 let diff = repo
19 .diff_index_to_workdir(None, Some(&mut opts))
20 .context("failed to diff working directory against index")?;
21 parse_diff(&diff)
22}
23
24pub fn get_staged_diff(repo: &Repository) -> Result<Vec<DiffInfo>> {
28 let head_tree = match repo.head() {
29 Ok(reference) => {
30 let commit = reference
31 .peel_to_commit()
32 .context("HEAD does not point to a commit")?;
33 Some(commit.tree().context("commit has no tree")?)
34 }
35 Err(_) => None,
37 };
38
39 let diff = repo
40 .diff_tree_to_index(head_tree.as_ref(), None, None)
41 .context("failed to diff index against HEAD tree")?;
42 parse_diff(&diff)
43}
44
45pub fn get_commit_diff(repo: &Repository, oid_str: &str) -> Result<Vec<DiffInfo>> {
49 let oid =
50 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
51 let commit = repo
52 .find_commit(oid)
53 .with_context(|| format!("commit {oid_str} not found"))?;
54 let commit_tree = commit.tree().context("commit has no tree")?;
55
56 let parent_tree = if commit.parent_count() > 0 {
57 let parent = commit.parent(0).context("failed to read parent commit")?;
58 Some(parent.tree().context("parent commit has no tree")?)
59 } else {
60 None
61 };
62
63 let mut opts = DiffOptions::new();
64 let diff = repo
65 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
66 .context("failed to diff commit against parent")?;
67 parse_diff(&diff)
68}
69
70pub fn get_commit_file_list(
76 repo: &Repository,
77 oid_str: &str,
78) -> Result<Vec<super::types::DiffFileEntry>> {
79 let oid =
80 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
81 let commit = repo
82 .find_commit(oid)
83 .with_context(|| format!("commit {oid_str} not found"))?;
84 let commit_tree = commit.tree().context("commit has no tree")?;
85
86 let parent_tree = if commit.parent_count() > 0 {
87 let parent = commit.parent(0).context("failed to read parent commit")?;
88 Some(parent.tree().context("parent commit has no tree")?)
89 } else {
90 None
91 };
92
93 let diff = repo
94 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
95 .context("failed to diff commit against parent")?;
96
97 Ok(diff
98 .deltas()
99 .map(|delta| super::types::DiffFileEntry {
100 old_file: delta
101 .old_file()
102 .path()
103 .map(|p| p.to_string_lossy().into_owned())
104 .unwrap_or_default(),
105 new_file: delta
106 .new_file()
107 .path()
108 .map(|p| p.to_string_lossy().into_owned())
109 .unwrap_or_default(),
110 status: FileStatus::from_delta(delta.status()),
111 })
112 .collect())
113}
114
115pub fn get_single_file_diff(repo: &Repository, oid_str: &str, file_path: &str) -> Result<DiffInfo> {
120 let oid =
121 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
122 let commit = repo
123 .find_commit(oid)
124 .with_context(|| format!("commit {oid_str} not found"))?;
125 let commit_tree = commit.tree().context("commit has no tree")?;
126
127 let parent_tree = if commit.parent_count() > 0 {
128 let parent = commit.parent(0).context("failed to read parent commit")?;
129 Some(parent.tree().context("parent commit has no tree")?)
130 } else {
131 None
132 };
133
134 let mut opts = DiffOptions::new();
135 opts.pathspec(file_path);
136
137 let diff = repo
138 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
139 .context("failed to diff commit against parent for single file")?;
140
141 let infos = parse_diff(&diff)?;
142 infos
143 .into_iter()
144 .next()
145 .ok_or_else(|| anyhow::anyhow!("file '{}' not found in commit diff", file_path))
146}
147
148pub fn diff_file_commit_vs_workdir(
152 repo: &Repository,
153 oid_str: &str,
154 file_path: &str,
155) -> Result<DiffInfo> {
156 let oid =
157 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
158 let commit = repo
159 .find_commit(oid)
160 .with_context(|| format!("commit {oid_str} not found"))?;
161 let commit_tree = commit.tree().context("commit has no tree")?;
162
163 let mut opts = DiffOptions::new();
164 opts.pathspec(file_path);
165
166 let diff = repo
168 .diff_tree_to_workdir_with_index(Some(&commit_tree), Some(&mut opts))
169 .context("failed to diff commit tree against working directory")?;
170
171 let infos = parse_diff(&diff)?;
172 infos
173 .into_iter()
174 .next()
175 .ok_or_else(|| anyhow::anyhow!("file '{}' not found in diff", file_path))
176}
177
178fn parse_diff(diff: &Diff<'_>) -> Result<Vec<DiffInfo>> {
183 let num_deltas = diff.deltas().len();
184 let mut infos: Vec<DiffInfo> = Vec::with_capacity(num_deltas);
185
186 for delta in diff.deltas() {
189 let old_file = delta
190 .old_file()
191 .path()
192 .map(|p| p.to_string_lossy().into_owned())
193 .unwrap_or_default();
194 let new_file = delta
195 .new_file()
196 .path()
197 .map(|p| p.to_string_lossy().into_owned())
198 .unwrap_or_default();
199 let status = FileStatus::from_delta(delta.status());
200 infos.push(DiffInfo {
201 old_file,
202 new_file,
203 status,
204 hunks: Vec::new(),
205 });
206 }
207
208 let mut current_delta_idx: usize = 0;
211
212 diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
213 let delta_new = delta
215 .new_file()
216 .path()
217 .map(|p| p.to_string_lossy().into_owned())
218 .unwrap_or_default();
219 let delta_old = delta
220 .old_file()
221 .path()
222 .map(|p| p.to_string_lossy().into_owned())
223 .unwrap_or_default();
224
225 let found_idx = infos[current_delta_idx..]
227 .iter()
228 .position(|info| info.new_file == delta_new && info.old_file == delta_old)
229 .map(|pos| pos + current_delta_idx)
230 .or_else(|| {
231 infos[..current_delta_idx]
233 .iter()
234 .position(|info| info.new_file == delta_new && info.old_file == delta_old)
235 });
236
237 let found = found_idx.is_some();
238 if let Some(idx) = found_idx {
239 current_delta_idx = idx;
240 }
241 if !found {
242 return true; }
244
245 let info = &mut infos[current_delta_idx];
246
247 if let Some(hunk) = maybe_hunk {
249 let header = String::from_utf8_lossy(hunk.header())
250 .trim_end()
251 .to_string();
252
253 let needs_new = match info.hunks.last() {
255 Some(h) => h.header != header,
256 None => true,
257 };
258 if needs_new {
259 info.hunks.push(DiffHunk {
260 header: header.clone(),
261 lines: vec![DiffLine::HunkHeader(header)],
262 });
263 }
264 }
265
266 if let Some(hunk) = info.hunks.last_mut() {
268 let content = String::from_utf8_lossy(line.content())
269 .trim_end_matches('\n')
270 .trim_end_matches('\r')
271 .to_string();
272
273 let diff_line = match line.origin() {
274 '+' | '>' => DiffLine::Addition(content),
275 '-' | '<' => DiffLine::Deletion(content),
276 ' ' => DiffLine::Context(content),
277 _ => return true,
280 };
281 hunk.lines.push(diff_line);
282 }
283
284 true
285 })
286 .context("failed to walk diff")?;
287
288 Ok(infos)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::fs;
295
296 fn init_repo_with_commit(dir: &std::path::Path) -> git2::Repository {
297 let repo = git2::Repository::init(dir).unwrap();
298 {
299 let file_path = dir.join("hello.txt");
300 fs::write(&file_path, "Hello, world!\n").unwrap();
301
302 let mut index = repo.index().unwrap();
303 index.add_path(std::path::Path::new("hello.txt")).unwrap();
304 index.write().unwrap();
305
306 let tree_oid = index.write_tree().unwrap();
307 let tree = repo.find_tree(tree_oid).unwrap();
308 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
309 repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
310 .unwrap();
311 }
312 repo
313 }
314
315 #[test]
316 fn working_dir_diff_shows_changes() {
317 let tmp = tempfile::tempdir().unwrap();
318 let repo = init_repo_with_commit(tmp.path());
319
320 fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
322
323 let diffs = get_working_dir_diff(&repo).unwrap();
324 assert_eq!(diffs.len(), 1);
325 assert_eq!(diffs[0].new_file, "hello.txt");
326 assert_eq!(diffs[0].status, FileStatus::Modified);
327 assert!(!diffs[0].hunks.is_empty());
328 }
329
330 #[test]
331 fn staged_diff_shows_staged_changes() {
332 let tmp = tempfile::tempdir().unwrap();
333 let repo = init_repo_with_commit(tmp.path());
334
335 fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
337 let mut index = repo.index().unwrap();
338 index.add_path(std::path::Path::new("hello.txt")).unwrap();
339 index.write().unwrap();
340
341 let diffs = get_staged_diff(&repo).unwrap();
342 assert_eq!(diffs.len(), 1);
343 assert_eq!(diffs[0].new_file, "hello.txt");
344 assert_eq!(diffs[0].status, FileStatus::Modified);
345 }
346
347 #[test]
348 fn commit_diff_shows_initial_commit() {
349 let tmp = tempfile::tempdir().unwrap();
350 let repo = init_repo_with_commit(tmp.path());
351
352 let head_oid = repo.head().unwrap().target().unwrap().to_string();
353 let diffs = get_commit_diff(&repo, &head_oid).unwrap();
354 assert_eq!(diffs.len(), 1);
355 assert_eq!(diffs[0].new_file, "hello.txt");
356 assert_eq!(diffs[0].status, FileStatus::New);
357 }
358
359 #[test]
360 fn working_dir_diff_untracked_file() {
361 let tmp = tempfile::tempdir().unwrap();
362 let repo = init_repo_with_commit(tmp.path());
363
364 fs::write(tmp.path().join("new_file.txt"), "I am new!\n").unwrap();
366
367 let diffs = get_working_dir_diff(&repo).unwrap();
368 assert_eq!(diffs.len(), 1);
369 assert_eq!(diffs[0].new_file, "new_file.txt");
370 assert_eq!(diffs[0].status, FileStatus::Untracked);
371 }
372
373 #[test]
374 fn commit_file_list_returns_entries() {
375 let tmp = tempfile::tempdir().unwrap();
376 let repo = init_repo_with_commit(tmp.path());
377 let head_oid = repo.head().unwrap().target().unwrap().to_string();
378 let files = get_commit_file_list(&repo, &head_oid).unwrap();
379 assert_eq!(files.len(), 1);
380 assert_eq!(files[0].new_file, "hello.txt");
381 assert_eq!(files[0].status, FileStatus::New);
382 assert_eq!(files[0].display_path(), "hello.txt");
383 }
384
385 #[test]
386 fn single_file_diff_returns_correct_file() {
387 let tmp = tempfile::tempdir().unwrap();
388 let repo = init_repo_with_commit(tmp.path());
389 let head_oid = repo.head().unwrap().target().unwrap().to_string();
390 let diff = get_single_file_diff(&repo, &head_oid, "hello.txt").unwrap();
391 assert_eq!(diff.new_file, "hello.txt");
392 assert_eq!(diff.status, FileStatus::New);
393 assert!(!diff.hunks.is_empty());
394 }
395
396 #[test]
397 fn diff_file_commit_vs_workdir_shows_changes() {
398 let tmp = tempfile::tempdir().unwrap();
399 let repo = init_repo_with_commit(tmp.path());
400 let head_oid = repo.head().unwrap().target().unwrap().to_string();
401
402 std::fs::write(tmp.path().join("hello.txt"), "Modified content!\n").unwrap();
404
405 let diff = diff_file_commit_vs_workdir(&repo, &head_oid, "hello.txt").unwrap();
406 assert_eq!(diff.new_file, "hello.txt");
407 assert!(!diff.hunks.is_empty());
408 }
409
410 #[test]
411 fn single_file_diff_not_found() {
412 let tmp = tempfile::tempdir().unwrap();
413 let repo = init_repo_with_commit(tmp.path());
414 let head_oid = repo.head().unwrap().target().unwrap().to_string();
415 let result = get_single_file_diff(&repo, &head_oid, "nonexistent.txt");
416 assert!(result.is_err());
417 }
418}