gioser

sergio/takana

> 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…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

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.

114aeba514f8fe4b09786b4ab6bb61998893d9fe · por Sergio · (hace 1 mes) · padre 6a5408ada · ver el árbol en este commit

Delta semántico · 13

1 archivos · +351 −3

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());