use crate::fleet::error::{Result, SuperGitError};
use std::path::{Path, PathBuf};
use std::process::Command;
#[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,
}
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 output = Command::new("git")
.arg("config")
.arg("-z")
.arg("-f")
.arg(&gitmodules_path)
.arg("--list")
.current_dir(repo_root.as_ref())
.output()?;
if !output.status.success() {
return Err(SuperGitError::GitModulesParse(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
let mut raw_entries = Vec::new();
let mut submodules = std::collections::BTreeMap::<String, Submodule>::new();
for entry in output
.stdout
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
{
let entry = String::from_utf8(entry.to_vec())
.map_err(|e| SuperGitError::GitModulesParse(e.to_string()))?;
let (key, value) = entry
.split_once('\n')
.ok_or_else(|| SuperGitError::GitModulesParse(format!("invalid git config entry: {}", entry)))?;
raw_entries.push(GitConfigEntry {
key: key.to_string(),
value: value.to_string(),
});
if let GitModulesKey::Submodule { name, field } = parse_gitmodules_key(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.to_string(),
"branch" => submodule.branch = Some(value.to_string()),
_ => unreachable!("unsupported submodule field filtered earlier"),
}
}
}
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 output = Command::new("git")
.arg("config")
.arg("-f")
.arg(&gitmodules_path)
.arg(format!("submodule.{}.branch", submodule.name))
.arg(branch)
.current_dir(repo_root.as_ref())
.output()?;
if !output.status.success() {
return Err(SuperGitError::GitModulesParse(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GitModulesKey<'a> {
Submodule { name: &'a str, field: &'a str },
Other,
}
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 std::process::Command;
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 output = Command::new("git")
.arg("config")
.arg("-f")
.arg(temp_dir.path().join(".gitmodules"))
.arg("--get")
.arg("submodule.core.branch")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "develop");
}
#[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"));
}
}