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>,
ignored: Vec<String>,
unmatched_ignore_patterns: Vec<String>,
}
impl SubmoduleTree {
pub fn discover<P: AsRef<Path>>(root: P) -> Result<Self> {
let root = root.as_ref().to_path_buf();
let patterns = ignore_patterns(&root)?;
Self::discover_with_ignores(root, &patterns)
}
pub fn discover_with_ignores<P: AsRef<Path>>(root: P, patterns: &[String]) -> Result<Self> {
let tree = Self::build(root, patterns)?;
if tree.nodes.is_empty() {
if !tree.ignored.is_empty() {
return Err(SuperGitError::Other(format!(
"All {} submodule(s) are excluded by 'ignored_submodules' in the workspace \
at '{}'; relax the pattern(s) to operate on them",
tree.ignored.len(),
tree.root.display()
)));
}
return Err(SuperGitError::NoSubmodules);
}
Ok(tree)
}
fn build<P: AsRef<Path>>(root: P, patterns: &[String]) -> Result<Self> {
let root = root.as_ref().to_path_buf();
let mut nodes = Vec::new();
let mut ignored = Vec::new();
let mut matched = vec![false; patterns.len()];
let mut visited = HashSet::new();
visited.insert(canonical_key(&root)?);
walk(
&root,
None,
0,
patterns,
&mut matched,
&mut ignored,
&mut visited,
&mut nodes,
)?;
let unmatched_ignore_patterns = patterns
.iter()
.zip(&matched)
.filter(|(_, hit)| !**hit)
.map(|(pattern, _)| pattern.clone())
.collect();
Ok(SubmoduleTree {
root,
nodes,
ignored,
unmatched_ignore_patterns,
})
}
pub fn discover_allow_empty<P: AsRef<Path>>(root: P) -> Result<Self> {
let patterns = ignore_patterns(root.as_ref())?;
Self::build(root, &patterns)
}
pub fn ignored(&self) -> &[String] {
&self.ignored
}
pub fn unmatched_ignore_patterns(&self) -> &[String] {
&self.unmatched_ignore_patterns
}
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)
}
}
#[allow(clippy::too_many_arguments)]
fn walk(
current: &Path,
prefix: Option<&str>,
depth: usize,
patterns: &[String],
matched: &mut [bool],
ignored: &mut Vec<String>,
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 mut is_ignored = false;
for (index, pattern) in patterns.iter().enumerate() {
if path_matches_pattern(pattern, &display_path) {
matched[index] = true;
is_ignored = true;
}
}
if is_ignored {
ignored.push(display_path);
continue;
}
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,
patterns,
matched,
ignored,
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> {
crate::platform::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())
}
fn ignore_patterns(root: &Path) -> Result<Vec<String>> {
if !root.join(".wsb").join("state.json").exists() {
return Ok(Vec::new());
}
let state = crate::workspace_state::WorkspaceState::load(root).map_err(|err| {
SuperGitError::Other(format!(
"Failed to read ignored submodules from the workspace at '{}': {}",
root.display(),
err
))
})?;
for pattern in &state.ignored_submodules {
if pattern.trim().is_empty() {
return Err(SuperGitError::Other(format!(
"'ignored_submodules' in the workspace at '{}' contains an empty pattern, which \
would match nothing; remove it or give it a submodule path",
root.display()
)));
}
}
Ok(state.ignored_submodules)
}
pub fn path_matches_pattern(pattern: &str, path: &str) -> bool {
let pattern_segments: Vec<&str> = pattern
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
segments_match(&pattern_segments, &path_segments)
}
fn segments_match(pattern: &[&str], path: &[&str]) -> bool {
match pattern.split_first() {
None => path.is_empty(),
Some((&"**", rest)) => {
(0..=path.len()).any(|skip| segments_match(rest, &path[skip..]))
}
Some((head, rest)) => match path.split_first() {
Some((candidate, path_rest)) if segment_matches(head, candidate) => {
segments_match(rest, path_rest)
}
_ => false,
},
}
}
fn segment_matches(pattern: &str, value: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let value: Vec<char> = value.chars().collect();
let (mut p, mut v) = (0usize, 0usize);
let (mut star, mut backtrack) = (None, 0usize);
while v < value.len() {
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == value[v]) {
p += 1;
v += 1;
} else if p < pattern.len() && pattern[p] == '*' {
star = Some(p);
backtrack = v;
p += 1;
} else if let Some(star_index) = star {
p = star_index + 1;
backtrack += 1;
v = backtrack;
} else {
return false;
}
}
pattern[p..].iter().all(|&ch| ch == '*')
}
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('/');
if base.contains('\\') && split_base(base).0.is_none() {
return resolve_against_windows_path(base.trim_end_matches('\\'), relative);
}
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 resolve_against_windows_path(base: &str, relative: &str) -> String {
let drive = match base.find(['\\', '/']) {
Some(index) if base[..index].ends_with(':') => index,
_ => 0,
};
let mut kept = base.to_string();
let mut added: Vec<&str> = Vec::new();
for segment in relative.split('/') {
match segment {
"." | "" => {}
".." => {
if added.pop().is_none() {
match kept.rfind(['\\', '/']) {
Some(index) if index > drive => kept.truncate(index),
_ => kept.truncate(drive),
}
}
}
other => added.push(other),
}
}
if added.is_empty() {
kept
} else {
format!("{kept}/{}", added.join("/"))
}
}
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 test20045_relative_urls_resolve_against_a_windows_path() {
assert_eq!(
resolve_relative_url(r"C:\work\repos\mid.git", "../leaf.git"),
r"C:\work\repos/leaf.git"
);
assert_eq!(
resolve_relative_url(r"C:\work\repos\mid.git", "./nested"),
r"C:\work\repos\mid.git/nested"
);
assert_eq!(
resolve_relative_url(r"C:\work\mid.git", "../../../leaf.git"),
r"C:/leaf.git",
"`..` stops at the drive"
);
assert_eq!(
resolve_relative_url(r"C:\work\a\mid.git", "../x/../leaf.git"),
r"C:\work\a/leaf.git"
);
}
#[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 ignore_patterns_match_a_whole_directory_of_submodules() {
assert!(path_matches_pattern("ext/*", "ext/gtest"));
assert!(path_matches_pattern("ext/*", "ext/gulrak-filesystem"));
assert!(!path_matches_pattern("ext/*", "libs/core"));
assert!(!path_matches_pattern("ext/*", "ext/a/b"));
assert!(path_matches_pattern("ext/**", "ext/a/b"));
assert!(path_matches_pattern("**", "anything/at/all"));
}
#[test]
fn ignore_patterns_match_exact_paths_and_partial_names() {
assert!(path_matches_pattern("ext/gtest", "ext/gtest"));
assert!(!path_matches_pattern("ext/gtest", "ext/gtest-extra"));
assert!(!path_matches_pattern("gtest", "ext/gtest"));
assert!(path_matches_pattern("ext/g*", "ext/gtest"));
assert!(path_matches_pattern("ext/*test", "ext/gtest"));
assert!(path_matches_pattern("ext/g?est", "ext/gtest"));
assert!(!path_matches_pattern("ext/g?est", "ext/gaaest"));
}
#[test]
fn a_bare_pattern_does_not_accidentally_match_a_subtree() {
assert!(path_matches_pattern("ext", "ext"));
assert!(!path_matches_pattern("ext", "ext/gtest"));
}
#[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());
}
}