1use std::{cell::RefCell, fs, path::Path, rc::Rc};
4
5use easy_cast::Conv;
6use git2::{
7 Delta, Diff, DiffDelta, DiffFormat, DiffHunk, Patch, Repository,
8};
9use scopetime::scope_time;
10use serde::{Deserialize, Serialize};
11
12use super::{
13 CommitId, RepoPath,
14 commit_files::{
15 OldNew, get_commit_diff, get_compare_commits_diff,
16 },
17 utils::{get_head_repo, work_dir},
18};
19use crate::{
20 error::{Error, Result},
21 hash,
22 sync::{get_stashes, repository::repo},
23};
24
25#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
27pub enum DiffLineType {
28 None,
30 Header,
34 Add,
36 Delete,
38}
39
40impl From<git2::DiffLineType> for DiffLineType {
41 fn from(line_type: git2::DiffLineType) -> Self {
42 match line_type {
43 git2::DiffLineType::HunkHeader => Self::Header,
45 git2::DiffLineType::DeleteEOFNL
46 | git2::DiffLineType::Deletion => Self::Delete,
47 git2::DiffLineType::AddEOFNL
48 | git2::DiffLineType::Addition => Self::Add,
49 _ => Self::None,
50 }
51 }
52}
53
54impl Default for DiffLineType {
55 fn default() -> Self {
56 Self::None
57 }
58}
59
60#[derive(Default, Clone, Hash, Debug)]
64pub struct DiffLine {
65 pub content: Box<str>,
67 pub line_type: DiffLineType,
69 pub position: DiffLinePosition,
71}
72
73#[derive(Clone, Copy, Default, Hash, Debug, PartialEq, Eq)]
75pub struct DiffLinePosition {
76 pub old_lineno: Option<u32>,
78 pub new_lineno: Option<u32>,
80}
81
82impl PartialEq<&git2::DiffLine<'_>> for DiffLinePosition {
83 fn eq(&self, other: &&git2::DiffLine) -> bool {
84 other.new_lineno() == self.new_lineno
85 && other.old_lineno() == self.old_lineno
86 }
87}
88
89impl From<&git2::DiffLine<'_>> for DiffLinePosition {
90 fn from(line: &git2::DiffLine<'_>) -> Self {
91 Self {
92 old_lineno: line.old_lineno(),
93 new_lineno: line.new_lineno(),
94 }
95 }
96}
97
98#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
99pub(crate) struct HunkHeader {
100 pub old_start: u32,
101 pub old_lines: u32,
102 pub new_start: u32,
103 pub new_lines: u32,
104}
105
106impl From<DiffHunk<'_>> for HunkHeader {
107 fn from(h: DiffHunk) -> Self {
108 Self {
109 old_start: h.old_start(),
110 old_lines: h.old_lines(),
111 new_start: h.new_start(),
112 new_lines: h.new_lines(),
113 }
114 }
115}
116
117#[derive(Default, Clone, Hash, Debug)]
119pub struct Hunk {
120 pub header_hash: u64,
124 pub lines: Vec<DiffLine>,
126}
127
128#[derive(Default, Clone, Hash, Debug)]
130pub struct FileDiff {
131 pub hunks: Vec<Hunk>,
135 pub lines: usize,
137 pub untracked: bool,
139 pub sizes: (u64, u64),
141 pub size_delta: i64,
143}
144
145#[derive(
147 Debug, Hash, Clone, Copy, PartialEq, Eq, Serialize, Deserialize,
148)]
149pub struct DiffOptions {
150 pub ignore_whitespace: bool,
152 pub context: u32,
154 pub interhunk_lines: u32,
156}
157
158impl Default for DiffOptions {
159 fn default() -> Self {
160 Self {
161 ignore_whitespace: false,
162 context: 3,
163 interhunk_lines: 0,
164 }
165 }
166}
167
168pub(crate) fn get_diff_raw<'a>(
171 repo: &'a Repository,
172 p: &str,stage: bool,
174 reverse: bool,
175 options: Option<DiffOptions>,
176) -> Result<Diff<'a>> {
177 let mut opt = git2::DiffOptions::new();
180 if let Some(options) = options {
181 opt.context_lines(options.context);
182 opt.ignore_whitespace(options.ignore_whitespace);
183 opt.interhunk_lines(options.interhunk_lines);
184 }
185 opt.pathspec(p);
187 opt.reverse(reverse);
188
189 let diff = if stage {
190 if let Ok(id) = get_head_repo(repo) {
194 let parent = repo.find_commit(id.into())?;
195
196 let tree = parent.tree()?;
197 repo.diff_tree_to_index(
198 Some(&tree),
199 Some(&repo.index()?),
200 Some(&mut opt),
201 )?
202 } else {
203 repo.diff_tree_to_index(
204 None,
205 Some(&repo.index()?),
206 Some(&mut opt),
207 )?
208 }
209 } else {
210 opt.include_untracked(true);
211 opt.recurse_untracked_dirs(true);
212 repo.diff_index_to_workdir(None, Some(&mut opt))?
213 };
214
215 Ok(diff)
216}
217
218pub fn get_diff(
222 repo_path: &RepoPath,
223 p: &str,stage: bool,
225 options: Option<DiffOptions>,
226) -> Result<FileDiff> {
227 scope_time!("get_diff");
228
229 let repo = repo(repo_path)?;
230 let work_dir = work_dir(&repo)?;
231 let diff = get_diff_raw(&repo, p, stage, false, options)?;
235
236 raw_diff_to_file_diff(&diff, work_dir)
237}
238
239pub fn get_diff_commit(
244 repo_path: &RepoPath,
245 id: CommitId,
246 p: String,
247 options: Option<DiffOptions>,
248) -> Result<FileDiff> {
249 scope_time!("get_diff_commit");
250
251 let repo = repo(repo_path)?;
252 let work_dir = work_dir(&repo)?;
253 let diff = get_commit_diff(
255 &repo,
256 id,
257 Some(p),
258 options,
259 Some(&get_stashes(repo_path)?.into_iter().collect()),
260 )?;
261
262 raw_diff_to_file_diff(&diff, work_dir)
263}
264
265pub fn get_diff_commits(
268 repo_path: &RepoPath,
269 ids: OldNew<CommitId>,
270 p: String,
271 options: Option<DiffOptions>,
272) -> Result<FileDiff> {
273 scope_time!("get_diff_commits");
274
275 let repo = repo(repo_path)?;
276 let work_dir = work_dir(&repo)?;
277 let diff =
278 get_compare_commits_diff(&repo, ids, Some(p), options)?;
279
280 raw_diff_to_file_diff(&diff, work_dir)
281}
282
283#[allow(clippy::too_many_lines)]
287fn raw_diff_to_file_diff(
290 diff: &Diff,
291 work_dir: &Path,
292) -> Result<FileDiff> {
293 let res = Rc::new(RefCell::new(FileDiff::default()));
294 {
295 let mut current_lines = Vec::new();
296 let mut current_hunk: Option<HunkHeader> = None;
297
298 let res_cell = Rc::clone(&res);
299 let adder = move |header: &HunkHeader,
300 lines: &Vec<DiffLine>| {
301 let mut res = res_cell.borrow_mut();
302 res.hunks.push(Hunk {
303 header_hash: hash(header),
304 lines: lines.clone(),
305 });
306 res.lines += lines.len();
307 };
308
309 let res_cell = Rc::clone(&res);
310 let mut put = |delta: DiffDelta,
311 hunk: Option<DiffHunk>,
312 line: git2::DiffLine| {
313 {
314 let mut res = res_cell.borrow_mut();
315 res.sizes = (
316 delta.old_file().size(),
317 delta.new_file().size(),
318 );
319 res.size_delta = (i64::conv(res.sizes.1))
321 .saturating_sub(i64::conv(res.sizes.0));
322 }
323 if let Some(hunk) = hunk {
324 let hunk_header = HunkHeader::from(hunk);
325
326 match current_hunk {
327 None => current_hunk = Some(hunk_header),
328 Some(h) => {
329 if h != hunk_header {
330 adder(&h, ¤t_lines);
331 current_lines.clear();
332 current_hunk = Some(hunk_header);
333 }
334 }
335 }
336
337 let diff_line = DiffLine {
338 position: DiffLinePosition::from(&line),
339 content: String::from_utf8_lossy(line.content())
340 .trim_matches(is_newline)
343 .into(),
344 line_type: line.origin_value().into(),
345 };
346
347 current_lines.push(diff_line);
348 }
349 };
350
351 let new_file_diff = if diff.deltas().len() == 1 {
352 if let Some(delta) = diff.deltas().next() {
353 if delta.status() == Delta::Untracked {
354 let relative_path =
355 delta.new_file().path().ok_or_else(|| {
356 Error::Generic(
357 "new file path is unspecified."
358 .to_string(),
359 )
360 })?;
361
362 let newfile_path = work_dir.join(relative_path);
363
364 if let Some(newfile_content) =
365 new_file_content(&newfile_path)
366 {
367 let mut patch = Patch::from_buffers(
368 &[],
369 None,
370 newfile_content.as_slice(),
371 Some(&newfile_path),
372 None,
373 )?;
374
375 patch.print(
376 &mut |delta,
377 hunk: Option<DiffHunk>,
378 line: git2::DiffLine| {
379 put(delta, hunk, line);
380 true
381 },
382 )?;
383
384 true
385 } else {
386 false
387 }
388 } else {
389 false
390 }
391 } else {
392 false
393 }
394 } else {
395 false
397 };
398
399 if !new_file_diff {
400 diff.print(
401 DiffFormat::Patch,
402 move |delta, hunk, line: git2::DiffLine| {
403 put(delta, hunk, line);
404 true
405 },
406 )?;
407 }
408
409 if !current_lines.is_empty() {
410 adder(
411 ¤t_hunk.map_or_else(
412 || Err(Error::Generic("invalid hunk".to_owned())),
413 Ok,
414 )?,
415 ¤t_lines,
416 );
417 }
418
419 if new_file_diff {
420 res.borrow_mut().untracked = true;
421 }
422 }
423 let res = Rc::try_unwrap(res)
425 .map_err(|_| Error::Generic("rc unwrap error".to_owned()))?;
426 Ok(res.into_inner())
427}
428
429const fn is_newline(c: char) -> bool {
430 c == '\n' || c == '\r'
431}
432
433fn new_file_content(path: &Path) -> Option<Vec<u8>> {
434 if let Ok(meta) = fs::symlink_metadata(path) {
435 if meta.file_type().is_symlink() {
436 if let Ok(path) = fs::read_link(path) {
437 return Some(
438 path.to_str()?.to_string().as_bytes().into(),
439 );
440 }
441 } else if !meta.file_type().is_dir() {
442 if let Ok(content) = fs::read(path) {
443 return Some(content);
444 }
445 }
446 }
447
448 None
449}
450
451#[cfg(test)]
452mod tests {
453 use std::{
454 fs::{self, File},
455 io::Write,
456 path::Path,
457 };
458
459 use super::{get_diff, get_diff_commit};
460 use crate::{
461 error::Result,
462 sync::{
463 RepoPath, commit, stage_add_file,
464 status::{StatusType, get_status},
465 tests::{get_statuses, repo_init, repo_init_empty},
466 },
467 };
468
469 #[test]
470 fn test_untracked_subfolder() {
471 let (_td, repo) = repo_init().unwrap();
472 let root = repo.path().parent().unwrap();
473 let repo_path: &RepoPath =
474 &root.as_os_str().to_str().unwrap().into();
475
476 assert_eq!(get_statuses(repo_path), (0, 0));
477
478 fs::create_dir(root.join("foo")).unwrap();
479 File::create(root.join("foo/bar.txt"))
480 .unwrap()
481 .write_all(b"test\nfoo")
482 .unwrap();
483
484 assert_eq!(get_statuses(repo_path), (1, 0));
485
486 let diff =
487 get_diff(repo_path, "foo/bar.txt", false, None).unwrap();
488
489 assert_eq!(diff.hunks.len(), 1);
490 assert_eq!(&*diff.hunks[0].lines[1].content, "test");
491 }
492
493 #[test]
494 fn test_empty_repo() {
495 let file_path = Path::new("foo.txt");
496 let (_td, repo) = repo_init_empty().unwrap();
497 let root = repo.path().parent().unwrap();
498 let repo_path: &RepoPath =
499 &root.as_os_str().to_str().unwrap().into();
500
501 assert_eq!(get_statuses(repo_path), (0, 0));
502
503 File::create(root.join(file_path))
504 .unwrap()
505 .write_all(b"test\nfoo")
506 .unwrap();
507
508 assert_eq!(get_statuses(repo_path), (1, 0));
509
510 stage_add_file(repo_path, file_path).unwrap();
511
512 assert_eq!(get_statuses(repo_path), (0, 1));
513
514 let diff = get_diff(
515 repo_path,
516 file_path.to_str().unwrap(),
517 true,
518 None,
519 )
520 .unwrap();
521
522 assert_eq!(diff.hunks.len(), 1);
523 }
524
525 static HUNK_A: &str = r"
5261 start
5272
5283
5294
5305
5316 middle
5327
5338
5349
5350
5361 end";
537
538 static HUNK_B: &str = r"
5391 start
5402 newa
5413
5424
5435
5446 middle
5457
5468
5479
5480 newb
5491 end";
550
551 #[test]
552 fn test_hunks() {
553 let (_td, repo) = repo_init().unwrap();
554 let root = repo.path().parent().unwrap();
555 let repo_path: &RepoPath =
556 &root.as_os_str().to_str().unwrap().into();
557
558 assert_eq!(get_statuses(repo_path), (0, 0));
559
560 let file_path = root.join("bar.txt");
561
562 {
563 File::create(&file_path)
564 .unwrap()
565 .write_all(HUNK_A.as_bytes())
566 .unwrap();
567 }
568
569 let res = get_status(repo_path, StatusType::WorkingDir, None)
570 .unwrap();
571 assert_eq!(res.len(), 1);
572 assert_eq!(res[0].path, "bar.txt");
573
574 stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
575 assert_eq!(get_statuses(repo_path), (0, 1));
576
577 {
579 File::create(&file_path)
580 .unwrap()
581 .write_all(HUNK_B.as_bytes())
582 .unwrap();
583 }
584
585 assert_eq!(get_statuses(repo_path), (1, 1));
586
587 let res =
588 get_diff(repo_path, "bar.txt", false, None).unwrap();
589
590 assert_eq!(res.hunks.len(), 2)
591 }
592
593 #[test]
594 fn test_diff_newfile_in_sub_dir_current_dir() {
595 let file_path = Path::new("foo/foo.txt");
596 let (_td, repo) = repo_init_empty().unwrap();
597 let root = repo.path().parent().unwrap();
598
599 let sub_path = root.join("foo/");
600
601 fs::create_dir_all(&sub_path).unwrap();
602 File::create(root.join(file_path))
603 .unwrap()
604 .write_all(b"test")
605 .unwrap();
606
607 let diff = get_diff(
608 &sub_path.to_str().unwrap().into(),
609 file_path.to_str().unwrap(),
610 false,
611 None,
612 )
613 .unwrap();
614
615 assert_eq!(&*diff.hunks[0].lines[1].content, "test");
616 }
617
618 #[test]
619 fn test_diff_delta_size() -> Result<()> {
620 let file_path = Path::new("bar");
621 let (_td, repo) = repo_init_empty().unwrap();
622 let root = repo.path().parent().unwrap();
623 let repo_path: &RepoPath =
624 &root.as_os_str().to_str().unwrap().into();
625
626 File::create(root.join(file_path))?.write_all(b"\x00")?;
627
628 stage_add_file(repo_path, file_path).unwrap();
629
630 commit(repo_path, "commit").unwrap();
631
632 File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
633
634 let diff = get_diff(
635 repo_path,
636 file_path.to_str().unwrap(),
637 false,
638 None,
639 )
640 .unwrap();
641
642 dbg!(&diff);
643 assert_eq!(diff.sizes, (1, 2));
644 assert_eq!(diff.size_delta, 1);
645
646 Ok(())
647 }
648
649 #[test]
650 fn test_binary_diff_delta_size_untracked() -> Result<()> {
651 let file_path = Path::new("bar");
652 let (_td, repo) = repo_init_empty().unwrap();
653 let root = repo.path().parent().unwrap();
654 let repo_path: &RepoPath =
655 &root.as_os_str().to_str().unwrap().into();
656
657 File::create(root.join(file_path))?.write_all(b"\x00\xc7")?;
658
659 let diff = get_diff(
660 repo_path,
661 file_path.to_str().unwrap(),
662 false,
663 None,
664 )
665 .unwrap();
666
667 dbg!(&diff);
668 assert_eq!(diff.sizes, (0, 2));
669 assert_eq!(diff.size_delta, 2);
670
671 Ok(())
672 }
673
674 #[test]
675 fn test_diff_delta_size_commit() -> Result<()> {
676 let file_path = Path::new("bar");
677 let (_td, repo) = repo_init_empty().unwrap();
678 let root = repo.path().parent().unwrap();
679 let repo_path: &RepoPath =
680 &root.as_os_str().to_str().unwrap().into();
681
682 File::create(root.join(file_path))?.write_all(b"\x00")?;
683
684 stage_add_file(repo_path, file_path).unwrap();
685
686 commit(repo_path, "").unwrap();
687
688 File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
689
690 stage_add_file(repo_path, file_path).unwrap();
691
692 let id = commit(repo_path, "").unwrap();
693
694 let diff =
695 get_diff_commit(repo_path, id, String::new(), None)
696 .unwrap();
697
698 dbg!(&diff);
699 assert_eq!(diff.sizes, (1, 2));
700 assert_eq!(diff.size_delta, 1);
701
702 Ok(())
703 }
704}