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.
This commit is contained in:
Sergio
2026-09-05 15:11:35 +00:00
parent 6a5408adae
commit 114aeba514
+351 -3
View File
@@ -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());