wsb-github 0.253.6349

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
//! What one checkout holds: the GitHub repositories in it, and the commit each
//! is checked out at.
//!
//! Read from git and from the declarations git reads — the superproject's
//! remote and every `.gitmodules` in the nesting tree — never from a list kept
//! beside them. A submodule added this morning has issues too, and one removed
//! stops being asked about.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};

use crate::client::parse_github_url;

/// One repository of a checkout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repository {
    /// `owner/repo` on GitHub.
    pub name: String,
    /// Where it is checked out in this checkout.
    pub path: PathBuf,
    /// The commit it is on, when it is checked out at all. A submodule that
    /// was never initialised is declared and has no commit.
    pub head: Option<String>,
}

/// A git command aimed only by the directory it runs in.
///
/// The variables git reads to decide WHICH repository it is acting on are
/// cleared: a caller running inside a hook, or inside another git command,
/// would otherwise aim every one of these at the caller's repository.
fn git(dir: &Path) -> Command {
    let mut command = Command::new("git");
    command.arg("-C").arg(dir);
    for variable in [
        "GIT_DIR",
        "GIT_WORK_TREE",
        "GIT_INDEX_FILE",
        "GIT_COMMON_DIR",
        "GIT_OBJECT_DIRECTORY",
        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
        "GIT_NAMESPACE",
        "GIT_PREFIX",
    ] {
        command.env_remove(variable);
    }
    command
}

fn output(mut command: Command, what: &str) -> Result<std::process::Output> {
    command
        .output()
        .with_context(|| format!("cannot run git to {what}"))
}

/// The URL of a repository's `origin`, or `None` when it has none.
pub fn origin_url(dir: &Path) -> Result<Option<String>> {
    let mut command = git(dir);
    command.args(["remote", "get-url", "origin"]);
    let out = output(command, &format!("read the origin of {}", dir.display()))?;
    if out.status.success() {
        Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_string()))
    } else {
        let stderr = String::from_utf8_lossy(&out.stderr);
        // "No such remote" is an answer: this repository has no origin.
        if stderr.contains("No such remote") {
            Ok(None)
        } else {
            bail!("git could not read the origin of {}: {}", dir.display(), stderr.trim())
        }
    }
}

/// The commit a checkout is on, or `None` when it has none yet.
///
/// A repository with no commits — HEAD names a branch nothing is on — is a
/// state, not a failure: there is simply no commit to ask CI about.
pub fn head(dir: &Path) -> Result<Option<String>> {
    let mut command = git(dir);
    command.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
    let out = output(command, &format!("read the commit of {}", dir.display()))?;
    match out.status.code() {
        Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_string())),
        Some(1) if out.stdout.is_empty() => Ok(None),
        _ => bail!(
            "git could not read the commit of {}: {}",
            dir.display(),
            String::from_utf8_lossy(&out.stderr).trim()
        ),
    }
}

/// Whether a folder is a checkout of its own: a repository or a worktree,
/// whose `.git` is a folder or a file pointing at one.
fn is_checked_out(dir: &Path) -> bool {
    dir.join(".git").exists()
}

/// The submodules one repository declares: name, path and URL.
fn declared_submodules(dir: &Path) -> Result<Vec<(String, String)>> {
    if !dir.join(".gitmodules").is_file() {
        return Ok(Vec::new());
    }
    let mut command = git(dir);
    command.args([
        "config",
        "--file",
        ".gitmodules",
        "--get-regexp",
        r"^submodule\..*\.(path|url)$",
    ]);
    let out = output(command, &format!("read {}/.gitmodules", dir.display()))?;
    match out.status.code() {
        Some(0) => {}
        // `--get-regexp` answers 1 when nothing matched: a .gitmodules that
        // declares no submodules.
        Some(1) => return Ok(Vec::new()),
        _ => bail!(
            "git could not read {}/.gitmodules: {}",
            dir.display(),
            String::from_utf8_lossy(&out.stderr).trim()
        ),
    }
    let mut paths: BTreeMap<String, String> = BTreeMap::new();
    let mut urls: BTreeMap<String, String> = BTreeMap::new();
    for line in String::from_utf8_lossy(&out.stdout).lines() {
        let Some((key, value)) = line.split_once(' ') else {
            continue;
        };
        let Some(rest) = key.strip_prefix("submodule.") else {
            continue;
        };
        if let Some(name) = rest.strip_suffix(".path") {
            paths.insert(name.to_string(), value.to_string());
        } else if let Some(name) = rest.strip_suffix(".url") {
            urls.insert(name.to_string(), value.to_string());
        }
    }
    let mut declared = Vec::new();
    for (name, path) in paths {
        let url = urls.remove(&name).ok_or_else(|| {
            anyhow::anyhow!(
                "{}/.gitmodules declares submodule {name:?} with a path and no url",
                dir.display()
            )
        })?;
        declared.push((path, url));
    }
    Ok(declared)
}

