use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::client::parse_github_url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repository {
pub name: String,
pub path: PathBuf,
pub head: Option<String>,
}
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}"))
}
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);
if stderr.contains("No such remote") {
Ok(None)
} else {
bail!("git could not read the origin of {}: {}", dir.display(), stderr.trim())
}
}
}
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()
),
}
}
fn is_checked_out(dir: &Path) -> bool {
dir.join(".git").exists()
}
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) => {}
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)
}
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");
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("/")))
}
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)
);
}
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");
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())),
("example/core", Some(core_head.as_str())),
("example/nested", None),
("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());
}
#[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")
);
assert_eq!(
resolve_url("https://github.com/other/thing", None).as_deref(),
Some("https://github.com/other/thing")
);
assert_eq!(resolve_url("../core", None), None);
assert_eq!(
resolve_url("../../../x", Some("https://github.com/example/workspace")),
None
);
}
}