use crate::error::Result;
use crate::interner::StringInterner;
use crate::types::{ChangeType, ChangedFile, DiffResult};
use std::path::{Path, PathBuf};
pub struct SubmoduleProcessor {
repo_path: PathBuf,
max_depth: Option<u8>,
}
impl SubmoduleProcessor {
pub fn new<P: AsRef<Path>>(repo_path: P, max_depth: Option<u8>) -> Self {
Self {
repo_path: repo_path.as_ref().to_path_buf(),
max_depth,
}
}
pub fn list_submodules(&self) -> Result<Vec<SubmoduleInfo>> {
let repo = git2::Repository::open(&self.repo_path)?;
let mut submodules = Vec::new();
for submodule in repo.submodules()? {
if let Some(path) = submodule.path().to_str() {
submodules.push(SubmoduleInfo {
path: path.to_string(),
name: submodule.name().unwrap_or(path).to_string(),
url: submodule.url().ok().flatten().map(|s| s.to_string()),
});
}
}
Ok(submodules)
}
pub fn process_submodule_changes(
&self,
base_sha: &str,
head_sha: &str,
interner: &StringInterner,
current_depth: u8,
) -> Result<DiffResult> {
if let Some(max_depth) = self.max_depth {
if current_depth >= max_depth {
return Ok(DiffResult::default());
}
}
let mut result = DiffResult::default();
let submodule_changes = self.extract_submodule_changes(base_sha, head_sha)?;
for (submodule_path, base_submodule_sha, head_submodule_sha) in submodule_changes {
let submodule_full_path = self.repo_path.join(&submodule_path);
if !submodule_full_path.exists() {
continue;
}
let submodule_repo = match git2::Repository::open(&submodule_full_path) {
Ok(repo) => repo,
Err(_) => continue, };
let base_sha_to_use = if base_submodule_sha.is_empty() {
Self::empty_tree_sha()
} else {
&base_submodule_sha
};
let head_sha_to_use = if head_submodule_sha.is_empty() {
Self::empty_tree_sha()
} else {
&head_submodule_sha
};
let submodule_diff = self.diff_submodule(
&submodule_repo,
base_sha_to_use,
head_sha_to_use,
&submodule_path,
interner,
current_depth,
)?;
result.files.extend(submodule_diff.files);
}
Ok(result)
}
fn extract_submodule_changes(
&self,
base_sha: &str,
head_sha: &str,
) -> Result<Vec<(String, String, String)>> {
let repo = git2::Repository::open(&self.repo_path)?;
let base_oid = git2::Oid::from_str(base_sha)?;
let head_oid = git2::Oid::from_str(head_sha)?;
let base_tree = repo.find_commit(base_oid)?.tree()?;
let head_tree = repo.find_commit(head_oid)?.tree()?;
let mut opts = git2::DiffOptions::new();
opts.ignore_submodules(false);
let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
let mut changes = Vec::new();
diff.foreach(
&mut |delta, _progress| {
let old_file = delta.old_file();
let new_file = delta.new_file();
let is_submodule = old_file.mode() == git2::FileMode::Commit
|| new_file.mode() == git2::FileMode::Commit;
if !is_submodule {
return true;
}
if let Some(path) = new_file.path().and_then(|p| p.to_str()) {
let old_sha = old_file.id().to_string();
let new_sha = new_file.id().to_string();
changes.push((path.to_string(), old_sha, new_sha));
}
true
},
None,
None,
None,
)?;
Ok(changes)
}
fn diff_submodule(
&self,
submodule_repo: &git2::Repository,
base_sha: &str,
head_sha: &str,
submodule_path: &str,
interner: &StringInterner,
current_depth: u8,
) -> Result<DiffResult> {
let base_oid = git2::Oid::from_str(base_sha)?;
let head_oid = git2::Oid::from_str(head_sha)?;
let base_tree = if base_sha == Self::empty_tree_sha() {
None
} else {
Some(submodule_repo.find_commit(base_oid)?.tree()?)
};
let head_tree = submodule_repo.find_commit(head_oid)?.tree()?;
let mut opts = git2::DiffOptions::new();
let diff = submodule_repo.diff_tree_to_tree(
base_tree.as_ref(),
Some(&head_tree),
Some(&mut opts),
)?;
let mut result = DiffResult::default();
diff.foreach(
&mut |delta, _progress| {
let status = delta.status();
let change_type = match status {
git2::Delta::Added => ChangeType::Added,
git2::Delta::Deleted => ChangeType::Deleted,
git2::Delta::Modified => ChangeType::Modified,
git2::Delta::Renamed => ChangeType::Renamed,
git2::Delta::Copied => ChangeType::Copied,
git2::Delta::Typechange => ChangeType::TypeChanged,
git2::Delta::Conflicted => ChangeType::Unmerged,
_ => ChangeType::Unknown,
};
let new_file = delta.new_file();
let old_file = delta.old_file();
if let Some(file_path) = new_file.path().and_then(|p| p.to_str()) {
let full_path = format!("{}/{}", submodule_path, file_path);
let previous_path = if change_type == ChangeType::Renamed
|| change_type == ChangeType::Copied
{
old_file.path().and_then(|p| p.to_str()).map(|old_p| {
let full_old_path = format!("{}/{}", submodule_path, old_p);
interner.intern(&full_old_path)
})
} else {
None
};
result.files.push(ChangedFile {
path: interner.intern(&full_path),
change_type,
previous_path,
is_symlink: false,
submodule_depth: current_depth + 1,
origin: crate::types::FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
});
}
true
},
None,
None,
None,
)?;
Ok(result)
}
fn empty_tree_sha() -> &'static str {
"4b825dc642cb6eb9a060e54bf8d69288fbee4904"
}
}
#[derive(Debug, Clone)]
pub struct SubmoduleInfo {
pub path: String,
pub name: String,
pub url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_repo_with_submodule() -> (TempDir, PathBuf, TempDir, PathBuf) {
let main_dir = TempDir::new().unwrap();
let main_path = main_dir.path().to_path_buf();
std::process::Command::new("git")
.args(["init"])
.current_dir(&main_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&main_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&main_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "protocol.file.allow", "always"])
.current_dir(&main_path)
.output()
.unwrap();
let sub_dir = TempDir::new().unwrap();
let sub_path = sub_dir.path().to_path_buf();
std::process::Command::new("git")
.args(["init"])
.current_dir(&sub_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&sub_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&sub_path)
.output()
.unwrap();
fs::write(sub_path.join("sub_file.txt"), "submodule content").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&sub_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Submodule initial commit"])
.current_dir(&sub_path)
.output()
.unwrap();
fs::write(main_path.join("main_file.txt"), "main content").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&main_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Main initial commit"])
.current_dir(&main_path)
.output()
.unwrap();
let output = std::process::Command::new("git")
.args([
"submodule",
"add",
sub_path.to_str().unwrap(),
"mysubmodule",
])
.current_dir(&main_path)
.output()
.unwrap();
if !output.status.success() {
eprintln!(
"git submodule add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&main_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Add submodule"])
.current_dir(&main_path)
.output()
.unwrap();
(main_dir, main_path, sub_dir, sub_path)
}
#[test]
#[ignore]
fn test_list_submodules() {
let (_main_dir, main_path, _sub_dir, _sub_path) = create_test_repo_with_submodule();
let processor = SubmoduleProcessor::new(&main_path, None);
let submodules = processor.list_submodules().unwrap();
assert_eq!(submodules.len(), 1);
assert_eq!(submodules[0].path, "mysubmodule");
}
#[test]
#[ignore]
fn test_extract_submodule_changes() {
let (_main_dir, main_path, _sub_dir, _sub_path) = create_test_repo_with_submodule();
let repo = git2::Repository::open(&main_path).unwrap();
let mut revwalk = repo.revwalk().unwrap();
revwalk.push_head().unwrap();
let commits: Vec<_> = revwalk.map(|r| r.unwrap().to_string()).collect();
eprintln!("Number of commits: {}", commits.len());
if commits.len() < 2 {
eprintln!("Not enough commits to test submodule changes");
return; }
let base_sha = &commits[1]; let head_sha = &commits[0];
let processor = SubmoduleProcessor::new(&main_path, None);
let changes = processor
.extract_submodule_changes(base_sha, head_sha)
.unwrap();
assert!(!changes.is_empty(), "Expected to find submodule changes");
assert_eq!(changes[0].0, "mysubmodule");
}
}