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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user