/// A submodule URL as git resolves it: a relative one is relative to the URL
/// of the repository that declares it.
fn resolve_url(declared: &str, parent: Option<&str>) -> Option<String> {
    if !(declared.starts_with("./") || declared.starts_with("../")) {
        return Some(declared.to_string());
    }
    let parent = parent?.trim_end_matches('/').trim_end_matches(".git");
    // Split the parent into the part before its path and its path segments,
    // for the two forms git writes: `scheme://host/path` and `host:path`.
    let (prefix, path) = if let Some(index) = parent.find("://") {
        let after = &parent[index + 3..];
        let slash = after.find('/')?;
        (&parent[..index + 3 + slash + 1], &after[slash + 1..])
    } else {
        let colon = parent.find(':')?;
        (&parent[..colon + 1], &parent[colon + 1..])
    };
    let mut segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
    for part in declared.split('/') {
        match part {
            "." | "" => {}
            ".." => {
                segments.pop()?;
            }
            other => segments.push(other),
        }
    }
    Some(format!("{prefix}{}", segments.join("/")))
}

/// Every GitHub repository in a checkout: the superproject and every
/// submodule in its nesting tree, parent before child.
///
/// A submodule's repository is the one its declaration names, whether or not
/// it is checked out: a repository declared and never initialised still has
/// issues. Its own submodules are known only once it is checked out, because
/// that is where they are declared.
pub fn repositories(root: &Path) -> Result<Vec<Repository>> {
    let mut found = Vec::new();
    let origin = origin_url(root)?;
    if let Some((owner, repo)) = origin.as_deref().and_then(parse_github_url) {
        found.push(Repository {
            name: format!("{owner}/{repo}"),
            path: root.to_path_buf(),
            head: head(root)?,
        });
    }
    walk(root, origin.as_deref(), &mut found)?;
    Ok(found)
}

