diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs index 816e2035..a7f9b32b 100644 --- a/crates/hammer-build/src/fetch.rs +++ b/crates/hammer-build/src/fetch.rs @@ -21,9 +21,52 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result match recipe.source.kind()? { SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root), SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root), + SourceKind::Dir { dir } => fetch_dir(recipe, dir, work_root), } } +/// Materializa el árbol de una receta DERIVADA: copia `/` al workspace. +/// +/// No hay red, ni caché que invalidar, ni mirror: la fuente ya está en el disco. Se COPIA y no se +/// bindea porque las fases mutan el árbol (parches, vendoreo, ficheros generados) y el original vive +/// en el repo git — un build que ensucie el working tree del repo sería una forma silenciosa de que +/// dos recetas se pisen, que es justo lo que el ADR 0012 documenta para las fuentes compartidas. +/// +/// El nombre del workspace lleva el hash del CONTENIDO y no el de la ruta: editar el overlay cambia +/// de directorio, así que un árbol viejo nunca se reusa por accidente. +fn fetch_dir(recipe: &Recipe, dir: &str, work_root: &Path) -> hammer_core::Result { + let origen = recipe.base_dir.join(dir); + if !origen.is_dir() { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "source.dir apunta a {} y no es un directorio", + origen.display() + ))); + } + let h = hammer_core::ArtifactHash::of_tree(&origen) + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("hash del árbol {}: {e}", origen.display())))?; + let bare = h.as_str().strip_prefix("b3:").unwrap_or(h.as_str()); + let sources_dir = work_root.join("sources"); + std::fs::create_dir_all(&sources_dir)?; + let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &bare[..16])); + if work_tree.is_dir() { + std::fs::remove_dir_all(&work_tree)?; + } + std::fs::create_dir_all(&work_tree)?; + let st = Command::new("cp") + .arg("-a") + .arg(format!("{}/.", origen.display())) + .arg(&work_tree) + .status() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cp -a del source.dir: {e}")))?; + if !st.success() { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "cp -a de {} falló", + origen.display() + ))); + } + Ok(work_tree) +} + fn fetch_git( recipe: &Recipe, repo: &str, diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs index 6093fdcd..5c5df20d 100644 --- a/crates/hammer-build/src/swm_bridge.rs +++ b/crates/hammer-build/src/swm_bridge.rs @@ -105,6 +105,14 @@ pub fn recipe_from_source_patch( let source_key: &str = match &source_kind { hammer_core::SourceKind::Git { commit, .. } => commit, hammer_core::SourceKind::Tarball { sha256, .. } => sha256, + // Inalcanzable por construcción —`swm_source_kind` sólo produce Git o Tarball— pero se + // escribe como error y no como `unreachable!`: esto es una librería, y un panic aquí + // mataría al llamador por una receta mal formada en vez de decírselo. + hammer_core::SourceKind::Dir { .. } => { + return Err(hammer_core::Error::Recipe( + "un .swm no puede llevar un source por directorio".into(), + )) + } }; let mut patches: Vec = Vec::new(); @@ -176,6 +184,12 @@ fn synthesize_recipe( *sha256, format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"), ), + // Ver la nota del `source_key` de arriba: un `.swm` no puede traer un `dir`. + hammer_core::SourceKind::Dir { .. } => { + return Err(hammer_core::Error::Recipe( + "un .swm no puede llevar un source por directorio".into(), + )) + } }; // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`. let toml_text = format!( diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index df3c2e14..75c16144 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -2295,6 +2295,12 @@ fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyh eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name); return Ok(()); } + // Una receta derivada ya está anclada por el CONTENIDO de su árbol (el hash entra en + // `hash_inputs` calculado, no declarado) ⇒ no hay ref flotante que fijar. + Ok(hammer_core::SourceKind::Dir { dir }) => { + eprintln!("{}: source.dir ({dir}) — anclado por contenido, nada que pinear", recipe.name); + return Ok(()); + } Err(e) => anyhow::bail!("{}: {e}", recipe.name), }; if is_git_sha(&commit) { @@ -2954,7 +2960,9 @@ fn build_export_mutations( Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => { (None, None, Some(url.to_string()), Some(sha256.to_string())) } - Err(_) => { + // El árbol de una receta derivada no se puede mandar en un `.swm` (no hay puntero + // resoluble del otro lado) ⇒ mismo tratamiento que un source inválido: file_drop. + Ok(hammer_core::SourceKind::Dir { .. }) | Err(_) => { stats.warnings += 1; eprintln!( "warning: receta de {art_hash} tiene source inválido; cayendo a file_drop" diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index 74e0512f..481f7652 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -94,6 +94,20 @@ pub struct Source { /// siempre tienen un único top-level `proyecto-version/`). #[serde(default = "default_strip")] pub strip_components: usize, + // --- modo directorio (recetas DERIVADAS) --- + /// Árbol fuente que vive en ESTE repo, relativo al directorio de la receta. + /// + /// Existe para las recetas cuyo contenido no viene de upstream sino de nosotros: un envoltorio + /// que se arma encima de otro artefacto (`atuq` sobre `firefox`, SDD 26), un tema, una + /// configuración. Antes de esto el `[source]` era obligatoriamente git o tarball, así que una + /// receta así no se podía ni escribir — y el rodeo (fetchear una fuente upstream que no se usa) + /// habría mentido sobre la identidad del artefacto. + /// + /// **Se hashea por CONTENIDO, no por ruta**: `ArtifactHash::of_tree` sobre el árbol entero, que + /// es lo mismo que ya se hace con los `patches` (bytes, no nombres). Editar un CSS del overlay + /// mueve el hash; renombrar el directorio sin tocar nada, no. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dir: Option, // --- común --- /// Destino del vendoreo cargo (default `vendor`). Override per-receta para proyectos que /// commitean su PROPIO `vendor/` (p.ej. mise → `vendor/aqua-registry`): `cargo vendor` borra @@ -128,10 +142,23 @@ fn default_strip() -> usize { pub enum SourceKind<'a> { Git { repo: &'a str, commit: &'a str }, Tarball { url: &'a str, sha256: &'a str }, + /// Árbol del propio repo (recetas derivadas). Ver [`Source::dir`]. + Dir { dir: &'a str }, } impl Source { pub fn kind(&self) -> crate::Result> { + // `dir` es EXCLUYENTE con los otros dos y se comprueba primero: declarar a la vez un + // árbol propio y una fuente upstream no es una receta ambigua, es un error de quien la + // escribió, y decirlo temprano evita un build que fetchea algo que después pisa. + if let Some(dir) = self.dir.as_deref() { + if self.repo.is_some() || self.tarball.is_some() { + return Err(crate::Error::Recipe( + "source: `dir` es excluyente con repo/tarball — una receta derivada no tiene fuente upstream".into(), + )); + } + return Ok(SourceKind::Dir { dir }); + } match ( self.repo.as_deref(), self.commit.as_deref(), @@ -146,7 +173,7 @@ impl Source { ), ), _ => Err(crate::Error::Recipe( - "source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(), + "source: faltan campos; necesito (repo+commit), (tarball+sha256) o (dir)".into(), )), } } @@ -516,6 +543,19 @@ impl Recipe { let source_id = match self.source.kind()? { SourceKind::Git { commit, .. } => format!("git:{commit}"), SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"), + // El árbol vive en el repo y puede cambiar en cualquier commit ⇒ el puntero al + // contenido hay que CALCULARLO, no leerlo de la receta. `of_tree` es determinista + // (rutas ordenadas, bit de ejecución, contenido; no sigue symlinks). + SourceKind::Dir { dir } => { + let root = self.base_dir.join(dir); + let h = crate::ArtifactHash::of_tree(&root).map_err(|e| { + crate::Error::Recipe(format!( + "no pude hashear el árbol de source.dir {}: {e}", + root.display() + )) + })?; + format!("dir:{}", h.as_str()) + } }; let mut v: Vec> = vec![ source_id.into_bytes(), @@ -1040,4 +1080,37 @@ expected_output = "sha256:zzz" // exit malo ⇒ falla antes de mirar el hash. assert!(!c.evaluate(1, b"hola\n").passed); } + + // ── source.dir: recetas DERIVADAS (SDD 26) ───────────────────────────────────────────────── + #[test] + fn source_dir_es_su_propio_modo_y_excluye_a_los_otros() { + let solo_dir: Source = toml::from_str("dir = \"atuq\"").unwrap(); + assert_eq!( + solo_dir.kind().unwrap(), + SourceKind::Dir { dir: "atuq" }, + "un `dir` solo tiene que resolver a modo directorio" + ); + + // Declarar árbol propio Y fuente upstream no es ambigüedad que resolver por precedencia: + // es un error de quien escribió la receta, y decirlo temprano evita un fetch que después + // se pisa. Se comprueba con las dos formas de upstream. + let con_git: Source = + toml::from_str("dir = \"atuq\"\nrepo = \"git://x\"\ncommit = \"c0\"").unwrap(); + assert!(con_git.kind().is_err(), "dir + git debe fallar"); + let con_tar: Source = + toml::from_str("dir = \"atuq\"\ntarball = \"http://x/t.tar\"\nsha256 = \"ab\"") + .unwrap(); + assert!(con_tar.kind().is_err(), "dir + tarball debe fallar"); + } + + #[test] + fn el_error_de_source_vacio_nombra_los_tres_modos() { + // El mensaje es la única guía que tiene quien escribe una receta a mano. Si sumamos un modo + // y el error sigue nombrando dos, mandamos a la gente a buscar un campo que no existe. + let vacio: Source = toml::from_str("").unwrap(); + let e = vacio.kind().unwrap_err().to_string(); + assert!(e.contains("repo"), "{e}"); + assert!(e.contains("tarball"), "{e}"); + assert!(e.contains("dir"), "{e}"); + } } diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs index c9e01d58..58ed963b 100644 --- a/crates/hammer-core/src/swm.rs +++ b/crates/hammer-core/src/swm.rs @@ -272,6 +272,17 @@ impl Swm { SourceKind::Tarball { url, sha256 } => { (None, None, Some(url.to_string()), Some(sha256.to_string())) } + // Un `.swm` es un manifiesto COMPARTIBLE: describe la mutación con punteros que el otro + // lado pueda resolver (un commit, un sha256). El árbol de una receta derivada vive en + // ESTE repo y no hay puntero que mandar, así que no se puede expresar todavía. Falla + // diciéndolo en vez de emitir un manifiesto con el source vacío, que viajaría bien y + // rompería del otro lado. + SourceKind::Dir { dir } => { + return Err(crate::Error::Recipe(format!( + "`{}` usa source.dir ({dir}) y una receta derivada no se puede exportar a .swm: el manifiesto necesita un puntero resoluble desde fuera (commit o sha256)", + recipe.name + ))) + } }; let build = SwmBuild { @@ -363,6 +374,14 @@ impl Mutation { return Err("source_patch: 'sha256' vacío".into()); } } + // `swm_source_kind` sólo construye Git o Tarball a partir de los campos del + // manifiesto: no hay forma de que un `.swm` traiga un `dir`. El brazo existe + // para que el compilador siga vigilando este match si mañana lo trae. + SourceKind::Dir { .. } => { + return Err( + "source_patch: un .swm no puede llevar un source por directorio".into(), + ); + } } if patch.is_some() && patch_url.is_some() { return Err(