asyncgit 0.2.1

allow using git2 in a asynchronous context
Documentation
//! sync git api for fetching a diff

use super::utils;
use crate::hash;
use git2::{
    Delta, Diff, DiffDelta, DiffFormat, DiffHunk, DiffOptions, Patch,
    Repository,
};
use scopetime::scope_time;
use std::{fs, path::Path};

/// type of diff of a single line
#[derive(Copy, Clone, PartialEq, Hash)]
pub enum DiffLineType {
    /// just surrounding line, no change
    None,
    /// header of the hunk
    Header,
    /// line added
    Add,
    /// line deleted
    Delete,
}

impl Default for DiffLineType {
    fn default() -> Self {
        DiffLineType::None
    }
}

///
#[derive(Default, Clone, Hash)]
pub struct DiffLine {
    ///
    pub content: String,
    ///
    pub line_type: DiffLineType,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
pub(crate) struct HunkHeader {
    old_start: u32,
    old_lines: u32,
    new_start: u32,
    new_lines: u32,
}

impl From<DiffHunk<'_>> for HunkHeader {
    fn from(h: DiffHunk) -> Self {
        Self {
            old_start: h.old_start(),
            old_lines: h.old_lines(),
            new_start: h.new_start(),
            new_lines: h.new_lines(),
        }
    }
}

/// single diff hunk
#[derive(Default, Clone, Hash)]
pub struct Hunk {
    /// hash of the hunk header
    pub header_hash: u64,
    /// list of `DiffLine`s
    pub lines: Vec<DiffLine>,
}

/// collection of hunks, sum of all diff lines
#[derive(Default, Clone, Hash)]
pub struct FileDiff {
    /// list of hunks
    pub hunks: Vec<Hunk>,
    /// lines total summed up over hunks
    pub lines: u16,
}

pub(crate) fn get_diff_raw<'a>(
    repo: &'a Repository,
    p: &str,
    stage: bool,
    reverse: bool,
) -> (Diff<'a>, DiffOptions) {
    let mut opt = DiffOptions::new();
    opt.pathspec(p);
    opt.reverse(reverse);

    let diff = if stage {
        // diff against head
        if let Ok(ref_head) = repo.head() {
            let parent =
                repo.find_commit(ref_head.target().unwrap()).unwrap();
            let tree = parent.tree().unwrap();
            repo.diff_tree_to_index(
                Some(&tree),
                Some(&repo.index().unwrap()),
                Some(&mut opt),
            )
            .unwrap()
        } else {
            repo.diff_tree_to_index(
                None,
                Some(&repo.index().unwrap()),
                Some(&mut opt),
            )
            .unwrap()
        }
    } else {
        opt.include_untracked(true);
        opt.recurse_untracked_dirs(true);
        repo.diff_index_to_workdir(None, Some(&mut opt)).unwrap()
    };

    (diff, opt)
}

///
pub fn get_diff(repo_path: &str, p: String, stage: bool) -> FileDiff {
    scope_time!("get_diff");

    let repo = utils::repo(repo_path);
    let repo_path = repo.path().parent().unwrap();

    let (diff, mut opt) = get_diff_raw(&repo, &p, stage, false);

    let mut res: FileDiff = FileDiff::default();
    let mut current_lines = Vec::new();
    let mut current_hunk: Option<HunkHeader> = None;

    let mut adder = |header: &HunkHeader, lines: &Vec<DiffLine>| {
        res.hunks.push(Hunk {
            header_hash: hash(header),
            lines: lines.clone(),
        });
        res.lines += lines.len() as u16;
    };

    let mut put = |hunk: Option<DiffHunk>, line: git2::DiffLine| {
        if let Some(hunk) = hunk {
            let hunk_header = HunkHeader::from(hunk);

            match current_hunk {
                None => current_hunk = Some(hunk_header),
                Some(h) if h != hunk_header => {
                    adder(&h, &current_lines);
                    current_lines.clear();
                    current_hunk = Some(hunk_header)
                }
                _ => (),
            }

            let line_type = match line.origin() {
                'H' => DiffLineType::Header,
                '<' | '-' => DiffLineType::Delete,
                '>' | '+' => DiffLineType::Add,
                _ => DiffLineType::None,
            };

            let diff_line = DiffLine {
                content: String::from_utf8_lossy(line.content())
                    .to_string(),
                line_type,
            };

            current_lines.push(diff_line);
        }
    };

    let new_file_diff = if diff.deltas().len() == 1 {
        let delta: DiffDelta = diff.deltas().next().unwrap();

        if delta.status() == Delta::Untracked {
            let newfile_path =
                repo_path.join(delta.new_file().path().unwrap());

            let newfile_content = new_file_content(&newfile_path)
                .unwrap_or_else(|| String::from("file not found"));

            let mut patch = Patch::from_buffers(
                &[],
                None,
                newfile_content.as_bytes(),
                Some(&newfile_path),
                Some(&mut opt),
            )
            .unwrap();

            patch
                .print(&mut |_delta, hunk:Option<DiffHunk>, line: git2::DiffLine| {
                    put(hunk,line);
                    true
                })
                .unwrap();

            true
        } else {
            false
        }
    } else {
        false
    };

    if !new_file_diff {
        diff.print(
            DiffFormat::Patch,
            |_, hunk, line: git2::DiffLine| {
                put(hunk, line);
                true
            },
        )
        .unwrap();
    }

    if !current_lines.is_empty() {
        adder(&current_hunk.unwrap(), &current_lines);
    }

    res
}

