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| {
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}