> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
fetch: materializa los submódulos git, que `git archive` no exporta
`fetch_git` clona a un mirror y materializa el árbol con `git archive | tar -x`, a propósito, para
no pagar un worktree. Pero `git archive` NO emite nada por una entrada gitlink (modo 160000): un
árbol con submódulos salía INCOMPLETO y el fallo aparecía recién en la fase configure, minutos
después y hablando de un fichero "que no existe".
Por eso un `--recurse-submodules` en el clone no arreglaba nada: el que los pierde es el archive,
no el clon. Lo que hace falta es leer `.gitmodules` + los SHA de gitlink DEL COMMIT, mirrorear cada
submódulo y archivarlo en su subruta, recursando. Encaja con el ADR 0006 sin ceder determinismo: un
submódulo ya viene pineado por SHA en el commit del padre.
Dos detalles que no son obvios:
· La URL se reescribe SSH→HTTPS. `.gitmodules` suele declarar `git@github.com:X/Y.git` y nuestro
fetch es anónimo, sin claves. Apunta al mismo repo y el commit está pineado, así que el
contenido no puede diferir: no añade nada que verificar. Las relativas (`../l10n.git`) se
resuelven contra la URL del padre tratada como DIRECTORIO, que es lo que hace git — `..` se
come el nombre del propio repo, no el del directorio que lo contiene.
· `.gitmodules` se lee del COMMIT (`git config --blob`), no del working tree, porque no hay
working tree. Y los gitlinks se listan con `ls-tree -z`: sin `-z` git escapa las rutas con
espacios entre comillas.
El test reproduce el caso completo con repos locales: comprueba primero que sin esto el gitlink
queda como directorio VACÍO —el síntoma exacto que costó el diagnóstico— y después que con esto el
fichero del submódulo llega.
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 <commit> | tar -x -C <destino>`: 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<Vec<(String, String)>> {
+ 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();
+ // <modo> <tipo> <sha>
+ 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<std::collections::BTreeMap<String, String>> {
+ 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<String, String> = Default::default();
+ let mut urls: std::collections::BTreeMap<String, String> = 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 <args>` capturando stdout. Error si el comando falla.
+fn salida_git(args: &[&str], cwd: &Path) -> hammer_core::Result<String> {
+ 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());