1 //! sync git api for fetching a diff
2 
3 use super::{
4     commit_files::get_commit_diff,
5     utils::{self, get_head_repo, work_dir},
6     CommitId,
7 };
8 use crate::{error::Error, error::Result, hash};
9 use git2::{
10     Delta, Diff, DiffDelta, DiffFormat, DiffHunk, DiffOptions, Patch,
11     Repository,
12 };
13 use scopetime::scope_time;
14 use std::{cell::RefCell, fs, path::Path, rc::Rc};
15 
16 /// type of diff of a single line
17 #[derive(Copy, Clone, PartialEq, Hash, Debug)]
18 pub enum DiffLineType {
19     /// just surrounding line, no change
20     None,
21     /// header of the hunk
22     Header,
23     /// line added
24     Add,
25     /// line deleted
26     Delete,
27 }
28 
29 impl Default for DiffLineType {
default() -> Self30     fn default() -> Self {
31         DiffLineType::None
32     }
33 }
34 
35 ///
36 #[derive(Default, Clone, Hash, Debug)]
37 pub struct DiffLine {
38     ///
39     pub content: String,
40     ///
41     pub line_type: DiffLineType,
42 }
43 
44 #[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
45 pub(crate) struct HunkHeader {
46     old_start: u32,
47     old_lines: u32,
48     new_start: u32,
49     new_lines: u32,
50 }
51 
52 impl From<DiffHunk<'_>> for HunkHeader {
from(h: DiffHunk) -> Self53     fn from(h: DiffHunk) -> Self {
54         Self {
55             old_start: h.old_start(),
56             old_lines: h.old_lines(),
57             new_start: h.new_start(),
58             new_lines: h.new_lines(),
59         }
60     }
61 }
62 
63 /// single diff hunk
64 #[derive(Default, Clone, Hash, Debug)]
65 pub struct Hunk {
66     /// hash of the hunk header
67     pub header_hash: u64,
68     /// list of `DiffLine`s
69     pub lines: Vec<DiffLine>,
70 }
71 
72 /// collection of hunks, sum of all diff lines
73 #[derive(Default, Clone, Hash, Debug)]
74 pub struct FileDiff {
75     /// list of hunks
76     pub hunks: Vec<Hunk>,
77     /// lines total summed up over hunks
78     pub lines: usize,
79     ///
80     pub untracked: bool,
81     /// old and new file size in bytes
82     pub sizes: (u64, u64),
83     /// size delta in bytes
84     pub size_delta: i64,
85 }
86 
get_diff_raw<'a>( repo: &'a Repository, p: &str, stage: bool, reverse: bool, ) -> Result<Diff<'a>>87 pub(crate) fn get_diff_raw<'a>(
88     repo: &'a Repository,
89     p: &str,
90     stage: bool,
91     reverse: bool,
92 ) -> Result<Diff<'a>> {
93     // scope_time!("get_diff_raw");
94 
95     let mut opt = DiffOptions::new();
96     opt.pathspec(p);
97     opt.reverse(reverse);
98 
99     let diff = if stage {
100         // diff against head
101         if let Ok(id) = get_head_repo(&repo) {
102             let parent = repo.find_commit(id.into())?;
103 
104             let tree = parent.tree()?;
105             repo.diff_tree_to_index(
106                 Some(&tree),
107                 Some(&repo.index()?),
108                 Some(&mut opt),
109             )?
110         } else {
111             repo.diff_tree_to_index(
112                 None,
113                 Some(&repo.index()?),
114                 Some(&mut opt),
115             )?
116         }
117     } else {
118         opt.include_untracked(true);
119         opt.recurse_untracked_dirs(true);
120         repo.diff_index_to_workdir(None, Some(&mut opt))?
121     };
122 
123     Ok(diff)
124 }
125 
126 /// returns diff of a specific file either in `stage` or workdir
get_diff( repo_path: &str, p: String, stage: bool, ) -> Result<FileDiff>127 pub fn get_diff(
128     repo_path: &str,
129     p: String,
130     stage: bool,
131 ) -> Result<FileDiff> {
132     scope_time!("get_diff");
133 
134     let repo = utils::repo(repo_path)?;
135     let work_dir = work_dir(&repo);
136     let diff = get_diff_raw(&repo, &p, stage, false)?;
137 
138     raw_diff_to_file_diff(&diff, work_dir)
139 }
140 
141 /// returns diff of a specific file inside a commit
142 /// see `get_commit_diff`
get_diff_commit( repo_path: &str, id: CommitId, p: String, ) -> Result<FileDiff>143 pub fn get_diff_commit(
144     repo_path: &str,
145     id: CommitId,
146     p: String,
147 ) -> Result<FileDiff> {
148     scope_time!("get_diff_commit");
149 
150     let repo = utils::repo(repo_path)?;
151     let work_dir = work_dir(&repo);
152     let diff = get_commit_diff(&repo, id, Some(p))?;
153 
154     raw_diff_to_file_diff(&diff, work_dir)
155 }
156 
157 ///
raw_diff_to_file_diff<'a>( diff: &'a Diff, work_dir: &Path, ) -> Result<FileDiff>158 fn raw_diff_to_file_diff<'a>(
159     diff: &'a Diff,
160     work_dir: &Path,
161 ) -> Result<FileDiff> {
162     let res = Rc::new(RefCell::new(FileDiff::default()));
163     {
164         let mut current_lines = Vec::new();
165         let mut current_hunk: Option<HunkHeader> = None;
166 
167         let res_cell = Rc::clone(&res);
168         let adder = move |header: &HunkHeader,
169                           lines: &Vec<DiffLine>| {
170             let mut res = res_cell.borrow_mut();
171             res.hunks.push(Hunk {
172                 header_hash: hash(header),
173                 lines: lines.clone(),
174             });
175             res.lines += lines.len();
176         };
177 
178         let res_cell = Rc::clone(&res);
179         let mut put = |delta: DiffDelta,
180                        hunk: Option<DiffHunk>,
181                        line: git2::DiffLine| {
182             {
183                 let mut res = res_cell.borrow_mut();
184                 res.sizes = (
185                     delta.old_file().size(),
186                     delta.new_file().size(),
187                 );
188                 res.size_delta = (res.sizes.1 as i64)
189                     .saturating_sub(res.sizes.0 as i64);
190             }
191             if let Some(hunk) = hunk {
192                 let hunk_header = HunkHeader::from(hunk);
193 
194                 match current_hunk {
195                     None => current_hunk = Some(hunk_header),
196                     Some(h) if h != hunk_header => {
197                         adder(&h, &current_lines);
198                         current_lines.clear();
199                         current_hunk = Some(hunk_header)
200                     }
201                     _ => (),
202                 }
203 
204                 let line_type = match line.origin() {
205                     'H' => DiffLineType::Header,
206                     '<' | '-' => DiffLineType::Delete,
207                     '>' | '+' => DiffLineType::Add,
208                     _ => DiffLineType::None,
209                 };
210 
211                 let diff_line = DiffLine {
212                     content: String::from_utf8_lossy(line.content())
213                         .to_string(),
214                     line_type,
215                 };
216 
217                 current_lines.push(diff_line);
218             }
219         };
220 
221         let new_file_diff = if diff.deltas().len() == 1 {
222             let delta: DiffDelta = diff
223                 .deltas()
224                 .next()
225                 .expect("it's safe to unwrap here because we check first that diff.deltas has a single element");
226 
227             if delta.status() == Delta::Untracked {
228                 let relative_path =
229                     delta.new_file().path().ok_or_else(|| {
230                         Error::Generic(
231                             "new file path is unspecified."
232                                 .to_string(),
233                         )
234                     })?;
235 
236                 let newfile_path = work_dir.join(relative_path);
237 
238                 if let Some(newfile_content) =
239                     new_file_content(&newfile_path)
240                 {
241                     let mut patch = Patch::from_buffers(
242                         &[],
243                         None,
244                         newfile_content.as_slice(),
245                         Some(&newfile_path),
246                         None,
247                     )?;
248 
249                     patch
250                     .print(&mut |delta, hunk:Option<DiffHunk>, line: git2::DiffLine| {
251                         put(delta,hunk,line);
252                         true
253                     })?;
254 
255                     true
256                 } else {
257                     false
258                 }
259             } else {
260                 false
261             }
262         } else {
263             false
264         };
265 
266         if !new_file_diff {
267             diff.print(
268                 DiffFormat::Patch,
269                 move |delta, hunk, line: git2::DiffLine| {
270                     put(delta, hunk, line);
271                     true
272                 },
273             )?;
274         }
275 
276         if !current_lines.is_empty() {
277             adder(
278                 &current_hunk.expect("invalid hunk"),
279                 &current_lines,
280             );
281         }
282 
283         if new_file_diff {
284             res.borrow_mut().untracked = true;
285         }
286     }
287     let res = Rc::try_unwrap(res).expect("rc error");
288     Ok(res.into_inner())
289 }
290 
new_file_content(path: &Path) -> Option<Vec<u8>>291 fn new_file_content(path: &Path) -> Option<Vec<u8>> {
292     if let Ok(meta) = fs::symlink_metadata(path) {
293         if meta.file_type().is_symlink() {
294             if let Ok(path) = fs::read_link(path) {
295                 return Some(
296                     path.to_str()?.to_string().as_bytes().into(),
297                 );
298             }
299         } else if meta.file_type().is_file() {
300             if let Ok(content) = fs::read(path) {
301                 return Some(content);
302             }
303         }
304     }
305 
306     None
307 }
308 
309 #[cfg(test)]
310 mod tests {
311     use super::{get_diff, get_diff_commit};
312     use crate::error::Result;
313     use crate::sync::{
314         commit, stage_add_file,
315         status::{get_status, StatusType},
316         tests::{get_statuses, repo_init, repo_init_empty},
317     };
318     use std::{
319         fs::{self, File},
320         io::Write,
321         path::Path,
322     };
323 
324     #[test]
test_untracked_subfolder()325     fn test_untracked_subfolder() {
326         let (_td, repo) = repo_init().unwrap();
327         let root = repo.path().parent().unwrap();
328         let repo_path = root.as_os_str().to_str().unwrap();
329 
330         assert_eq!(get_statuses(repo_path), (0, 0));
331 
332         fs::create_dir(&root.join("foo")).unwrap();
333         File::create(&root.join("foo/bar.txt"))
334             .unwrap()
335             .write_all(b"test\nfoo")
336             .unwrap();
337 
338         assert_eq!(get_statuses(repo_path), (1, 0));
339 
340         let diff =
341             get_diff(repo_path, "foo/bar.txt".to_string(), false)
342                 .unwrap();
343 
344         assert_eq!(diff.hunks.len(), 1);
345         assert_eq!(diff.hunks[0].lines[1].content, "test\n");
346     }
347 
348     #[test]
test_empty_repo()349     fn test_empty_repo() {
350         let file_path = Path::new("foo.txt");
351         let (_td, repo) = repo_init_empty().unwrap();
352         let root = repo.path().parent().unwrap();
353         let repo_path = root.as_os_str().to_str().unwrap();
354 
355         assert_eq!(get_statuses(repo_path), (0, 0));
356 
357         File::create(&root.join(file_path))
358             .unwrap()
359             .write_all(b"test\nfoo")
360             .unwrap();
361 
362         assert_eq!(get_statuses(repo_path), (1, 0));
363 
364         stage_add_file(repo_path, file_path).unwrap();
365 
366         assert_eq!(get_statuses(repo_path), (0, 1));
367 
368         let diff = get_diff(
369             repo_path,
370             String::from(file_path.to_str().unwrap()),
371             true,
372         )
373         .unwrap();
374 
375         assert_eq!(diff.hunks.len(), 1);
376     }
377 
378     static HUNK_A: &str = r"
379 1   start
380 2
381 3
382 4
383 5
384 6   middle
385 7
386 8
387 9
388 0
389 1   end";
390 
391     static HUNK_B: &str = r"
392 1   start
393 2   newa
394 3
395 4
396 5
397 6   middle
398 7
399 8
400 9
401 0   newb
402 1   end";
403 
404     #[test]
test_hunks()405     fn test_hunks() {
406         let (_td, repo) = repo_init().unwrap();
407         let root = repo.path().parent().unwrap();
408         let repo_path = root.as_os_str().to_str().unwrap();
409 
410         assert_eq!(get_statuses(repo_path), (0, 0));
411 
412         let file_path = root.join("bar.txt");
413 
414         {
415             File::create(&file_path)
416                 .unwrap()
417                 .write_all(HUNK_A.as_bytes())
418                 .unwrap();
419         }
420 
421         let res = get_status(repo_path, StatusType::WorkingDir, true)
422             .unwrap();
423         assert_eq!(res.len(), 1);
424         assert_eq!(res[0].path, "bar.txt");
425 
426         stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
427         assert_eq!(get_statuses(repo_path), (0, 1));
428 
429         // overwrite with next content
430         {
431             File::create(&file_path)
432                 .unwrap()
433                 .write_all(HUNK_B.as_bytes())
434                 .unwrap();
435         }
436 
437         assert_eq!(get_statuses(repo_path), (1, 1));
438 
439         let res = get_diff(repo_path, "bar.txt".to_string(), false)
440             .unwrap();
441 
442         assert_eq!(res.hunks.len(), 2)
443     }
444 
445     #[test]
test_diff_newfile_in_sub_dir_current_dir()446     fn test_diff_newfile_in_sub_dir_current_dir() {
447         let file_path = Path::new("foo/foo.txt");
448         let (_td, repo) = repo_init_empty().unwrap();
449         let root = repo.path().parent().unwrap();
450 
451         let sub_path = root.join("foo/");
452 
453         fs::create_dir_all(&sub_path).unwrap();
454         File::create(&root.join(file_path))
455             .unwrap()
456             .write_all(b"test")
457             .unwrap();
458 
459         let diff = get_diff(
460             sub_path.to_str().unwrap(),
461             String::from(file_path.to_str().unwrap()),
462             false,
463         )
464         .unwrap();
465 
466         assert_eq!(diff.hunks[0].lines[1].content, "test");
467     }
468 
469     #[test]
test_diff_delta_size() -> Result<()>470     fn test_diff_delta_size() -> Result<()> {
471         let file_path = Path::new("bar");
472         let (_td, repo) = repo_init_empty().unwrap();
473         let root = repo.path().parent().unwrap();
474         let repo_path = root.as_os_str().to_str().unwrap();
475 
476         File::create(&root.join(file_path))?.write_all(b"\x00")?;
477 
478         stage_add_file(repo_path, file_path).unwrap();
479 
480         commit(repo_path, "commit").unwrap();
481 
482         File::create(&root.join(file_path))?
483             .write_all(b"\x00\x02")?;
484 
485         let diff = get_diff(
486             repo_path,
487             String::from(file_path.to_str().unwrap()),
488             false,
489         )
490         .unwrap();
491 
492         dbg!(&diff);
493         assert_eq!(diff.sizes, (1, 2));
494         assert_eq!(diff.size_delta, 1);
495 
496         Ok(())
497     }
498 
499     #[test]
test_binary_diff_delta_size_untracked() -> Result<()>500     fn test_binary_diff_delta_size_untracked() -> Result<()> {
501         let file_path = Path::new("bar");
502         let (_td, repo) = repo_init_empty().unwrap();
503         let root = repo.path().parent().unwrap();
504         let repo_path = root.as_os_str().to_str().unwrap();
505 
506         File::create(&root.join(file_path))?
507             .write_all(b"\x00\xc7")?;
508 
509         let diff = get_diff(
510             repo_path,
511             String::from(file_path.to_str().unwrap()),
512             false,
513         )
514         .unwrap();
515 
516         dbg!(&diff);
517         assert_eq!(diff.sizes, (0, 2));
518         assert_eq!(diff.size_delta, 2);
519 
520         Ok(())
521     }
522 
523     #[test]
test_diff_delta_size_commit() -> Result<()>524     fn test_diff_delta_size_commit() -> Result<()> {
525         let file_path = Path::new("bar");
526         let (_td, repo) = repo_init_empty().unwrap();
527         let root = repo.path().parent().unwrap();
528         let repo_path = root.as_os_str().to_str().unwrap();
529 
530         File::create(&root.join(file_path))?.write_all(b"\x00")?;
531 
532         stage_add_file(repo_path, file_path).unwrap();
533 
534         commit(repo_path, "").unwrap();
535 
536         File::create(&root.join(file_path))?
537             .write_all(b"\x00\x02")?;
538 
539         stage_add_file(repo_path, file_path).unwrap();
540 
541         let id = commit(repo_path, "").unwrap();
542 
543         let diff =
544             get_diff_commit(repo_path, id, String::new()).unwrap();
545 
546         dbg!(&diff);
547         assert_eq!(diff.sizes, (1, 2));
548         assert_eq!(diff.size_delta, 1);
549 
550         Ok(())
551     }
552 }
553