diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs index a7f9b32b..c93c26f9 100644 --- a/crates/hammer-build/src/fetch.rs +++ b/crates/hammer-build/src/fetch.rs @@ -119,9 +119,22 @@ fn fetch_git( } std::fs::create_dir_all(&work_tree)?; + archivar_en(&mirror, commit, &work_tree)?; + + // Un `git archive` NO emite nada por una entrada gitlink (modo 160000), así que un árbol con + // submódulos sale INCOMPLETO y el fallo aparece recién en `configure`, minutos después y + // hablando de un fichero que "no existe". Los materializamos acá, con el mismo mecanismo y el + // mismo determinismo: un submódulo ya viene pineado por SHA en el commit del padre (ADR 0006). + materializar_submodulos(&mirror, commit, &work_tree, &repos_dir, repo, 0)?; + + Ok(work_tree) +} + +/// `git archive | tar -x -C `: materializa UN árbol sin pagar un worktree. +fn archivar_en(mirror: &Path, commit: &str, destino: &Path) -> hammer_core::Result<()> { let archive = Command::new("git") .arg("-C") - .arg(&mirror) + .arg(mirror) .args(["archive", "--format=tar", commit]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -130,7 +143,7 @@ fn fetch_git( let tar_status = Command::new("tar") .args(["-x", "-C"]) - .arg(&work_tree) + .arg(destino) .stdin(archive.stdout.unwrap()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -143,7 +156,231 @@ fn fetch_git( tar_status.code() ))); } - Ok(work_tree) + Ok(()) +} + +/// Cuántos niveles de submódulo anidado aceptamos antes de declarar un ciclo. +const PROFUNDIDAD_SUBMODULOS: usize = 8; + +/// Materializa los **submódulos** del commit dentro del árbol ya extraído, recursivamente. +/// +/// # Por qué no alcanza un flag en el `clone` +/// +/// El que pierde los submódulos no es el clon sino `git archive`: una entrada gitlink no emite +/// nada al tar, por diseño. `--recurse-submodules` en el `clone` no cambiaría eso. Hay que leer +/// `.gitmodules` + los SHA de gitlink del commit, mirrorear cada submódulo y archivarlo en su +/// subruta — que es exactamente lo que hace esta función. +/// +/// # Por qué reescribe la URL +/// +/// `.gitmodules` suele declarar `git@github.com:X/Y.git`, que es SSH; nuestro fetch es anónimo y +/// no tiene claves. La forma `https://github.com/X/Y.git` apunta al MISMO repo y el commit está +/// pineado por SHA, así que el contenido no puede diferir: reescribirla no añade confianza que +/// verificar. Las URLs relativas (`../foo.git`) se resuelven contra la del padre, como hace git. +fn materializar_submodulos( + mirror: &Path, + commit: &str, + destino: &Path, + repos_dir: &Path, + url_padre: &str, + profundidad: usize, +) -> hammer_core::Result<()> { + let gitlinks = gitlinks_de(mirror, commit)?; + if gitlinks.is_empty() { + return Ok(()); + } + if profundidad >= PROFUNDIDAD_SUBMODULOS { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "submódulos anidados más de {PROFUNDIDAD_SUBMODULOS} niveles en {url_padre} \ + ({commit}): parece un ciclo" + ))); + } + + let urls = urls_de_gitmodules(mirror, commit)?; + + for (sha, ruta) in gitlinks { + let declarada = urls.get(&ruta).ok_or_else(|| { + hammer_core::Error::Other(anyhow::anyhow!( + "el commit {commit} declara el submódulo `{ruta}` pero .gitmodules no le da url" + )) + })?; + let url = resolver_url_submodulo(declarada, url_padre); + + let sub_mirror = repos_dir.join(format!("sub-{}.git", sanear_para_ruta(&url))); + if !sub_mirror.is_dir() { + run_git( + &[ + "clone", + "--mirror", + "--filter=blob:none", + &url, + sub_mirror.to_str().unwrap(), + ], + None, + )?; + } + if run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).is_err() { + run_git(&["fetch", "--all", "--tags"], Some(&sub_mirror))?; + run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).map_err(|_| { + hammer_core::Error::Other(anyhow::anyhow!( + "el submódulo `{ruta}` pide {sha}, que no existe en {url} tras fetch" + )) + })?; + } + + let destino_sub = destino.join(&ruta); + std::fs::create_dir_all(&destino_sub)?; + archivar_en(&sub_mirror, &sha, &destino_sub)?; + materializar_submodulos( + &sub_mirror, + &sha, + &destino_sub, + repos_dir, + &url, + profundidad + 1, + )?; + } + Ok(()) +} + +/// Las entradas de tipo `commit` (modo 160000) del commit: `(sha, ruta)`. +/// +/// `-z` porque una ruta puede llevar espacios y el formato sin `-z` los escapa con comillas. +fn gitlinks_de(mirror: &Path, commit: &str) -> hammer_core::Result> { + let salida = salida_git(&["ls-tree", "-r", "-z", commit], mirror)?; + let mut out = Vec::new(); + for reg in salida.split('\0').filter(|r| !r.is_empty()) { + let Some((meta, ruta)) = reg.split_once('\t') else { + continue; + }; + let campos: Vec<&str> = meta.split_whitespace().collect(); + // + if campos.len() == 3 && campos[1] == "commit" { + out.push((campos[2].to_string(), ruta.to_string())); + } + } + Ok(out) +} + +/// `ruta → url` leído del `.gitmodules` **del commit** (no del working tree, que no existe). +/// +/// Si el commit no trae `.gitmodules`, devuelve vacío: eso es legítimo (un gitlink sin declarar es +/// un error, pero lo reporta quien lo consulta, con el nombre del submódulo a la vista). +fn urls_de_gitmodules( + mirror: &Path, + commit: &str, +) -> hammer_core::Result> { + let blob = format!("{commit}:.gitmodules"); + let salida = match salida_git( + &[ + "config", + "--blob", + &blob, + "-z", + "--get-regexp", + "^submodule\\..*\\.(path|url)$", + ], + mirror, + ) { + Ok(s) => s, + Err(_) => return Ok(Default::default()), + }; + + let mut rutas: std::collections::BTreeMap = Default::default(); + let mut urls: std::collections::BTreeMap = Default::default(); + for reg in salida.split('\0').filter(|r| !r.is_empty()) { + // Con `-z` cada registro es `clave\nvalor`. + let Some((clave, valor)) = reg.split_once('\n') else { + continue; + }; + let Some(resto) = clave.strip_prefix("submodule.") else { + continue; + }; + // El NOMBRE del submódulo puede llevar puntos, así que se parte por el último. + let Some((nombre, campo)) = resto.rsplit_once('.') else { + continue; + }; + match campo { + "path" => { + rutas.insert(nombre.to_string(), valor.to_string()); + } + "url" => { + urls.insert(nombre.to_string(), valor.to_string()); + } + _ => {} + } + } + + Ok(rutas + .into_iter() + .filter_map(|(nombre, ruta)| urls.get(&nombre).map(|u| (ruta, u.clone()))) + .collect()) +} + +/// SSH → HTTPS y relativa → absoluta contra la URL del padre. El resto pasa tal cual. +fn resolver_url_submodulo(declarada: &str, url_padre: &str) -> String { + if let Some(resto) = declarada.strip_prefix("git@") { + if let Some((host, ruta)) = resto.split_once(':') { + return format!("https://{host}/{ruta}"); + } + } + if let Some(resto) = declarada.strip_prefix("ssh://git@") { + return format!("https://{resto}"); + } + if declarada.starts_with("./") || declarada.starts_with("../") { + return resolver_relativa(declarada, url_padre); + } + declarada.to_string() +} + +/// `../x` sube un segmento de la URL del padre; `./x` se queda en el mismo nivel. +fn resolver_relativa(rel: &str, url_padre: &str) -> String { + // git resuelve la relativa tratando la URL del padre como un DIRECTORIO: `../l10n.git` sobre + // `…/BrowserWorks/waterfox.git` da `…/BrowserWorks/l10n.git`, o sea que `..` se come el nombre + // del propio repo. Por eso NO se popea nada antes del bucle. + let mut base: Vec<&str> = url_padre.trim_end_matches('/').split('/').collect(); + for seg in rel.split('/') { + match seg { + "." | "" => {} + ".." => { + base.pop(); + } + otro => base.push(otro), + } + } + base.join("/") +} + +/// Un nombre de directorio estable y legible para el mirror de una URL. +fn sanear_para_ruta(url: &str) -> String { + url.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect() +} + +/// `git ` capturando stdout. Error si el comando falla. +fn salida_git(args: &[&str], cwd: &Path) -> hammer_core::Result { + let out = Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?; + if !out.status.success() { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "git {args:?} falló (exit {:?})", + out.status.code() + ))); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// Los orígenes declarados en `var` (**lista** separada por comas), **rotados** por `clave`. @@ -606,6 +843,117 @@ pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> { mod tests { use super::*; + #[test] + fn resolver_url_reescribe_ssh_a_https() { + assert_eq!( + resolver_url_submodulo("git@github.com:BrowserWorks/l10n.git", "https://x/y.git"), + "https://github.com/BrowserWorks/l10n.git" + ); + assert_eq!( + resolver_url_submodulo("ssh://git@gitlab.com/a/b.git", "https://x/y.git"), + "https://gitlab.com/a/b.git" + ); + // Una https ya buena no se toca. + assert_eq!( + resolver_url_submodulo("https://github.com/a/b.git", "https://x/y.git"), + "https://github.com/a/b.git" + ); + } + + #[test] + fn resolver_url_resuelve_relativas_contra_el_padre() { + // `..` sube desde el DIRECTORIO que contiene al padre, como hace git. + assert_eq!( + resolver_url_submodulo("../l10n.git", "https://github.com/BrowserWorks/waterfox.git"), + "https://github.com/BrowserWorks/l10n.git" + ); + assert_eq!( + resolver_url_submodulo("./sub.git", "https://github.com/o/r.git"), + "https://github.com/o/r.git/sub.git" + ); + } + + /// El caso que mató a waterfox: un árbol con un gitlink debe llegar COMPLETO. + #[test] + fn materializa_un_submodulo_real() { + let raiz = tempfile::tempdir().unwrap(); + let git = |args: &[&str], cwd: &Path| { + let st = Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(st.success(), "git {args:?} falló"); + }; + + // Un repo `hijo` con un fichero, y un `padre` que lo monta como submódulo. + let hijo = raiz.path().join("hijo"); + std::fs::create_dir_all(&hijo).unwrap(); + git(&["init", "-q", "-b", "main"], &hijo); + std::fs::write(hijo.join("moz.build"), "DEL SUBMODULO\n").unwrap(); + git(&["add", "moz.build"], &hijo); + git(&["commit", "-qm", "hijo"], &hijo); + + let padre = raiz.path().join("padre"); + std::fs::create_dir_all(&padre).unwrap(); + git(&["init", "-q", "-b", "main"], &padre); + std::fs::write(padre.join("raiz.txt"), "DEL PADRE\n").unwrap(); + git(&["add", "raiz.txt"], &padre); + git( + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + hijo.to_str().unwrap(), + "browser/locales", + ], + &padre, + ); + git(&["commit", "-qm", "padre"], &padre); + + let mirror = raiz.path().join("padre.git"); + let st = Command::new("git") + .args(["clone", "--mirror", padre.to_str().unwrap()]) + .arg(&mirror) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(st.success()); + let commit = salida_git(&["rev-parse", "main"], &mirror).unwrap(); + let commit = commit.trim(); + + let destino = raiz.path().join("arbol"); + std::fs::create_dir_all(&destino).unwrap(); + let repos = raiz.path().join("repos"); + std::fs::create_dir_all(&repos).unwrap(); + + archivar_en(&mirror, commit, &destino).unwrap(); + // Sin submódulos, `git archive` deja el gitlink como un directorio VACÍO: el fichero que + // el build pide no está, y el error llega recién en `configure`. + assert!(!destino.join("browser/locales/moz.build").exists()); + + materializar_submodulos(&mirror, commit, &destino, &repos, padre.to_str().unwrap(), 0) + .unwrap(); + assert_eq!( + std::fs::read_to_string(destino.join("browser/locales/moz.build")).unwrap(), + "DEL SUBMODULO\n" + ); + assert_eq!( + std::fs::read_to_string(destino.join("raiz.txt")).unwrap(), + "DEL PADRE\n" + ); + } + #[test] fn rotar_bases_lista_vacia_o_unica() { assert!(rotar_bases("", "abcd").is_empty());