wsb 0.153.17

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
use crate::prelude::*;
use crate::fleet::error::{Result, SuperGitError};
use gix::bstr::ByteSlice;
use std::path::{Path, PathBuf};

#[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 {
    /// 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 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,
        })
    }

    /// 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 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,
}

/// Validate that .gitmodules has no key-value pairs outside a section header.
/// Public so other modules (e.g. git_backend) can call it before handing the file to gix.
/// Git silently ignores such lines; strict parsers (gix) reject them.
/// We fail hard here and tell the user exactly what to fix.
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();
        // Real-world .gitmodules with dangling key-value lines before the first header.
        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"));
    }
}