gnostr_asyncgit/sync/
diff.rs

1//! sync git api for fetching a diff
2
3use std::{cell::RefCell, fs, path::Path, rc::Rc};
4
5use easy_cast::Conv;
6use git2::{Delta, Diff, DiffDelta, DiffFormat, DiffHunk, Patch, Repository};
7use scopetime::scope_time;
8use serde::{Deserialize, Serialize};
9
10use super::{
11    commit_files::{get_commit_diff, get_compare_commits_diff, OldNew},
12    utils::{get_head_repo, work_dir},
13    CommitId, RepoPath,
14};
15use crate::{
16    error::{Error, Result},
17    hash,
18    sync::{get_stashes, repository::repo},
19};
20
21/// type of diff of a single line
22#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
23pub enum DiffLineType {
24    /// just surrounding line, no change
25    None,
26    /// header of the hunk
27    /// header of the hunk
28    /// header of the hunk
29    Header,
30    /// line added
31    Add,
32    /// line deleted
33    Delete,
34}
35
36impl From<git2::DiffLineType> for DiffLineType {
37    fn from(line_type: git2::DiffLineType) -> Self {
38        match line_type {
39            //git2::DiffLineType::HunkHeader => Self::Header,
40            git2::DiffLineType::HunkHeader => Self::Header,
41            git2::DiffLineType::DeleteEOFNL | git2::DiffLineType::Deletion => Self::Delete,
42            git2::DiffLineType::AddEOFNL | git2::DiffLineType::Addition => Self::Add,
43            _ => Self::None,
44        }
45    }
46}
47
48impl Default for DiffLineType {
49    fn default() -> Self {
50        Self::None
51    }
52}
53
54///DiffLine
55///DiffLine
56///DiffLine
57#[derive(Default, Clone, Hash, Debug)]
58pub struct DiffLine {
59    ///
60    pub content: Box<str>,
61    ///
62    pub line_type: DiffLineType,
63    ///
64    pub position: DiffLinePosition,
65}
66
67///
68#[derive(Clone, Copy, Default, Hash, Debug, PartialEq, Eq)]
69pub struct DiffLinePosition {
70    ///
71    pub old_lineno: Option<u32>,
72    ///
73    pub new_lineno: Option<u32>,
74}
75
76impl PartialEq<&git2::DiffLine<'_>> for DiffLinePosition {
77    fn eq(&self, other: &&git2::DiffLine) -> bool {
78        other.new_lineno() == self.new_lineno && other.old_lineno() == self.old_lineno
79    }
80}
81
82impl From<&git2::DiffLine<'_>> for DiffLinePosition {
83    fn from(line: &git2::DiffLine<'_>) -> Self {
84        Self {
85            old_lineno: line.old_lineno(),
86            new_lineno: line.new_lineno(),
87        }
88    }
89}
90
91#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
92pub(crate) struct HunkHeader {
93    pub old_start: u32,
94    pub old_lines: u32,
95    pub new_start: u32,
96    pub new_lines: u32,
97}
98
99impl From<DiffHunk<'_>> for HunkHeader {
100    fn from(h: DiffHunk) -> Self {
101        Self {
102            old_start: h.old_start(),
103            old_lines: h.old_lines(),
104            new_start: h.new_start(),
105            new_lines: h.new_lines(),
106        }
107    }
108}
109
110/// single diff hunk
111#[derive(Default, Clone, Hash, Debug)]
112pub struct Hunk {
113    /// hash of the hunk header
114    /// hash of the hunk header
115    /// hash of the hunk header
116    pub header_hash: u64,
117    /// list of `DiffLine`s
118    pub lines: Vec<DiffLine>,
119}
120
121/// collection of hunks, sum of all diff lines
122#[derive(Default, Clone, Hash, Debug)]
123pub struct FileDiff {
124    /// list of hunks
125    /// list of hunks
126    /// list of hunks
127    pub hunks: Vec<Hunk>,
128    /// lines total summed up over hunks
129    pub lines: usize,
130    ///
131    pub untracked: bool,
132    /// old and new file size in bytes
133    pub sizes: (u64, u64),
134    /// size delta in bytes
135    pub size_delta: i64,
136}
137
138/// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
139#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub struct DiffOptions {
141    /// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
142    pub ignore_whitespace: bool,
143    /// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
144    pub context: u32,
145    /// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
146    pub interhunk_lines: u32,
147}
148
149impl Default for DiffOptions {
150    fn default() -> Self {
151        Self {
152            ignore_whitespace: false,
153            context: 3,
154            interhunk_lines: 0,
155        }
156    }
157}
158
159///get_diff_raw
160///make public for event rendering?
161pub(crate) fn get_diff_raw<'a>(
162    repo: &'a Repository,
163    p: &str, //path
164    stage: bool,
165    reverse: bool,
166    options: Option<DiffOptions>,
167) -> Result<Diff<'a>> {
168    // scope_time!("get_diff_raw");
169
170    let mut opt = git2::DiffOptions::new();
171    if let Some(options) = options {
172        opt.context_lines(options.context);
173        opt.ignore_whitespace(options.ignore_whitespace);
174        opt.interhunk_lines(options.interhunk_lines);
175    }
176    //opt.pathspec(p);
177    opt.pathspec(p);
178    opt.reverse(reverse);
179
180    let diff = if stage {
181        // diff against head
182        // diff against head
183        // diff against head
184        if let Ok(id) = get_head_repo(repo) {
185            let parent = repo.find_commit(id.into())?;
186
187            let tree = parent.tree()?;
188            repo.diff_tree_to_index(Some(&tree), Some(&repo.index()?), Some(&mut opt))?
189        } else {
190            repo.diff_tree_to_index(None, Some(&repo.index()?), Some(&mut opt))?
191        }
192    } else {
193        opt.include_untracked(true);
194        opt.recurse_untracked_dirs(true);
195        repo.diff_index_to_workdir(None, Some(&mut opt))?
196    };
197
198    Ok(diff)
199}
200
201/// returns diff of a specific file either in `stage` or workdir
202/// returns diff of a specific file either in `stage` or workdir
203/// returns diff of a specific file either in `stage` or workdir
204pub fn get_diff(
205    repo_path: &RepoPath,
206    p: &str, //path
207    stage: bool,
208    options: Option<DiffOptions>,
209) -> Result<FileDiff> {
210    scope_time!("get_diff");
211
212    let repo = repo(repo_path)?;
213    let work_dir = work_dir(&repo)?;
214    //get_diff_raw
215    //get_diff_raw
216    //get_diff_raw
217    let diff = get_diff_raw(&repo, p, stage, false, options)?;
218
219    raw_diff_to_file_diff(&diff, work_dir)
220}
221
222/// returns diff of a specific file inside a commit
223/// returns diff of a specific file inside a commit
224/// returns diff of a specific file inside a commit
225/// see `get_commit_diff`
226pub fn get_diff_commit(
227    repo_path: &RepoPath,
228    id: CommitId,
229    p: String,
230    options: Option<DiffOptions>,
231) -> Result<FileDiff> {
232    scope_time!("get_diff_commit");
233
234    let repo = repo(repo_path)?;
235    let work_dir = work_dir(&repo)?;
236    //get_commit_diff
237    let diff = get_commit_diff(
238        &repo,
239        id,
240        Some(p),
241        options,
242        Some(&get_stashes(repo_path)?.into_iter().collect()),
243    )?;
244
245    raw_diff_to_file_diff(&diff, work_dir)
246}
247
248///for compare
249/// get file changes of a diff between two commits
250pub fn get_diff_commits(
251    repo_path: &RepoPath,
252    ids: OldNew<CommitId>,
253    p: String,
254    options: Option<DiffOptions>,
255) -> Result<FileDiff> {
256    scope_time!("get_diff_commits");
257
258    let repo = repo(repo_path)?;
259    let work_dir = work_dir(&repo)?;
260    let diff = get_compare_commits_diff(&repo, ids, Some(p), options)?;
261
262    raw_diff_to_file_diff(&diff, work_dir)
263}
264
265///
266//TODO: refactor into helper type with the inline closures as
267// dedicated functions
268#[allow(clippy::too_many_lines)]
269//fn raw_diff_to_file_diff(
270//fn raw_diff_to_file_diff(
271fn raw_diff_to_file_diff(diff: &Diff, work_dir: &Path) -> Result<FileDiff> {
272    let res = Rc::new(RefCell::new(FileDiff::default()));
273    {
274        let mut current_lines = Vec::new();
275        let mut current_hunk: Option<HunkHeader> = None;
276
277        let res_cell = Rc::clone(&res);
278        let adder = move |header: &HunkHeader, lines: &Vec<DiffLine>| {
279            let mut res = res_cell.borrow_mut();
280            res.hunks.push(Hunk {
281                header_hash: hash(header),
282                lines: lines.clone(),
283            });
284            res.lines += lines.len();
285        };
286
287        let res_cell = Rc::clone(&res);
288        let mut put = |delta: DiffDelta, hunk: Option<DiffHunk>, line: git2::DiffLine| {
289            {
290                let mut res = res_cell.borrow_mut();
291                res.sizes = (delta.old_file().size(), delta.new_file().size());
292                //TODO: use try_conv
293                res.size_delta = (i64::conv(res.sizes.1)).saturating_sub(i64::conv(res.sizes.0));
294            }
295            if let Some(hunk) = hunk {
296                let hunk_header = HunkHeader::from(hunk);
297
298                match current_hunk {
299                    None => current_hunk = Some(hunk_header),
300                    Some(h) => {
301                        if h != hunk_header {
302                            adder(&h, &current_lines);
303                            current_lines.clear();
304                            current_hunk = Some(hunk_header);
305                        }
306                    }
307                }
308
309                let diff_line = DiffLine {
310                    position: DiffLinePosition::from(&line),
311                    content: String::from_utf8_lossy(line.content())
312                        //Note: trim await trailing newline
313                        // characters
314                        .trim_matches(is_newline)
315                        .into(),
316                    line_type: line.origin_value().into(),
317                };
318
319                current_lines.push(diff_line);
320            }
321        };
322
323        let new_file_diff = if diff.deltas().len() == 1 {
324            if let Some(delta) = diff.deltas().next() {
325                if delta.status() == Delta::Untracked {
326                    let relative_path = delta.new_file().path().ok_or_else(|| {
327                        Error::Generic("new file path is unspecified.".to_string())
328                    })?;
329
330                    let newfile_path = work_dir.join(relative_path);
331
332                    if let Some(newfile_content) = new_file_content(&newfile_path) {
333                        let mut patch = Patch::from_buffers(
334                            &[],
335                            None,
336                            newfile_content.as_slice(),
337                            Some(&newfile_path),
338                            None,
339                        )?;
340
341                        patch.print(
342                            &mut |delta, hunk: Option<DiffHunk>, line: git2::DiffLine| {
343                                put(delta, hunk, line);
344                                true
345                            },
346                        )?;
347
348                        true
349                    } else {
350                        false
351                    }
352                } else {
353                    false
354                }
355            } else {
356                false
357            }
358        } else {
359            //false
360            false
361        };
362
363        if !new_file_diff {
364            diff.print(
365                DiffFormat::Patch,
366                move |delta, hunk, line: git2::DiffLine| {
367                    put(delta, hunk, line);
368                    true
369                },
370            )?;
371        }
372
373        if !current_lines.is_empty() {
374            adder(
375                &current_hunk.map_or_else(|| Err(Error::Generic("invalid hunk".to_owned())), Ok)?,
376                &current_lines,
377            );
378        }
379
380        if new_file_diff {
381            res.borrow_mut().untracked = true;
382        }
383    }
384    //
385    let res = Rc::try_unwrap(res).map_err(|_| Error::Generic("rc unwrap error".to_owned()))?;
386    Ok(res.into_inner())
387}
388
389const fn is_newline(c: char) -> bool {
390    c == '\n' || c == '\r'
391}
392
393fn new_file_content(path: &Path) -> Option<Vec<u8>> {
394    if let Ok(meta) = fs::symlink_metadata(path) {
395        if meta.file_type().is_symlink() {
396            if let Ok(path) = fs::read_link(path) {
397                return Some(path.to_str()?.to_string().as_bytes().into());
398            }
399        } else if !meta.file_type().is_dir() {
400            if let Ok(content) = fs::read(path) {
401                return Some(content);
402            }
403        }
404    }
405
406    None
407}
408
409#[cfg(test)]
410mod tests {
411    use std::{
412        fs::{self, File},
413        io::Write,
414        path::Path,
415    };
416
417    use super::{get_diff, get_diff_commit};
418    use crate::{
419        error::Result,
420        sync::{
421            commit, stage_add_file,
422            status::{get_status, StatusType},
423            tests::{get_statuses, repo_init, repo_init_empty},
424            RepoPath,
425        },
426    };
427
428    #[test]
429    fn test_untracked_subfolder() {
430        let (_td, repo) = repo_init().unwrap();
431        let root = repo.path().parent().unwrap();
432        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
433
434        assert_eq!(get_statuses(repo_path), (0, 0));
435
436        fs::create_dir(root.join("foo")).unwrap();
437        File::create(root.join("foo/bar.txt"))
438            .unwrap()
439            .write_all(b"test\nfoo")
440            .unwrap();
441
442        assert_eq!(get_statuses(repo_path), (1, 0));
443
444        let diff = get_diff(repo_path, "foo/bar.txt", false, None).unwrap();
445
446        assert_eq!(diff.hunks.len(), 1);
447        assert_eq!(&*diff.hunks[0].lines[1].content, "test");
448    }
449
450    #[test]
451    fn test_empty_repo() {
452        let file_path = Path::new("foo.txt");
453        let (_td, repo) = repo_init_empty().unwrap();
454        let root = repo.path().parent().unwrap();
455        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
456
457        assert_eq!(get_statuses(repo_path), (0, 0));
458
459        File::create(root.join(file_path))
460            .unwrap()
461            .write_all(b"test\nfoo")
462            .unwrap();
463
464        assert_eq!(get_statuses(repo_path), (1, 0));
465
466        stage_add_file(repo_path, file_path).unwrap();
467
468        assert_eq!(get_statuses(repo_path), (0, 1));
469
470        let diff = get_diff(repo_path, file_path.to_str().unwrap(), true, None).unwrap();
471
472        assert_eq!(diff.hunks.len(), 1);
473    }
474
475    static HUNK_A: &str = r"
4761   start
4772
4783
4794
4805
4816   middle
4827
4838
4849
4850
4861   end";
487
488    static HUNK_B: &str = r"
4891   start
4902   newa
4913
4924
4935
4946   middle
4957
4968
4979
4980   newb
4991   end";
500
501    #[test]
502    fn test_hunks() {
503        let (_td, repo) = repo_init().unwrap();
504        let root = repo.path().parent().unwrap();
505        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
506
507        assert_eq!(get_statuses(repo_path), (0, 0));
508
509        let file_path = root.join("bar.txt");
510
511        {
512            File::create(&file_path)
513                .unwrap()
514                .write_all(HUNK_A.as_bytes())
515                .unwrap();
516        }
517
518        let res = get_status(repo_path, StatusType::WorkingDir, None).unwrap();
519        assert_eq!(res.len(), 1);
520        assert_eq!(res[0].path, "bar.txt");
521
522        stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
523        assert_eq!(get_statuses(repo_path), (0, 1));
524
525        // overwrite with next content
526        {
527            File::create(&file_path)
528                .unwrap()
529                .write_all(HUNK_B.as_bytes())
530                .unwrap();
531        }
532
533        assert_eq!(get_statuses(repo_path), (1, 1));
534
535        let res = get_diff(repo_path, "bar.txt", false, None).unwrap();
536
537        assert_eq!(res.hunks.len(), 2)
538    }
539
540    #[test]
541    fn test_diff_newfile_in_sub_dir_current_dir() {
542        let file_path = Path::new("foo/foo.txt");
543        let (_td, repo) = repo_init_empty().unwrap();
544        let root = repo.path().parent().unwrap();
545
546        let sub_path = root.join("foo/");
547
548        fs::create_dir_all(&sub_path).unwrap();
549        File::create(root.join(file_path))
550            .unwrap()
551            .write_all(b"test")
552            .unwrap();
553
554        let diff = get_diff(
555            &sub_path.to_str().unwrap().into(),
556            file_path.to_str().unwrap(),
557            false,
558            None,
559        )
560        .unwrap();
561
562        assert_eq!(&*diff.hunks[0].lines[1].content, "test");
563    }
564
565    #[test]
566    fn test_diff_delta_size() -> Result<()> {
567        let file_path = Path::new("bar");
568        let (_td, repo) = repo_init_empty().unwrap();
569        let root = repo.path().parent().unwrap();
570        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
571
572        File::create(root.join(file_path))?.write_all(b"\x00")?;
573
574        stage_add_file(repo_path, file_path).unwrap();
575
576        commit(repo_path, "commit").unwrap();
577
578        File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
579
580        let diff = get_diff(repo_path, file_path.to_str().unwrap(), false, None).unwrap();
581
582        dbg!(&diff);
583        assert_eq!(diff.sizes, (1, 2));
584        assert_eq!(diff.size_delta, 1);
585
586        Ok(())
587    }
588
589    #[test]
590    fn test_binary_diff_delta_size_untracked() -> Result<()> {
591        let file_path = Path::new("bar");
592        let (_td, repo) = repo_init_empty().unwrap();
593        let root = repo.path().parent().unwrap();
594        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
595
596        File::create(root.join(file_path))?.write_all(b"\x00\xc7")?;
597
598        let diff = get_diff(repo_path, file_path.to_str().unwrap(), false, None).unwrap();
599
600        dbg!(&diff);
601        assert_eq!(diff.sizes, (0, 2));
602        assert_eq!(diff.size_delta, 2);
603
604        Ok(())
605    }
606
607    #[test]
608    fn test_diff_delta_size_commit() -> Result<()> {
609        let file_path = Path::new("bar");
610        let (_td, repo) = repo_init_empty().unwrap();
611        let root = repo.path().parent().unwrap();
612        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
613
614        File::create(root.join(file_path))?.write_all(b"\x00")?;
615
616        stage_add_file(repo_path, file_path).unwrap();
617
618        commit(repo_path, "").unwrap();
619
620        File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
621
622        stage_add_file(repo_path, file_path).unwrap();
623
624        let id = commit(repo_path, "").unwrap();
625
626        let diff = get_diff_commit(repo_path, id, String::new(), None).unwrap();
627
628        dbg!(&diff);
629        assert_eq!(diff.sizes, (1, 2));
630        assert_eq!(diff.size_delta, 1);
631
632        Ok(())
633    }
634}