wsb 0.93.391653

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
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 {
    /// Parse .gitmodules file from repository root
    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,
        })
    }

    /// Update branch field for a submodule in .gitmodules
    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"));
    }
}