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
24pub 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| TuffError::new(error.to_string()))?,
143 );
144 }
145 }
146 Ok(())
147}
148
149fn file_hashes(root: &Path) -> Result<BTreeMap<String, String>> {
150 let mut paths = Vec::new();
151 collect_paths(root, root, &mut paths)?;
152 let mut result = BTreeMap::new();
153 for relative in paths {
154 let content = std::fs::read(root.join(&relative))?;
155 let mut hasher = Sha256::new();
156 hasher.update(content);
157 result.insert(
158 relative.to_string_lossy().replace('\\', "/"),
159 format!("{:x}", hasher.finalize()),
160 );
161 }
162 Ok(result)
163}
164
165fn git_error(error: git2::Error) -> TuffError {
166 TuffError::new(format!("git2 diff failed: {error}"))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use tempfile::TempDir;
173
174 #[test]
175 fn compares_added_removed_and_modified_files() {
176 let old = TempDir::new().unwrap();
177 let new = TempDir::new().unwrap();
178 std::fs::write(old.path().join("modified"), "old").unwrap();
179 std::fs::write(old.path().join("removed"), "gone").unwrap();
180 std::fs::write(new.path().join("modified"), "new").unwrap();
181 std::fs::write(new.path().join("added"), "new file").unwrap();
182
183 let result = compare(old.path(), new.path()).unwrap();
184 assert_eq!(result.changes.len(), 3);
185 assert!(result.changes.iter().any(|change| change.status == "added"));
186 assert!(
187 result
188 .changes
189 .iter()
190 .any(|change| change.status == "removed")
191 );
192 assert!(
193 result
194 .changes
195 .iter()
196 .any(|change| change.status == "modified")
197 );
198 }
199}