1use crate::error::Result;
4use crate::interner::StringInterner;
5use crate::types::{ChangeType, ChangedFile, DiffResult};
6use std::path::{Path, PathBuf};
7
8pub struct SubmoduleProcessor {
10 repo_path: PathBuf,
11 max_depth: Option<u8>,
12}
13
14impl SubmoduleProcessor {
15 pub fn new<P: AsRef<Path>>(repo_path: P, max_depth: Option<u8>) -> Self {
17 Self {
18 repo_path: repo_path.as_ref().to_path_buf(),
19 max_depth,
20 }
21 }
22
23 pub fn list_submodules(&self) -> Result<Vec<SubmoduleInfo>> {
25 let repo = git2::Repository::open(&self.repo_path)?;
26 let mut submodules = Vec::new();
27
28 for submodule in repo.submodules()? {
29 if let Some(path) = submodule.path().to_str() {
30 submodules.push(SubmoduleInfo {
31 path: path.to_string(),
32 name: submodule.name().unwrap_or(path).to_string(),
33 url: submodule.url().ok().flatten().map(|s| s.to_string()),
34 });
35 }
36 }
37
38 Ok(submodules)
39 }
40
41 pub fn process_submodule_changes(
43 &self,
44 base_sha: &str,
45 head_sha: &str,
46 interner: &StringInterner,
47 current_depth: u8,
48 ) -> Result<DiffResult> {
49 if let Some(max_depth) = self.max_depth {
51 if current_depth >= max_depth {
52 return Ok(DiffResult::default());
53 }
54 }
55
56 let mut result = DiffResult::default();
57
58 let submodule_changes = self.extract_submodule_changes(base_sha, head_sha)?;
60
61 for (submodule_path, base_submodule_sha, head_submodule_sha) in submodule_changes {
62 let submodule_full_path = self.repo_path.join(&submodule_path);
64
65 if !submodule_full_path.exists() {
67 continue;
69 }
70
71 let submodule_repo = match git2::Repository::open(&submodule_full_path) {
73 Ok(repo) => repo,
74 Err(_) => continue, };
76
77 let base_sha_to_use = if base_submodule_sha.is_empty() {
79 Self::empty_tree_sha()
80 } else {
81 &base_submodule_sha
82 };
83
84 let head_sha_to_use = if head_submodule_sha.is_empty() {
85 Self::empty_tree_sha()
86 } else {
87 &head_submodule_sha
88 };
89
90 let submodule_diff = self.diff_submodule(
92 &submodule_repo,
93 base_sha_to_use,
94 head_sha_to_use,
95 &submodule_path,
96 interner,
97 current_depth,
98 )?;
99
100 result.files.extend(submodule_diff.files);
102 }
103
104 Ok(result)
105 }
106
107 fn extract_submodule_changes(
109 &self,
110 base_sha: &str,
111 head_sha: &str,
112 ) -> Result<Vec<(String, String, String)>> {
113 let repo = git2::Repository::open(&self.repo_path)?;
114
115 let base_oid = git2::Oid::from_str(base_sha)?;
116 let head_oid = git2::Oid::from_str(head_sha)?;
117
118 let base_tree = repo.find_commit(base_oid)?.tree()?;
119 let head_tree = repo.find_commit(head_oid)?.tree()?;
120
121 let mut opts = git2::DiffOptions::new();
122 opts.ignore_submodules(false); let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
125
126 let mut changes = Vec::new();
127
128 diff.foreach(
129 &mut |delta, _progress| {
130 let old_file = delta.old_file();
132 let new_file = delta.new_file();
133
134 let is_submodule = old_file.mode() == git2::FileMode::Commit
136 || new_file.mode() == git2::FileMode::Commit;
137
138 if !is_submodule {
139 return true;
140 }
141
142 if let Some(path) = new_file.path().and_then(|p| p.to_str()) {
143 let old_sha = old_file.id().to_string();
144 let new_sha = new_file.id().to_string();
145
146 changes.push((path.to_string(), old_sha, new_sha));
147 }
148
149 true
150 },
151 None,
152 None,
153 None,
154 )?;
155
156 Ok(changes)
157 }
158
159 fn diff_submodule(
161 &self,
162 submodule_repo: &git2::Repository,
163 base_sha: &str,
164 head_sha: &str,
165 submodule_path: &str,
166 interner: &StringInterner,
167 current_depth: u8,
168 ) -> Result<DiffResult> {
169 let base_oid = git2::Oid::from_str(base_sha)?;
171
172 let head_oid = git2::Oid::from_str(head_sha)?;
173
174 let base_tree = if base_sha == Self::empty_tree_sha() {
176 None
177 } else {
178 Some(submodule_repo.find_commit(base_oid)?.tree()?)
179 };
180
181 let head_tree = submodule_repo.find_commit(head_oid)?.tree()?;
182
183 let mut opts = git2::DiffOptions::new();
184 let diff = submodule_repo.diff_tree_to_tree(
185 base_tree.as_ref(),
186 Some(&head_tree),
187 Some(&mut opts),
188 )?;
189
190 let mut result = DiffResult::default();
191
192 diff.foreach(
193 &mut |delta, _progress| {
194 let status = delta.status();
195
196 let change_type = match status {
197 git2::Delta::Added => ChangeType::Added,
198 git2::Delta::Deleted => ChangeType::Deleted,
199 git2::Delta::Modified => ChangeType::Modified,
200 git2::Delta::Renamed => ChangeType::Renamed,
201 git2::Delta::Copied => ChangeType::Copied,
202 git2::Delta::Typechange => ChangeType::TypeChanged,
203 git2::Delta::Conflicted => ChangeType::Unmerged,
204 _ => ChangeType::Unknown,
205 };
206
207 let new_file = delta.new_file();
208 let old_file = delta.old_file();
209
210 if let Some(file_path) = new_file.path().and_then(|p| p.to_str()) {
211 let full_path = format!("{}/{}", submodule_path, file_path);
213
214 let previous_path = if change_type == ChangeType::Renamed
215 || change_type == ChangeType::Copied
216 {
217 old_file.path().and_then(|p| p.to_str()).map(|old_p| {
218 let full_old_path = format!("{}/{}", submodule_path, old_p);
219 interner.intern(&full_old_path)
220 })
221 } else {
222 None
223 };
224
225 result.files.push(ChangedFile {
226 path: interner.intern(&full_path),
227 change_type,
228 previous_path,
229 is_symlink: false,
230 submodule_depth: current_depth + 1,
231 origin: crate::types::FileOrigin {
232 in_current_changes: true,
233 in_previous_failure: false,
234 in_previous_success: false,
235 },
236 });
237 }
238
239 true
240 },
241 None,
242 None,
243 None,
244 )?;
245
246 Ok(result)
247 }
248
249 fn empty_tree_sha() -> &'static str {
251 "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
252 }
253}
254
255#[derive(Debug, Clone)]
257pub struct SubmoduleInfo {
258 pub path: String,
260 pub name: String,
262 pub url: Option<String>,
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use std::fs;
270 use tempfile::TempDir;
271
272 fn create_test_repo_with_submodule() -> (TempDir, PathBuf, TempDir, PathBuf) {
273 let main_dir = TempDir::new().unwrap();
275 let main_path = main_dir.path().to_path_buf();
276
277 std::process::Command::new("git")
278 .args(["init"])
279 .current_dir(&main_path)
280 .output()
281 .unwrap();
282
283 std::process::Command::new("git")
284 .args(["config", "user.name", "Test User"])
285 .current_dir(&main_path)
286 .output()
287 .unwrap();
288
289 std::process::Command::new("git")
290 .args(["config", "user.email", "test@example.com"])
291 .current_dir(&main_path)
292 .output()
293 .unwrap();
294
295 std::process::Command::new("git")
297 .args(["config", "protocol.file.allow", "always"])
298 .current_dir(&main_path)
299 .output()
300 .unwrap();
301
302 let sub_dir = TempDir::new().unwrap();
304 let sub_path = sub_dir.path().to_path_buf();
305
306 std::process::Command::new("git")
307 .args(["init"])
308 .current_dir(&sub_path)
309 .output()
310 .unwrap();
311
312 std::process::Command::new("git")
313 .args(["config", "user.name", "Test User"])
314 .current_dir(&sub_path)
315 .output()
316 .unwrap();
317
318 std::process::Command::new("git")
319 .args(["config", "user.email", "test@example.com"])
320 .current_dir(&sub_path)
321 .output()
322 .unwrap();
323
324 fs::write(sub_path.join("sub_file.txt"), "submodule content").unwrap();
326 std::process::Command::new("git")
327 .args(["add", "."])
328 .current_dir(&sub_path)
329 .output()
330 .unwrap();
331 std::process::Command::new("git")
332 .args(["commit", "-m", "Submodule initial commit"])
333 .current_dir(&sub_path)
334 .output()
335 .unwrap();
336
337 fs::write(main_path.join("main_file.txt"), "main content").unwrap();
339 std::process::Command::new("git")
340 .args(["add", "."])
341 .current_dir(&main_path)
342 .output()
343 .unwrap();
344 std::process::Command::new("git")
345 .args(["commit", "-m", "Main initial commit"])
346 .current_dir(&main_path)
347 .output()
348 .unwrap();
349
350 let output = std::process::Command::new("git")
352 .args([
353 "submodule",
354 "add",
355 sub_path.to_str().unwrap(),
356 "mysubmodule",
357 ])
358 .current_dir(&main_path)
359 .output()
360 .unwrap();
361
362 if !output.status.success() {
364 eprintln!(
365 "git submodule add failed: {}",
366 String::from_utf8_lossy(&output.stderr)
367 );
368 }
369
370 std::process::Command::new("git")
372 .args(["add", "."])
373 .current_dir(&main_path)
374 .output()
375 .unwrap();
376
377 std::process::Command::new("git")
378 .args(["commit", "-m", "Add submodule"])
379 .current_dir(&main_path)
380 .output()
381 .unwrap();
382
383 (main_dir, main_path, sub_dir, sub_path)
384 }
385
386 #[test]
389 #[ignore]
390 fn test_list_submodules() {
391 let (_main_dir, main_path, _sub_dir, _sub_path) = create_test_repo_with_submodule();
392
393 let processor = SubmoduleProcessor::new(&main_path, None);
394 let submodules = processor.list_submodules().unwrap();
395
396 assert_eq!(submodules.len(), 1);
397 assert_eq!(submodules[0].path, "mysubmodule");
398 }
399
400 #[test]
401 #[ignore]
402 fn test_extract_submodule_changes() {
403 let (_main_dir, main_path, _sub_dir, _sub_path) = create_test_repo_with_submodule();
404
405 let repo = git2::Repository::open(&main_path).unwrap();
407 let mut revwalk = repo.revwalk().unwrap();
408 revwalk.push_head().unwrap();
409
410 let commits: Vec<_> = revwalk.map(|r| r.unwrap().to_string()).collect();
411
412 eprintln!("Number of commits: {}", commits.len());
414
415 if commits.len() < 2 {
416 eprintln!("Not enough commits to test submodule changes");
417 return; }
419
420 let base_sha = &commits[1]; let head_sha = &commits[0]; let processor = SubmoduleProcessor::new(&main_path, None);
424 let changes = processor
425 .extract_submodule_changes(base_sha, head_sha)
426 .unwrap();
427
428 assert!(!changes.is_empty(), "Expected to find submodule changes");
429 assert_eq!(changes[0].0, "mysubmodule");
430 }
431}