use crate::fleet::config::GitModules;
use crate::fleet::error::{Result, SuperGitError};
use crate::prelude::*;
use std::collections::HashSet;
pub const MAX_SUBMODULE_DEPTH: usize = 32;
#[derive(Debug, Clone)]
pub struct SubmoduleNode {
pub name: String,
pub rel_path: PathBuf,
pub display_path: String,
pub abs_path: PathBuf,
pub parent_path: PathBuf,
pub url: String,
pub branch: Option<String>,
pub depth: usize,
pub initialized: bool,
}
impl SubmoduleNode {
pub fn label(&self) -> &str {
&self.display_path
}
pub fn resolved_url(&self) -> Result<String> {
if !is_relative_submodule_url(&self.url) {
return Ok(self.url.clone());
}
let base = parent_remote_url(&self.parent_path)?.ok_or_else(|| {
SuperGitError::Other(format!(
"Submodule '{}' uses the relative URL '{}', but its parent repository at '{}' \
has no 'origin' remote to resolve it against",
self.display_path,
self.url,
self.parent_path.display()
))
})?;
Ok(resolve_relative_url(&base, &self.url))
}
}
#[derive(Debug, Clone)]
pub struct SubmoduleTree {
pub root: PathBuf,
nodes: Vec<SubmoduleNode>,
}
impl SubmoduleTree {
pub fn discover<P: AsRef<Path>>(root: P) -> Result<Self> {
let root = root.as_ref().to_path_buf();
let mut nodes = Vec::new();
let mut visited = HashSet::new();
visited.insert(canonical_key(&root)?);
walk(&root, None, 0, &mut visited, &mut nodes)?;
if nodes.is_empty() {
return Err(SuperGitError::NoSubmodules);
}
Ok(SubmoduleTree { root, nodes })
}
pub fn discover_allow_empty<P: AsRef<Path>>(root: P) -> Result<Self> {
match Self::discover(root.as_ref()) {
Ok(tree) => Ok(tree),
Err(SuperGitError::NoSubmodules) => Ok(SubmoduleTree {
root: root.as_ref().to_path_buf(),
nodes: Vec::new(),
}),
Err(err) => Err(err),
}
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn top_down(&self) -> &[SubmoduleNode] {
&self.nodes
}
pub fn bottom_up(&self) -> Vec<&SubmoduleNode> {
let mut ordered: Vec<&SubmoduleNode> = self.nodes.iter().collect();
ordered.sort_by_key(|node| std::cmp::Reverse(node.depth));
ordered
}
pub fn top_level(&self) -> impl Iterator<Item = &SubmoduleNode> {
self.nodes.iter().filter(|node| node.depth == 0)
}
pub fn parents_bottom_up(&self) -> Vec<(PathBuf, Vec<&SubmoduleNode>)> {
let mut groups: Vec<(PathBuf, Vec<&SubmoduleNode>)> = Vec::new();
for node in self.bottom_up() {
match groups
.iter_mut()
.find(|(parent, _)| parent == &node.parent_path)
{
Some((_, children)) => children.push(node),
None => groups.push((node.parent_path.clone(), vec![node])),
}
}
groups
}
pub fn find(&self, display_path: &str) -> Option<&SubmoduleNode> {
self.nodes
.iter()
.find(|node| node.display_path == display_path)
}
}
fn walk(
current: &Path,
prefix: Option<&str>,
depth: usize,
visited: &mut HashSet<PathBuf>,
out: &mut Vec<SubmoduleNode>,
) -> Result<()> {
if depth >= MAX_SUBMODULE_DEPTH {
return Err(SuperGitError::Other(format!(
"Submodule nesting exceeds the maximum depth of {MAX_SUBMODULE_DEPTH} at '{}' — \
this usually means .gitmodules entries form a cycle",
current.display()
)));
}
let gitmodules = match GitModules::parse(current) {
Ok(parsed) => parsed,
Err(SuperGitError::NoSubmodules) => return Ok(()),
Err(err) => return Err(err),
};
for submodule in &gitmodules.submodules {
validate_submodule_path(&submodule.path, current)?;
let abs_path = current.join(&submodule.path);
let display_path = match prefix {
Some(prefix) => format!("{prefix}/{}", to_slash(&submodule.path)),
None => to_slash(&submodule.path),
};
let initialized = is_initialized(&abs_path);
out.push(SubmoduleNode {
name: submodule.name.clone(),
rel_path: submodule.path.clone(),
display_path: display_path.clone(),
abs_path: abs_path.clone(),
parent_path: current.to_path_buf(),
url: submodule.url.clone(),
branch: submodule.branch.clone(),
depth,
initialized,
});
if !initialized {
continue;
}
if !visited.insert(canonical_key(&abs_path)?) {
return Err(SuperGitError::Other(format!(
"Submodule cycle detected: '{display_path}' resolves to a repository already \
visited in this tree",
)));
}
walk(&abs_path, Some(&display_path), depth + 1, visited, out)?;
}
Ok(())
}
fn validate_submodule_path(path: &Path, parent: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
return Err(SuperGitError::GitModulesParse(format!(
"empty submodule path declared in '{}'",
parent.display()
)));
}
if path.is_absolute() {
return Err(SuperGitError::GitModulesParse(format!(
"submodule path '{}' in '{}' is absolute; submodule paths must be relative to the \
repository that declares them",
path.display(),
parent.display()
)));
}
for component in path.components() {
match component {
std::path::Component::ParentDir => {
return Err(SuperGitError::GitModulesParse(format!(
"submodule path '{}' in '{}' escapes the repository via '..'",
path.display(),
parent.display()
)));
}
std::path::Component::Prefix(_) | std::path::Component::RootDir => {
return Err(SuperGitError::GitModulesParse(format!(
"submodule path '{}' in '{}' is not a plain relative path",
path.display(),
parent.display()
)));
}
std::path::Component::Normal(part) => {
if part.eq_ignore_ascii_case(".git") {
return Err(SuperGitError::GitModulesParse(format!(
"submodule path '{}' in '{}' targets a .git directory",
path.display(),
parent.display()
)));
}
}
std::path::Component::CurDir => {}
}
}
Ok(())
}
fn is_initialized(path: &Path) -> bool {
path.join(".git").exists()
}
fn canonical_key(path: &Path) -> Result<PathBuf> {
std::fs::canonicalize(path).map_err(|err| {
SuperGitError::Other(format!(
"Failed to resolve '{}' while walking the submodule tree: {}",
path.display(),
err
))
})
}
fn to_slash(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn parent_remote_url(repo_root: &Path) -> Result<Option<String>> {
let repo = crate::git_backend::open_repo(repo_root).map_err(|err| {
SuperGitError::Other(format!(
"Failed to open '{}' to resolve relative submodule URLs: {}",
repo_root.display(),
err
))
})?;
Ok(crate::git_backend::remote_url(&repo, "origin").ok())
}
pub fn is_relative_submodule_url(url: &str) -> bool {
url.starts_with("./") || url.starts_with("../")
}
pub fn resolve_relative_url(base: &str, relative: &str) -> String {
let base = base.trim_end_matches('/');
let (prefix, path) = split_base(base);
let mut segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for segment in relative.split('/') {
match segment {
"." | "" => {}
".." => {
segments.pop();
}
other => segments.push(other),
}
}
let joined = segments.join("/");
match prefix {
Some(prefix) if prefix.ends_with(':') => format!("{prefix}{joined}"),
Some(prefix) => format!("{prefix}/{joined}"),
None if base.starts_with('/') => format!("/{joined}"),
None => joined,
}
}
fn split_base(base: &str) -> (Option<&str>, &str) {
if let Some(scheme_end) = base.find("://") {
let after_scheme = scheme_end + 3;
return match base[after_scheme..].find('/') {
Some(offset) => {
let split = after_scheme + offset;
(Some(&base[..split]), &base[split + 1..])
}
None => (Some(base), ""),
};
}
if let Some(colon) = base.find(':') {
if colon > 1 && !base[..colon].contains('/') {
return (Some(&base[..=colon]), &base[colon + 1..]);
}
}
(None, base)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_relative_urls_against_https_base() {
assert_eq!(
resolve_relative_url("https://github.com/acme/super", "../core"),
"https://github.com/acme/core"
);
assert_eq!(
resolve_relative_url("https://github.com/acme/super", "./nested"),
"https://github.com/acme/super/nested"
);
assert_eq!(
resolve_relative_url("https://github.com/acme/group/super", "../../other/lib"),
"https://github.com/acme/other/lib"
);
}
#[test]
fn resolves_relative_urls_against_scp_style_base() {
assert_eq!(
resolve_relative_url("git@github.com:acme/super.git", "../core.git"),
"git@github.com:acme/core.git"
);
assert_eq!(
resolve_relative_url("git@github.com:acme/super.git", "./deep.git"),
"git@github.com:acme/super.git/deep.git"
);
}
#[test]
fn resolves_relative_urls_against_a_local_path_base() {
assert_eq!(
resolve_relative_url("/srv/git/super.git", "../core.git"),
"/srv/git/core.git"
);
assert_eq!(
resolve_relative_url("/srv/git/super.git", "./nested.git"),
"/srv/git/super.git/nested.git"
);
assert_eq!(
resolve_relative_url("relative/base", "../sibling"),
"relative/sibling"
);
}
#[test]
fn a_windows_drive_letter_is_not_mistaken_for_an_scp_host() {
assert_eq!(
resolve_relative_url("C:/repos/super.git", "../core.git"),
"C:/repos/core.git"
);
}
#[test]
fn relative_url_detection_only_matches_dot_prefixes() {
assert!(is_relative_submodule_url("../core.git"));
assert!(is_relative_submodule_url("./core.git"));
assert!(!is_relative_submodule_url("https://example.com/core.git"));
assert!(!is_relative_submodule_url("git@example.com:acme/core.git"));
}
#[test]
fn rejects_submodule_paths_that_escape_the_parent() {
let parent = Path::new("/tmp/super");
assert!(validate_submodule_path(Path::new("../outside"), parent).is_err());
assert!(validate_submodule_path(Path::new("libs/../../outside"), parent).is_err());
assert!(validate_submodule_path(Path::new("/etc/passwd"), parent).is_err());
assert!(validate_submodule_path(Path::new(".git/hooks"), parent).is_err());
assert!(validate_submodule_path(Path::new("libs/core"), parent).is_ok());
}
}