Skip to main content

tuff_core/
tree_diff.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use git2::{DiffFormat, DiffOptions, Repository, Tree};
5use sha2::{Digest, Sha256};
6use tempfile::TempDir;
7
8use crate::error::{Result, TuffError};
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct FileChange {
12    pub path: String,
13    pub status: String,
14    pub old_hash: Option<String>,
15    pub new_hash: Option<String>,
16}
17
18#[derive(Debug)]
19pub struct TreeComparison {
20    pub patch: String,
21    pub changes: Vec<FileChange>,
22}
23
24/// Imports both directories into one scoped temporary Git repository and compares
25/// the resulting Git trees through libgit2. The repository is deleted with `temp`.
26pub fn compare(old_root: &Path, new_root: &Path) -> Result<TreeComparison> {
27    let temp = TempDir::new()?;
28    let repository = Repository::init(temp.path()).map_err(git_error)?;
29    let old_tree = write_tree(&repository, old_root)?;
30    let new_tree = write_tree(&repository, new_root)?;
31
32    let mut options = DiffOptions::new();
33    let diff = repository
34        .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), Some(&mut options))
35        .map_err(git_error)?;
36    let mut patch = Vec::new();
37    diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
38        match line.origin() {
39            '+' | '-' | ' ' => {
40                patch.push(line.origin() as u8);
41                patch.extend_from_slice(line.content());
42            }
43            _ => patch.extend_from_slice(line.content()),
44        }
45        true
46    })
47    .map_err(git_error)?;
48
49    let old_files = file_hashes(old_root)?;
50    let new_files = file_hashes(new_root)?;
51    let mut changes = Vec::new();
52    let mut paths = old_files.keys().cloned().collect::<Vec<_>>();
53    paths.extend(
54        new_files
55            .keys()
56            .filter(|path| !old_files.contains_key(*path))
57            .cloned(),
58    );
59    paths.sort();
60    paths.dedup();
61    for path in paths {
62        let old_hash = old_files.get(&path).cloned();
63        let new_hash = new_files.get(&path).cloned();
64        if old_hash == new_hash {
65            continue;
66        }
67        let status = match (&old_hash, &new_hash) {
68            (None, Some(_)) => "added",
69            (Some(_), None) => "removed",
70            (Some(_), Some(_)) => "modified",
71            (None, None) => unreachable!(),
72        };
73        changes.push(FileChange {
74            path,
75            status: status.to_string(),
76            old_hash,
77            new_hash,
78        });
79    }
80
81    Ok(TreeComparison {
82        patch: String::from_utf8_lossy(&patch).into_owned(),
83        changes,
84    })
85}
86
87fn write_tree<'repo>(repository: &'repo Repository, root: &Path) -> Result<Tree<'repo>> {
88    let workdir = repository
89        .workdir()
90        .ok_or_else(|| TuffError::new("temporary git repository has no workdir"))?;
91    for entry in std::fs::read_dir(workdir)? {
92        let entry = entry?;
93        if entry.file_name() == ".git" {
94            continue;
95        }
96        let path = entry.path();
97        if path.is_dir() {
98            std::fs::remove_dir_all(path)?;
99        } else {
100            std::fs::remove_file(path)?;
101        }
102    }
103    copy_tree(root, workdir)?;
104
105    let mut index = repository.index().map_err(git_error)?;
106    index.clear().map_err(git_error)?;
107    let mut files = Vec::new();
108    collect_paths(root, root, &mut files)?;
109    files.sort();
110    for path in files {
111        index.add_path(&path).map_err(git_error)?;
112    }
113    index.write().map_err(git_error)?;
114    let tree_id = index.write_tree().map_err(git_error)?;
115    repository.find_tree(tree_id).map_err(git_error)
116}
117
118fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
119    for entry in std::fs::read_dir(source)? {
120        let entry = entry?;
121        let source_path = entry.path();
122        let destination_path = destination.join(entry.file_name());
123        if source_path.is_dir() {
124            std::fs::create_dir_all(&destination_path)?;
125            copy_tree(&source_path, &destination_path)?;
126        } else if source_path.is_file() {
127            std::fs::copy(source_path, destination_path)?;
128        }
129    }
130    Ok(())
131}
132
133fn collect_paths(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
134    for entry in std::fs::read_dir(current)? {
135        let path = entry?.path();
136        if path.is_dir() {
137            collect_paths(root, &path, output)?;
138        } else if path.is_file() {
139            output.push(
140                path.strip_prefix(root)
141                    .map(PathBuf::from)
142                    .map_err(|error| {
143                        TuffError::of(crate::error::ErrorKind::Io, error.to_string())
144                    })?,
145            );
146        }
147    }
148    Ok(())
149}
150
151fn file_hashes(root: &Path) -> Result<BTreeMap<String, String>> {
152    let mut paths = Vec::new();
153    collect_paths(root, root, &mut paths)?;
154    let mut result = BTreeMap::new();
155    for relative in paths {
156        let content = std::fs::read(root.join(&relative))?;
157        let mut hasher = Sha256::new();
158        hasher.update(content);
159        result.insert(
160            relative.to_string_lossy().replace('\\', "/"),
161            format!("{:x}", hasher.finalize()),
162        );
163    }
164    Ok(result)
165}
166
167fn git_error(error: git2::Error) -> TuffError {
168    TuffError::new(format!("git2 diff failed: {error}"))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use tempfile::TempDir;
175
176    #[test]
177    fn compares_added_removed_and_modified_files() {
178        let old = TempDir::new().unwrap();
179        let new = TempDir::new().unwrap();
180        std::fs::write(old.path().join("modified"), "old").unwrap();
181        std::fs::write(old.path().join("removed"), "gone").unwrap();
182        std::fs::write(new.path().join("modified"), "new").unwrap();
183        std::fs::write(new.path().join("added"), "new file").unwrap();
184
185        let result = compare(old.path(), new.path()).unwrap();
186        assert_eq!(result.changes.len(), 3);
187        assert!(result.changes.iter().any(|change| change.status == "added"));
188        assert!(
189            result
190                .changes
191                .iter()
192                .any(|change| change.status == "removed")
193        );
194        assert!(
195            result
196                .changes
197                .iter()
198                .any(|change| change.status == "modified")
199        );
200    }
201}