tawasuyu

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…

⎇ c2b1cafff10d4c9cf093ea1e40092fc21a935588 crates/hammer-build/src/fetch.rs Rust · 584 lines · 25.3 KB History Who touched what raw

mirror git: soportar pines que son objetos tag anotados c2b1cafff · Sergio ·

1//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
2//!
3//! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`):
4//!
5//! * **Git** — mirror local en `<work_root>/repos/<name>.git` + `git archive` del commit
6//!   fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable.
7//! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se
8//!   cachean los archivos descargados en `<work_root>/tarballs/`.
9//!
10//! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del
11//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
12//! copia (sin red).
13
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use hammer_core::{Recipe, SourceKind};
18
19/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
20pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
21    match recipe.source.kind()? {
22        SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
23        SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
24    }
25}
26
27fn fetch_git(
28    recipe: &Recipe,
29    repo: &str,
30    commit: &str,
31    work_root: &Path,
32) -> hammer_core::Result<PathBuf> {
33    let repos_dir = work_root.join("repos");
34    let sources_dir = work_root.join("sources");
35    std::fs::create_dir_all(&repos_dir)?;
36    std::fs::create_dir_all(&sources_dir)?;
37
38    let mirror = repos_dir.join(format!("{}.git", recipe.name));
39
40    // Mirror propio de fuentes git (ADR 0013). Se intenta ANTES que el remoto, por la misma razón
41    // que en los tarballs: git verifica cada objeto por su SHA al desempaquetar, así que no hay
42    // diferencia de contenido posible entre las dos rutas — y un mirror que sólo se usa cuando
43    // upstream falla es un mirror que nadie prueba.
44    if !mirror.is_dir() || run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
45        let _ = hidratar_desde_bundle(commit, &mirror);
46    }
47
48    if !mirror.is_dir() {
49        run_git(
50            &[
51                "clone",
52                "--mirror",
53                "--filter=blob:none",
54                repo,
55                mirror.to_str().unwrap(),
56            ],
57            None,
58        )?;
59    }
60
61    // Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
62    if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
63        run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
64        run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
65            hammer_core::Error::Other(anyhow::anyhow!(
66                "commit {commit} no existe en {repo} tras fetch"
67            ))
68        })?;
69    }
70
71    // Materializa un árbol trabajable mediante `git archive` → tar → extract.
72    // Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
73    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
74    if work_tree.is_dir() {
75        std::fs::remove_dir_all(&work_tree)?;
76    }
77    std::fs::create_dir_all(&work_tree)?;
78
79    let archive = Command::new("git")
80        .arg("-C")
81        .arg(&mirror)
82        .args(["archive", "--format=tar", commit])
83        .stdout(Stdio::piped())
84        .stderr(Stdio::piped())
85        .spawn()
86        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
87
88    let tar_status = Command::new("tar")
89        .args(["-x", "-C"])
90        .arg(&work_tree)
91        .stdin(archive.stdout.unwrap())
92        .stdout(Stdio::inherit())
93        .stderr(Stdio::inherit())
94        .status()
95        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
96
97    if !tar_status.success() {
98        return Err(hammer_core::Error::Other(anyhow::anyhow!(
99            "git archive | tar -x falló (tar exit {:?})",
100            tar_status.code()
101        )));
102    }
103    Ok(work_tree)
104}
105
106/// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013).
107/// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el
108/// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre.
109///
110/// # Por qué un bundle SHALLOW y no un clon completo
111///
112/// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez
113/// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es
114/// `git archive <commit> | tar -x`, o sea materializar UN árbol.
115///
116/// # El detalle que hace que esto funcione: el fichero `shallow`
117///
118/// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al
119/// desempaquetarlo git intenta recorrer los padres y aborta:
120///
121/// ```text
122/// fatal: Failed to traverse parents of commit 4f41128…
123/// error: … did not send all necessary objects
124/// ```
125///
126/// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de
127/// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la
128/// historia. Se arregla escribiendo el propio commit en `<destino>/shallow` ANTES del fetch — y no
129/// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit.
130///
131/// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al
132/// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa.
133fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool {
134    let Ok(base) = std::env::var("HAMMER_MIRROR_GIT") else {
135        return false;
136    };
137    let base = base.trim_end_matches('/');
138    if base.is_empty() {
139        return false;
140    }
141    let tmp = destino.with_extension("bundle.partial");
142    if let Some(padre) = tmp.parent() {
143        let _ = std::fs::create_dir_all(padre);
144    }
145    let _ = std::fs::remove_file(&tmp);
146    if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() {
147        let _ = std::fs::remove_file(&tmp);
148        return false;
149    }
150    if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() {
151        let _ = std::fs::remove_file(&tmp);
152        return false;
153    }
154    // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos.
155    //
156    // La frontera se LEE DEL BUNDLE, no se supone. `git bundle list-heads` imprime la lista de refs
157    // sin necesitar los objetos, así que sirve justo antes del fetch. Suponerla igual a `commit`
158    // estaba mal: el campo `commit` de una receta puede pinear un OBJETO TAG anotado (así están
159    // diffutils, findutils-xargs y kustomize), y el fichero `shallow` sólo admite SHAs de commit.
160    // El poblador deja siempre `refs/heads/hammer` en el commit ya pelado.
161    let Some(frontera) = frontera_del_bundle(&tmp) else {
162        let _ = std::fs::remove_file(&tmp);
163        return false;
164    };
165    let _ = std::fs::write(destino.join("shallow"), format!("{frontera}\n"));
166    // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C <destino> …`, y git resuelve una ruta
167    // relativa de bundle DENTRO de ese `-C` — o sea buscaría `<destino>/work/repos/x.bundle.partial`.
168    // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build
169    // muere diciendo «commit … no existe en <repo> tras fetch», culpando a upstream de un error de
170    // ruta local.
171    let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone());
172    // `refs/*:refs/*` y no un ref concreto: cuando el pin es un tag anotado el bundle trae DOS refs
173    // (el commit pelado y el objeto tag), y hace falta el objeto tag para que el `cat-file -e` de
174    // abajo — y el `git archive <commit>` de después — encuentren lo que la receta pide.
175    let ok = run_git(
176        &["fetch", bundle_abs.to_str().unwrap(), "refs/*:refs/*"],
177        Some(destino),
178    )
179    .is_ok()
180        && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok();
181    let _ = std::fs::remove_file(&tmp);
182    ok
183}
184
185/// SHA del commit que `refs/heads/hammer` señala dentro del bundle, leído sin desempaquetarlo.
186fn frontera_del_bundle(bundle: &Path) -> Option<String> {
187    let abs = std::fs::canonicalize(bundle).ok()?;
188    let salida = Command::new("git")
189        .args(["bundle", "list-heads"])
190        .arg(&abs)
191        .output()
192        .ok()?;
193    if !salida.status.success() {
194        return None;
195    }
196    String::from_utf8_lossy(&salida.stdout)
197        .lines()
198        .find_map(|l| {
199            let (sha, refname) = l.split_once(char::is_whitespace)?;
200            (refname.trim() == "refs/heads/hammer").then(|| sha.trim().to_string())
201        })
202}
203
204fn fetch_tarball(
205    recipe: &Recipe,
206    url: &str,
207    sha256: &str,
208    work_root: &Path,
209) -> hammer_core::Result<PathBuf> {
210    let tarballs_dir = work_root.join("tarballs");
211    let sources_dir = work_root.join("sources");
212    std::fs::create_dir_all(&tarballs_dir)?;
213    std::fs::create_dir_all(&sources_dir)?;
214
215    // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
216    // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
217    let cached = tarballs_dir.join(format!("{sha256}.tar"));
218    if !cached.is_file() {
219        fetch_con_mirror(url, sha256, &cached)?;
220    } else {
221        verify_sha256(&cached, sha256)?;
222    }
223
224    // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
225    // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la
226    // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro
227    // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano.
228    // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit
229    // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto.
230    // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un
231    // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es
232    // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea
233    // las tres salidas y qué medir para elegir.
234    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
235    if work_tree.is_dir() {
236        std::fs::remove_dir_all(&work_tree)?;
237    }
238    std::fs::create_dir_all(&work_tree)?;
239
240    // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
241    // identificador interno (no afecta a la detección).
242    let strip = recipe.source.strip_components.to_string();
243    let status = Command::new("tar")
244        .arg("-x")
245        // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el
246        // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros
247        // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla
248        // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el
249        // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma.
250        .arg("--no-same-owner")
251        .arg("-f")
252        .arg(&cached)
253        .arg("--strip-components")
254        .arg(&strip)
255        .arg("-C")
256        .arg(&work_tree)
257        .stdout(Stdio::inherit())
258        .stderr(Stdio::inherit())
259        .status()
260        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
261    if !status.success() {
262        return Err(hammer_core::Error::Other(anyhow::anyhow!(
263            "tar -x falló (exit {:?})",
264            status.code()
265        )));
266    }
267    Ok(work_tree)
268}
269
270/// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013).
271///
272/// # Por qué el mirror va primero y no como rescate
273///
274/// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido
275/// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más
276/// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que
277/// **nadie prueba**, y se descubre roto justo el día en que hace falta.
278///
279/// # Por qué NO avisa cuando cae al mirror
280///
281/// Tentación evidente y **equivocada**: si `hammer build` avisara en cada descarga, el aviso sería
282/// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*,
283/// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en
284/// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`.
285/// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo
286/// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso.
287///
288/// `HAMMER_MIRROR` es un prefijo de URL que curl entienda (`file://`, `https://`, `sftp://`…); el
289/// objeto se busca por contenido en `{HAMMER_MIRROR}/{sha256}.tar`. `HAMMER_MIRROR_KEY`, si está,
290/// se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR` el comportamiento es el de
291/// siempre: derecho a upstream.
292fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
293    if let Ok(base) = std::env::var("HAMMER_MIRROR") {
294        let base = base.trim_end_matches('/');
295        if !base.is_empty() {
296            let espejo = format!("{base}/{sha256}.tar");
297            // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente.
298            // Se ignora en silencio y se sigue a upstream.
299            if download_verify(&espejo, sha256, dst).is_ok() {
300                return Ok(());
301            }
302        }
303    }
304    download_verify(url, sha256, dst)
305}
306
307/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
308/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
309/// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba —
310/// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles).
311fn descargar(url: &str, dst: &Path) -> hammer_core::Result<()> {
312    let mut cmd = Command::new("curl");
313    cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]);
314    // Clave para el mirror por sftp (Storage Box). Inocua para http/file.
315    if let Ok(key) = std::env::var("HAMMER_MIRROR_KEY") {
316        if !key.is_empty() {
317            cmd.arg("--key").arg(&key);
318        }
319    }
320    let status = cmd
321        .arg("-o")
322        .arg(dst)
323        .arg(url)
324        .stdout(Stdio::inherit())
325        .stderr(Stdio::inherit())
326        .status()
327        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
328    if !status.success() {
329        return Err(hammer_core::Error::Other(anyhow::anyhow!(
330            "curl {url} falló (exit {:?})",
331            status.code()
332        )));
333    }
334    Ok(())
335}
336
337fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
338    let tmp = dst.with_extension("tar.partial");
339    if let Err(e) = descargar(url, &tmp) {
340        let _ = std::fs::remove_file(&tmp);
341        return Err(e);
342    }
343    if let Err(e) = verify_sha256(&tmp, sha256) {
344        let _ = std::fs::remove_file(&tmp);
345        return Err(e);
346    }
347    std::fs::rename(&tmp, dst)?;
348    Ok(())
349}
350
351fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
352    use sha2::{Digest, Sha256};
353    let mut f = std::fs::File::open(path)?;
354    let mut hasher = Sha256::new();
355    std::io::copy(&mut f, &mut hasher)?;
356    let got = hex::encode(hasher.finalize());
357    if !got.eq_ignore_ascii_case(expected) {
358        return Err(hammer_core::Error::Other(anyhow::anyhow!(
359            "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
360            path.display()
361        )));
362    }
363    Ok(())
364}
365
366/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
367/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
368///
369/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
370///
371/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
372/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
373/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
374/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
375/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
376/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
377/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
378///
379/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
380pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> hammer_core::Result<()> {
381    let locked = src.join("Cargo.lock").is_file();
382    let mut args: Vec<&str> = vec!["vendor"];
383    if locked {
384        args.push("--locked");
385    } else {
386        tracing::warn!(
387            src = %src.display(),
388            "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
389             tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
390        );
391    }
392    // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir`
393    // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout
394    // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo.
395    args.push(vendor_dir);
396    let output = Command::new("cargo")
397        .current_dir(src)
398        .args(&args)
399        .stderr(Stdio::inherit())
400        .output()
401        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
402    if !output.status.success() {
403        return Err(hammer_core::Error::Other(anyhow::anyhow!(
404            "cargo vendor falló (exit {:?})",
405            output.status.code()
406        )));
407    }
408    // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
409    // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
410    // resuelve igual dentro del sandbox donde el source se monta en /src).
411    let cargo_dir = src.join(".cargo");
412    std::fs::create_dir_all(&cargo_dir)?;
413    std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
414    Ok(())
415}
416
417/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
418/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
419/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
420///
421/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
422/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
423/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
424/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
425/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
426///
427/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
428pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> {
429    // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off
430    // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent
431    // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace.
432    // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico
433    // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz).
434    let has_work = src.join("go.work").is_file();
435    let (verb, label): ([&str; 2], &str) = if has_work {
436        (["work", "vendor"], "go work vendor")
437    } else {
438        (["mod", "vendor"], "go mod vendor")
439    };
440    let mut cmd = Command::new("go");
441    cmd.current_dir(src)
442        .args(verb)
443        .env("GOFLAGS", "-mod=mod")
444        .env("GOTOOLCHAIN", "local");
445    if !has_work {
446        cmd.env("GOWORK", "off");
447    }
448    let output = cmd
449        .stdout(Stdio::inherit())
450        .stderr(Stdio::inherit())
451        .output()
452        .map_err(|e| {
453            hammer_core::Error::Other(anyhow::anyhow!(
454                "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \
455                 cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
456            ))
457        })?;
458    if !output.status.success() {
459        return Err(hammer_core::Error::Other(anyhow::anyhow!(
460            "{label} falló (exit {:?})",
461            output.status.code()
462        )));
463    }
464    Ok(())
465}
466
467/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
468pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
469    for p in &recipe.source.patches {
470        let patch_path = recipe.base_dir.join(p);
471        let bytes = std::fs::read(&patch_path).map_err(|e| {
472            hammer_core::Error::Recipe(format!(
473                "no pude leer patch {}: {e}",
474                patch_path.display()
475            ))
476        })?;
477        let mut child = Command::new("patch")
478            .args(["-p1", "--batch", "--silent"])
479            .current_dir(tree)
480            .stdin(Stdio::piped())
481            .stdout(Stdio::inherit())
482            .stderr(Stdio::inherit())
483            .spawn()
484            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
485        if let Some(mut stdin) = child.stdin.take() {
486            use std::io::Write;
487            stdin
488                .write_all(&bytes)
489                .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
490        }
491        let status = child
492            .wait()
493            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
494        if !status.success() {
495            return Err(hammer_core::Error::Other(anyhow::anyhow!(
496                "patch {} falló (exit {:?})",
497                patch_path.display(),
498                status.code()
499            )));
500        }
501    }
502    Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn vendor_cargo_deps_runs_and_writes_config() {
511        // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
512        // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
513        let tmp = tempfile::tempdir().unwrap();
514        let src = tmp.path();
515        std::fs::write(
516            src.join("Cargo.toml"),
517            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
518        )
519        .unwrap();
520        std::fs::create_dir_all(src.join("src")).unwrap();
521        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
522
523        // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
524        let lock_ok = Command::new("cargo")
525            .args(["generate-lockfile", "--offline"])
526            .current_dir(src)
527            .status()
528            .map(|s| s.success())
529            .unwrap_or(false);
530        if !lock_ok {
531            eprintln!("SKIP: cargo no disponible para generate-lockfile");
532            return;
533        }
534
535        vendor_cargo_deps(src, "vendor").unwrap();
536        assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
537    }
538
539    #[test]
540    fn vendor_cargo_deps_without_committed_lock() {
541        // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
542        // generando el lock al vuelo. Para un crate depless es offline.
543        let tmp = tempfile::tempdir().unwrap();
544        let src = tmp.path();
545        std::fs::write(
546            src.join("Cargo.toml"),
547            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
548        )
549        .unwrap();
550        std::fs::create_dir_all(src.join("src")).unwrap();
551        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
552        assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
553
554        if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
555            eprintln!("SKIP: cargo no disponible");
556            return;
557        }
558        // No debe fallar por falta de --locked; genera el lock y escribe el config.
559        vendor_cargo_deps(src, "vendor").unwrap();
560        assert!(src.join(".cargo/config.toml").is_file());
561        assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
562    }
563}
564
565fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
566    let mut cmd = Command::new("git");
567    if let Some(c) = cwd {
568        cmd.arg("-C").arg(c);
569    }
570    cmd.args(args);
571    let status = cmd
572        .stdin(Stdio::null())
573        .stdout(Stdio::inherit())
574        .stderr(Stdio::inherit())
575        .status()
576        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
577    if !status.success() {
578        return Err(hammer_core::Error::Other(anyhow::anyhow!(
579            "git {args:?} falló (exit {:?})",
580            status.code()
581        )));
582    }
583    Ok(())
584}