fn walk(dir: &Path, parent_url: Option<&str>, found: &mut Vec<Repository>) -> Result<()> {
    for (path, declared_url) in declared_submodules(dir)? {
        let child = dir.join(&path);
        let url = resolve_url(&declared_url, parent_url);
        let checked_out = is_checked_out(&child);
        if let Some((owner, repo)) = url.as_deref().and_then(parse_github_url) {
            found.push(Repository {
                name: format!("{owner}/{repo}"),
                path: child.clone(),
                head: if checked_out { head(&child)? } else { None },
            });
        }
        if checked_out {
            walk(&child, url.as_deref(), found)?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn run(dir: &Path, args: &[&str]) {
        let out = git(dir).args(args).output().expect("git runs");
        assert!(
            out.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }

    /// A repository with one commit and, when given, an origin.
    fn repository(dir: &Path, origin: Option<&str>) -> String {
        std::fs::create_dir_all(dir).expect("a folder");
        run(dir, &["init", "-q", "-b", "main"]);
        run(dir, &["config", "user.email", "wsb-github-tests@example.com"]);
        run(dir, &["config", "user.name", "wsb-github tests"]);
        std::fs::write(dir.join("README.md"), "a repository\n").expect("a file");
        run(dir, &["add", "README.md"]);
        run(dir, &["commit", "-q", "-m", "init"]);
        if let Some(origin) = origin {
            run(dir, &["remote", "add", "origin", origin]);
        }
        head(dir).expect("readable").expect("a commit")
    }

    #[test]
    fn a_checkout_names_every_github_repository_in_its_tree() {
        let temp = tempfile::tempdir().expect("a scratch directory");
        let root = temp.path().join("workspace");
        let root_head = repository(&root, Some("git@github.com:example/workspace.git"));
        std::fs::write(
            root.join(".gitmodules"),
            "[submodule \"core\"]\n\tpath = libs/core\n\turl = ../core.git\n\
             [submodule \"data\"]\n\tpath = data\n\turl = https://huggingface.co/datasets/example/data\n\
             [submodule \"later\"]\n\tpath = libs/later\n\turl = https://github.com/example/later\n",
        )
        .expect("declared");
        // `core` is checked out and declares a submodule of its own; `later`
        // is declared and was never initialised.
        let core = root.join("libs/core");
        let core_head = repository(&core, Some("git@github.com:example/core.git"));
        std::fs::write(
            core.join(".gitmodules"),
            "[submodule \"nested\"]\n\tpath = nested\n\turl = git@github.com:example/nested.git\n",
        )
        .expect("declared");

        let found = repositories(&root).expect("read");
        let named: Vec<(&str, Option<&str>)> = found
            .iter()
            .map(|repository| (repository.name.as_str(), repository.head.as_deref()))
            .collect();
        assert_eq!(
            named,
            vec![
                ("example/workspace", Some(root_head.as_str())),
                // Relative to the workspace's own URL, as git resolves it.
                ("example/core", Some(core_head.as_str())),
                // Declared inside a submodule, so known because it is checked out.
                ("example/nested", None),
                // Declared and never initialised: a repository, with no commit.
                ("example/later", None),
            ],
            "the Hugging Face submodule is not on GitHub and must not be listed"
        );
    }

    #[test]
    fn a_superproject_off_github_still_has_its_github_submodules() {
        let temp = tempfile::tempdir().expect("a scratch directory");
        let root = temp.path().join("workspace");
        repository(&root, Some("https://huggingface.co/datasets/example/workspace"));
        std::fs::write(
            root.join(".gitmodules"),
            "[submodule \"core\"]\n\tpath = core\n\turl = git@github.com:example/core.git\n",
        )
        .expect("declared");
        let names: Vec<String> = repositories(&root)
            .expect("read")
            .into_iter()
            .map(|repository| repository.name)
            .collect();
        assert_eq!(names, vec!["example/core".to_string()]);
    }

    #[test]
    fn a_repository_with_no_origin_and_no_commit_is_read_not_refused() {
        let temp = tempfile::tempdir().expect("a scratch directory");
        let root = temp.path().join("fresh");
        std::fs::create_dir_all(&root).expect("a folder");
        run(&root, &["init", "-q", "-b", "main"]);
        assert_eq!(head(&root).expect("readable"), None, "an unborn HEAD has no commit");
        assert_eq!(origin_url(&root).expect("readable"), None);
        assert!(repositories(&root).expect("read").is_empty());
    }

    /// TEST12911: a submodule is found by the repository its declaration names,
    /// whatever its clone's `origin` still says.
    ///
    /// A repository renamed on GitHub after it was cloned keeps the old URL in
    /// its clone, which GitHub redirects, while the declaration has the new
    /// one. Looked up by `origin`, its checks were "not checked out in this
    /// workspace" in a workspace that had it checked out (18 Oct 2026).
    #[test]
    fn test12911_a_renamed_submodule_is_found_by_its_declared_name() {
        let temp = tempfile::tempdir().expect("a scratch directory");
        let root = temp.path().join("workspace");
        repository(&root, Some("https://github.com/example/workspace.git"));
        std::fs::write(
            root.join(".gitmodules"),
            "[submodule \"core\"]\n\tpath = libs/core\n\turl = https://github.com/example/core.git\n",
        )
        .expect("declared");
        let head = repository(&root.join("libs/core"), Some("https://github.com/example/core-old.git"));

        let found = repositories(&root).expect("read");
        let core = found
            .iter()
            .find(|repository| repository.name == "example/core")
            .expect("the declared name finds the checkout");
        assert_eq!(
            core.head.as_deref(),
            Some(head.as_str()),
            "the checks of a submodule are asked at the commit its checkout is on"
        );
        assert!(
            !found.iter().any(|repository| repository.name == "example/core-old"),
            "the name the clone was made under is not one the workspace declares: {found:?}"
        );
    }

    #[test]
    fn a_relative_submodule_url_resolves_against_the_declaring_repository() {
        assert_eq!(
            resolve_url("../core.git", Some("https://github.com/example/workspace.git")).as_deref(),
            Some("https://github.com/example/core.git")
        );
        assert_eq!(
            resolve_url("../core", Some("git@github.com:example/workspace")).as_deref(),
            Some("git@github.com:example/core")
        );
        assert_eq!(
            resolve_url("./nested", Some("https://github.com/example/workspace")).as_deref(),
            Some("https://github.com/example/workspace/nested")
        );
        // An absolute URL is taken as it stands, parent or not.
        assert_eq!(
            resolve_url("https://github.com/other/thing", None).as_deref(),
            Some("https://github.com/other/thing")
        );
        // A relative URL with nothing to be relative to cannot be resolved,
        // and is not guessed at.
        assert_eq!(resolve_url("../core", None), None);
        // Nor can one that climbs above the host.
        assert_eq!(
            resolve_url("../../../x", Some("https://github.com/example/workspace")),
            None
        );
    }
}