use crate::prelude::*;
use crate::fleet::error::{Result, SuperGitError};
use gix::bstr::ByteSlice;
#[derive(Debug, Clone)]
pub struct Submodule {
pub name: String,
pub path: PathBuf,
pub url: String,
pub branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitConfigEntry {
pub key: String,
pub value: String,
}
#[derive(Debug)]
pub struct GitModules {
pub entries: Vec<GitConfigEntry>,
pub submodules: Vec<Submodule>,
}
impl GitModules {
pub fn parse<P: AsRef<Path>>(repo_root: P) -> Result<Self> {
let gitmodules_path = repo_root.as_ref().join(".gitmodules");
if !gitmodules_path.exists() {
return Err(SuperGitError::NoSubmodules);
}
let bytes = std::fs::read(&gitmodules_path).map_err(|err| {
SuperGitError::GitModulesParse(format!(
"failed to read {}: {}",
gitmodules_path.display(),
err
))
})?;
validate_gitmodules_structure(&bytes, &gitmodules_path)?;
let config = gix::config::File::from_bytes_no_includes(
&bytes,
gix::config::file::Metadata::from(gix::config::Source::Local),
Default::default(),
)
.map_err(|err| {
SuperGitError::GitModulesParse(format!(
"{}\n\nFix: ensure every key-value pair in {} is inside a [section] header.",
err,
gitmodules_path.display()
))
})?;
let modules = gix::submodule::File::from_bytes(
&bytes,
Some(gitmodules_path.clone()),
&gix::config::File::default(),
)
.map_err(|err| {
SuperGitError::GitModulesParse(format!(
"{}\n\nFix: ensure every key-value pair in {} is inside a [section] header.",
err,
gitmodules_path.display()
))
})?;
let mut raw_entries = Vec::new();
let mut submodules = std::collections::BTreeMap::<String, Submodule>::new();
for section in config.sections() {
let header = section.header();
let section_name = header.name();
let subsection = header
.subsection_name()
.map(|value| value.to_str_lossy().into_owned());
for (key, value) in section.body().clone() {
let full_key = match &subsection {
Some(subsection) => format!("{section_name}.{subsection}.{}", key.as_ref()),
None => format!("{section_name}.{}", key.as_ref()),
};
let value = value.to_str_lossy().into_owned();
raw_entries.push(GitConfigEntry {
key: full_key.clone(),
value: value.clone(),
});
if let GitModulesKey::Submodule { name, field } = parse_gitmodules_key(&full_key) {
let submodule =
submodules
.entry(name.to_string())
.or_insert_with(|| Submodule {
name: name.to_string(),
path: PathBuf::new(),
url: String::new(),
branch: None,
});
match field {
"path" => submodule.path = PathBuf::from(&value),
"url" => submodule.url = value,
"branch" => submodule.branch = Some(value),
_ => unreachable!("unsupported submodule field filtered earlier"),
}
}
}
}
for name in modules.names() {
let name = name.to_str_lossy();
let submodule = submodules
.entry(name.to_string())
.or_insert_with(|| Submodule {
name: name.to_string(),
path: PathBuf::new(),
url: String::new(),
branch: None,
});
if submodule.path.as_os_str().is_empty() {
submodule.path = PathBuf::from(
modules
.path(name.as_bytes().as_bstr())
.map_err(|err| SuperGitError::GitModulesParse(err.to_string()))?
.to_str_lossy()
.into_owned(),
);
}
if submodule.url.is_empty() {
submodule.url = modules
.url(name.as_bytes().as_bstr())
.map_err(|err| SuperGitError::GitModulesParse(err.to_string()))?
.to_bstring()
.to_str_lossy()
.into_owned();
}
if submodule.branch.is_none() {
submodule.branch = modules
.branch(name.as_bytes().as_bstr())
.map_err(|err| SuperGitError::GitModulesParse(err.to_string()))?
.map(|branch| match branch {
gix::submodule::config::Branch::CurrentInSuperproject => ".".to_string(),
gix::submodule::config::Branch::Name(name) => {
name.to_str_lossy().into_owned()
}
});
}
}
let submodules: Vec<Submodule> = submodules
.into_values()
.map(|submodule| {
if submodule.path.as_os_str().is_empty() {
return Err(SuperGitError::GitModulesParse(format!(
"submodule '{}' is missing path",
submodule.name
)));
}
if submodule.url.is_empty() {
return Err(SuperGitError::GitModulesParse(format!(
"submodule '{}' is missing url",
submodule.name
)));
}
Ok(submodule)
})
.collect::<Result<Vec<_>>>()?;
if submodules.is_empty() {
return Err(SuperGitError::NoSubmodules);
}
Ok(GitModules {
entries: raw_entries,
submodules,
})
}
pub fn update_branch<P: AsRef<Path>>(
repo_root: P,
submodule_path: &str,
branch: &str,
) -> Result<()> {
let gitmodules_path = repo_root.as_ref().join(".gitmodules");
let submodule = Self::parse(repo_root.as_ref())?
.submodules
.into_iter()
.find(|submodule| submodule.path == PathBuf::from(submodule_path))
.ok_or_else(|| SuperGitError::SubmoduleNotFound(submodule_path.to_string()))?;
let bytes = std::fs::read(&gitmodules_path).map_err(|err| {
SuperGitError::GitModulesParse(format!(
"failed to read {}: {}",
gitmodules_path.display(),
err
))
})?;
validate_gitmodules_structure(&bytes, &gitmodules_path)?;
let mut config = gix::config::File::from_bytes_no_includes(
&bytes,
gix::config::file::Metadata::from(gix::config::Source::Local),
Default::default(),
)
.map_err(|err| SuperGitError::GitModulesParse(err.to_string()))?;
config
.set_raw_value_by(
"submodule",
Some(submodule.name.as_str().as_bytes().as_bstr()),
"branch",
branch,
)
.map_err(|err| SuperGitError::GitModulesParse(err.to_string()))?;
let mut serialized = Vec::new();
config
.write_to(&mut serialized)
.map_err(|err| SuperGitError::GitModulesParse(err.to_string()))?;
std::fs::write(&gitmodules_path, serialized).map_err(|err| {
SuperGitError::GitModulesParse(format!(
"failed to write {}: {}",
gitmodules_path.display(),
err
))
})?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GitModulesKey<'a> {
Submodule { name: &'a str, field: &'a str },
Other,
}
pub fn validate_gitmodules_structure(bytes: &[u8], path: &std::path::Path) -> Result<()> {
let mut in_section = false;
let mut orphaned: Vec<(usize, String)> = Vec::new();
for (line_num, line) in bytes.split(|&b| b == b'\n').enumerate() {
let trimmed_start = line
.iter()
.position(|&b| b != b' ' && b != b'\t')
.map(|i| &line[i..])
.unwrap_or(line);
if trimmed_start.is_empty() || trimmed_start.starts_with(b"#") {
continue;
}
if trimmed_start.starts_with(b"[") {
in_section = true;
continue;
}
if !in_section {
orphaned.push((
line_num + 1,
String::from_utf8_lossy(line).trim().to_string(),
));
}
}
if !orphaned.is_empty() {
let lines_display = orphaned
.iter()
.map(|(n, l)| format!(" line {}: {}", n, l))
.collect::<Vec<_>>()
.join("\n");
return Err(SuperGitError::GitModulesParse(format!(
"{path} has key-value entries before the first section header — this is invalid git-config syntax:\n{lines_display}\n\nFix: remove or move these lines inside a [section] header in {path}.",
path = path.display(),
lines_display = lines_display,
)));
}
Ok(())
}
fn parse_gitmodules_key(key: &str) -> GitModulesKey<'_> {
let Some(remainder) = key.strip_prefix("submodule.") else {
return GitModulesKey::Other;
};
let Some((name, field)) = remainder.rsplit_once('.') else {
return GitModulesKey::Other;
};
match field {
"path" | "url" | "branch" => GitModulesKey::Submodule { name, field },
_ => GitModulesKey::Other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_uses_git_config_semantics_for_quoted_submodule_names() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join(".gitmodules"),
r#"[submodule "libs/core.api"]
path = libs/core
url = git@github.com:example/core.git
branch = main
"#,
)
.unwrap();
let parsed = GitModules::parse(temp_dir.path()).unwrap();
assert_eq!(
parsed.entries,
vec![
GitConfigEntry {
key: "submodule.libs/core.api.path".to_string(),
value: "libs/core".to_string(),
},
GitConfigEntry {
key: "submodule.libs/core.api.url".to_string(),
value: "git@github.com:example/core.git".to_string(),
},
GitConfigEntry {
key: "submodule.libs/core.api.branch".to_string(),
value: "main".to_string(),
},
]
);
assert_eq!(parsed.submodules.len(), 1);
assert_eq!(parsed.submodules[0].name, "libs/core.api");
assert_eq!(parsed.submodules[0].path, PathBuf::from("libs/core"));
assert_eq!(parsed.submodules[0].url, "git@github.com:example/core.git");
assert_eq!(parsed.submodules[0].branch.as_deref(), Some("main"));
}
#[test]
fn update_branch_writes_with_git_config() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join(".gitmodules"),
r#"[submodule "core"]
path = libs/core
url = git@github.com:example/core.git
"#,
)
.unwrap();
GitModules::update_branch(temp_dir.path(), "libs/core", "develop").unwrap();
let parsed = GitModules::parse(temp_dir.path()).unwrap();
assert_eq!(parsed.submodules[0].branch.as_deref(), Some("develop"));
}
#[test]
fn parse_fails_with_diagnostic_on_orphaned_lines_before_first_section_header() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join(".gitmodules"),
"\tbranch = unification\n\tbranch = main\n[submodule \"core\"]\n\tpath = libs/core\n\turl = git@github.com:example/core.git\n\tbranch = main\n",
)
.unwrap();
let err = GitModules::parse(temp_dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("key-value entries before the first section header"),
"error should diagnose the problem: {}",
msg
);
assert!(
msg.contains("line 1"),
"error should name the offending line: {}",
msg
);
assert!(msg.contains("Fix:"), "error should suggest a fix: {}", msg);
}
#[test]
fn parse_preserves_non_submodule_git_config_entries() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join(".gitmodules"),
r#"[branch "unification"]
main = llmcaps
[submodule "core"]
path = libs/core
url = git@github.com:example/core.git
branch = main
"#,
)
.unwrap();
let parsed = GitModules::parse(temp_dir.path()).unwrap();
assert_eq!(
parsed.entries[0],
GitConfigEntry {
key: "branch.unification.main".to_string(),
value: "llmcaps".to_string(),
}
);
assert_eq!(parsed.submodules.len(), 1);
assert_eq!(parsed.submodules[0].name, "core");
assert_eq!(parsed.submodules[0].branch.as_deref(), Some("main"));
}
}