fn new_file_content(path: &Path) -> Option<String> {
    if let Ok(meta) = fs::symlink_metadata(path) {
        if meta.file_type().is_symlink() {
            if let Ok(path) = fs::read_link(path) {
                return Some(path.to_str()?.to_string());
            }
        } else if meta.file_type().is_file() {
            if let Ok(content) = fs::read_to_string(path) {
                return Some(content);
            } else {
                return Some(String::from("no text file"));
            }
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::get_diff;
    use crate::sync::{
        stage_add_file,
        status::{get_status, StatusType},
        tests::{get_statuses, repo_init, repo_init_empty},
    };
    use std::{
        fs::{self, File},
        io::Write,
        path::Path,
    };

    #[test]
    fn test_untracked_subfolder() {
        let (_td, repo) = repo_init();
        let root = repo.path().parent().unwrap();
        let repo_path = root.as_os_str().to_str().unwrap();

        let res = get_status(repo_path, StatusType::WorkingDir);
        assert_eq!(res.len(), 0);

        fs::create_dir(&root.join("foo")).unwrap();
        File::create(&root.join("foo/bar.txt"))
            .unwrap()
            .write_all(b"test\nfoo")
            .unwrap();

        let res = get_status(repo_path, StatusType::WorkingDir);
        assert_eq!(res.len(), 1);

        let diff =
            get_diff(repo_path, "foo/bar.txt".to_string(), false);

        assert_eq!(diff.hunks.len(), 1);
        assert_eq!(diff.hunks[0].lines[1].content, "test\n");
    }

    #[test]
    fn test_empty_repo() {
        let file_path = Path::new("foo.txt");
        let (_td, repo) = repo_init_empty();
        let root = repo.path().parent().unwrap();
        let repo_path = root.as_os_str().to_str().unwrap();

        assert_eq!(get_statuses(repo_path), (0, 0));

        File::create(&root.join(file_path))
            .unwrap()
            .write_all(b"test\nfoo")
            .unwrap();

        assert_eq!(get_statuses(repo_path), (1, 0));

        assert_eq!(stage_add_file(repo_path, file_path), true);

        assert_eq!(get_statuses(repo_path), (0, 1));

        let diff = get_diff(
            repo_path,
            String::from(file_path.to_str().unwrap()),
            true,
        );

        assert_eq!(diff.hunks.len(), 1);
    }

    static HUNK_A: &str = r"
1   start
2
3
4
5
6   middle
7
8
9
0
1   end";

    static HUNK_B: &str = r"
1   start
2   newa
3
4
5
6   middle
7
8
9
0   newb
1   end";

    #[test]
    fn test_hunks() {
        let (_td, repo) = repo_init();
        let root = repo.path().parent().unwrap();
        let repo_path = root.as_os_str().to_str().unwrap();

        assert_eq!(get_statuses(repo_path), (0, 0));

        let file_path = root.join("bar.txt");

        {
            File::create(&file_path)
                .unwrap()
                .write_all(HUNK_A.as_bytes())
                .unwrap();
        }

        let res = get_status(repo_path, StatusType::WorkingDir);
        assert_eq!(res.len(), 1);
        assert_eq!(res[0].path, "bar.txt");

        let res = stage_add_file(repo_path, Path::new("bar.txt"));
        assert_eq!(res, true);
        assert_eq!(get_statuses(repo_path), (0, 1));

        // overwrite with next content
        {
            File::create(&file_path)
                .unwrap()
                .write_all(HUNK_B.as_bytes())
                .unwrap();
        }

        assert_eq!(get_statuses(repo_path), (1, 1));

        let res = get_diff(repo_path, "bar.txt".to_string(), false);

        assert_eq!(res.hunks.len(), 2)
    }

    #[test]
    fn test_diff_newfile_in_sub_dir_current_dir() {
        let file_path = Path::new("foo/foo.txt");
        let (_td, repo) = repo_init_empty();
        let root = repo.path().parent().unwrap();

        let sub_path = root.join("foo/");

        fs::create_dir_all(&sub_path).unwrap();
        File::create(&root.join(file_path))
            .unwrap()
            .write_all(b"test")
            .unwrap();

        let diff = get_diff(
            sub_path.to_str().unwrap(),
            String::from(file_path.to_str().unwrap()),
            false,
        );

        assert_eq!(diff.hunks[0].lines[1].content, "test");
    }
}