use crate::vcs::Repo;
use color_eyre::{
eyre::{bail, eyre},
Result,
};
use git2::Repository;
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
pub fn validate_submodule_states(repo: &Repository) -> Result<()> {
for submodule in repo.submodules()? {
let name = submodule
.name()
.ok_or_else(|| eyre!("Submodule without name"))?;
let path = submodule.path();
if submodule.workdir_id().is_none() {
bail!(
"Submodule '{}' at {} is not initialized. \
Please run 'git submodule update --init' first.",
name,
path.display()
);
}
let sub_repo = match submodule.open() {
Ok(repo) => repo,
Err(_) => {
bail!(
"Cannot open submodule '{}' at {}. \
It may be deinitialized or corrupted.",
name,
path.display()
);
}
};
let statuses = sub_repo.statuses(None)?;
if !statuses.is_empty() {
let modified_count = statuses
.iter()
.filter(|s| {
let flags = s.status();
flags.contains(git2::Status::WT_MODIFIED)
|| flags.contains(git2::Status::INDEX_MODIFIED)
|| flags.contains(git2::Status::WT_NEW)
|| flags.contains(git2::Status::INDEX_NEW)
})
.count();
if modified_count > 0 {
bail!(
"Submodule '{}' at {} has uncommitted changes. \
Please commit or stash changes before running vcs2git.",
name,
path.display()
);
}
}
let head_oid = sub_repo
.head()?
.target()
.ok_or_else(|| eyre!("Submodule HEAD has no target"))?;
let expected_oid = submodule
.workdir_id()
.ok_or_else(|| eyre!("No workdir commit for submodule"))?;
if head_oid != expected_oid {
bail!(
"Submodule '{}' at {} is checked out to a different commit than expected. \
Expected: {}, Actual: {}. \
Please run 'git submodule update' to synchronize.",
name,
path.display(),
expected_oid,
head_oid
);
}
}
Ok(())
}
pub fn validate_repositories(
repos: &indexmap::IndexMap<PathBuf, Repo>,
prefix: &Path,
) -> Result<()> {
let mut seen_names = HashSet::new();
let mut seen_paths = HashSet::new();
for (path, repo) in repos {
let full_path = prefix.join(path);
let name = full_path.to_string_lossy().to_string();
if !seen_names.insert(name.clone()) {
bail!("Duplicate submodule name: {}", name);
}
if !seen_paths.insert(full_path.clone()) {
bail!("Duplicate submodule path: {}", full_path.display());
}
let scheme = repo.url.scheme();
if scheme != "git"
&& scheme != "ssh"
&& scheme != "https"
&& scheme != "http"
&& scheme != "file"
{
bail!("Invalid repository URL scheme '{}' for {}. Supported schemes: git, ssh, https, http, file",
scheme, repo.url);
}
if path.is_absolute() {
bail!("Repository path must be relative: {}", path.display());
}
if path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
bail!(
"Repository path cannot contain '..' components: {}",
path.display()
);
}
}
Ok(())
}
pub fn validate_main_repo_clean(repo: &Repository) -> Result<()> {
let statuses = repo.statuses(None)?;
let has_staged_changes = statuses.iter().any(|s| {
s.status().contains(git2::Status::INDEX_NEW)
|| s.status().contains(git2::Status::INDEX_MODIFIED)
|| s.status().contains(git2::Status::INDEX_DELETED)
});
if has_staged_changes {
bail!(
"The repository has staged changes. \
Please commit or reset staged changes before running vcs2git."
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::{Repo, RepoType};
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_validate_repositories() {
let mut repos = indexmap::IndexMap::new();
repos.insert(
PathBuf::from("src/repo1"),
Repo {
r#type: RepoType::Git,
url: "https://github.com/test/repo1".parse().unwrap(),
version: "main".to_string(),
},
);
assert!(validate_repositories(&repos, &PathBuf::from("src")).is_ok());
repos.insert(
PathBuf::from("/absolute/path"),
Repo {
r#type: RepoType::Git,
url: "https://github.com/test/repo2".parse().unwrap(),
version: "main".to_string(),
},
);
assert!(validate_repositories(&repos, &PathBuf::from("src")).is_err());
repos.shift_remove(&PathBuf::from("/absolute/path"));
repos.insert(
PathBuf::from("../parent"),
Repo {
r#type: RepoType::Git,
url: "https://github.com/test/repo3".parse().unwrap(),
version: "main".to_string(),
},
);
assert!(validate_repositories(&repos, &PathBuf::from("src")).is_err());
repos.shift_remove(&PathBuf::from("../parent"));
repos.insert(
PathBuf::from("src/repo2"),
Repo {
r#type: RepoType::Git,
url: "ftp://github.com/test/repo4".parse().unwrap(),
version: "main".to_string(),
},
);
assert!(validate_repositories(&repos, &PathBuf::from("src")).is_err());
}
fn create_test_repo() -> Result<(TempDir, Repository)> {
let dir = TempDir::new()?;
let repo = Repository::init(dir.path())?;
let sig = git2::Signature::now("Test User", "test@example.com")?;
let tree_id = {
let mut index = repo.index()?;
index.write_tree()?
};
let tree = repo.find_tree(tree_id)?;
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
drop(tree); Ok((dir, repo))
}
#[test]
fn test_validate_submodule_states_with_clean_repo() {
let (_dir, repo) = create_test_repo().unwrap();
assert!(validate_submodule_states(&repo).is_ok());
}
}