//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable. //! //! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`): //! //! * **Git** — mirror local en `/repos/.git` + `git archive` del commit //! fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable. //! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se //! cachean los archivos descargados en `/tarballs/`. //! //! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del //! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa //! copia (sin red). use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use hammer_core::{Recipe, SourceKind}; /// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta. 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), } } fn fetch_git( recipe: &Recipe, repo: &str, commit: &str, work_root: &Path, ) -> hammer_core::Result { let repos_dir = work_root.join("repos"); let sources_dir = work_root.join("sources"); std::fs::create_dir_all(&repos_dir)?; std::fs::create_dir_all(&sources_dir)?; let mirror = repos_dir.join(format!("{}.git", recipe.name)); // Mirror propio de fuentes git (ADR 0013). Se intenta ANTES que el remoto, por la misma razón // que en los tarballs: git verifica cada objeto por su SHA al desempaquetar, así que no hay // diferencia de contenido posible entre las dos rutas — y un mirror que sólo se usa cuando // upstream falla es un mirror que nadie prueba. if !mirror.is_dir() || run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() { let _ = hidratar_desde_bundle(commit, &mirror); } if !mirror.is_dir() { run_git( &[ "clone", "--mirror", "--filter=blob:none", repo, mirror.to_str().unwrap(), ], None, )?; } // Asegura que tenemos el commit pedido. En un mirror existente puede faltar. if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() { run_git(&["fetch", "--all", "--tags"], Some(&mirror))?; run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| { hammer_core::Error::Other(anyhow::anyhow!( "commit {commit} no existe en {repo} tras fetch" )) })?; } // Materializa un árbol trabajable mediante `git archive` → tar → extract. // Esto evita el coste de un worktree completo y deja sólo los archivos del commit. let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit)); if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; } std::fs::create_dir_all(&work_tree)?; let archive = Command::new("git") .arg("-C") .arg(&mirror) .args(["archive", "--format=tar", commit]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?; let tar_status = Command::new("tar") .args(["-x", "-C"]) .arg(&work_tree) .stdin(archive.stdout.unwrap()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?; if !tar_status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "git archive | tar -x falló (tar exit {:?})", tar_status.code() ))); } Ok(work_tree) } /// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013). /// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el /// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre. /// /// # Por qué un bundle SHALLOW y no un clon completo /// /// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez /// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es /// `git archive | tar -x`, o sea materializar UN árbol. /// /// # El detalle que hace que esto funcione: el fichero `shallow` /// /// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al /// desempaquetarlo git intenta recorrer los padres y aborta: /// /// ```text /// fatal: Failed to traverse parents of commit 4f41128… /// error: … did not send all necessary objects /// ``` /// /// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de /// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la /// historia. Se arregla escribiendo el propio commit en `/shallow` ANTES del fetch — y no /// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit. /// /// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al /// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa. fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool { let Ok(base) = std::env::var("HAMMER_MIRROR_GIT") else { return false; }; let base = base.trim_end_matches('/'); if base.is_empty() { return false; } let tmp = destino.with_extension("bundle.partial"); if let Some(padre) = tmp.parent() { let _ = std::fs::create_dir_all(padre); } let _ = std::fs::remove_file(&tmp); if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() { let _ = std::fs::remove_file(&tmp); return false; } if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() { let _ = std::fs::remove_file(&tmp); return false; } // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos. // // La frontera se LEE DEL BUNDLE, no se supone. `git bundle list-heads` imprime la lista de refs // sin necesitar los objetos, así que sirve justo antes del fetch. Suponerla igual a `commit` // estaba mal: el campo `commit` de una receta puede pinear un OBJETO TAG anotado (así están // diffutils, findutils-xargs y kustomize), y el fichero `shallow` sólo admite SHAs de commit. // El poblador deja siempre `refs/heads/hammer` en el commit ya pelado. let Some(frontera) = frontera_del_bundle(&tmp) else { let _ = std::fs::remove_file(&tmp); return false; }; let _ = std::fs::write(destino.join("shallow"), format!("{frontera}\n")); // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C …`, y git resuelve una ruta // relativa de bundle DENTRO de ese `-C` — o sea buscaría `/work/repos/x.bundle.partial`. // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build // muere diciendo «commit … no existe en tras fetch», culpando a upstream de un error de // ruta local. let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone()); // `refs/*:refs/*` y no un ref concreto: cuando el pin es un tag anotado el bundle trae DOS refs // (el commit pelado y el objeto tag), y hace falta el objeto tag para que el `cat-file -e` de // abajo — y el `git archive ` de después — encuentren lo que la receta pide. let ok = run_git( &["fetch", bundle_abs.to_str().unwrap(), "refs/*:refs/*"], Some(destino), ) .is_ok() && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok(); let _ = std::fs::remove_file(&tmp); ok } /// SHA del commit que `refs/heads/hammer` señala dentro del bundle, leído sin desempaquetarlo. fn frontera_del_bundle(bundle: &Path) -> Option { let abs = std::fs::canonicalize(bundle).ok()?; let salida = Command::new("git") .args(["bundle", "list-heads"]) .arg(&abs) .output() .ok()?; if !salida.status.success() { return None; } String::from_utf8_lossy(&salida.stdout) .lines() .find_map(|l| { let (sha, refname) = l.split_once(char::is_whitespace)?; (refname.trim() == "refs/heads/hammer").then(|| sha.trim().to_string()) }) } fn fetch_tarball( recipe: &Recipe, url: &str, sha256: &str, work_root: &Path, ) -> hammer_core::Result { let tarballs_dir = work_root.join("tarballs"); let sources_dir = work_root.join("sources"); std::fs::create_dir_all(&tarballs_dir)?; std::fs::create_dir_all(&sources_dir)?; // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y // cambiar la URL sin cambiar el sha (mirror) no invalida la caché. let cached = tarballs_dir.join(format!("{sha256}.tar")); if !cached.is_file() { fetch_con_mirror(url, sha256, &cached)?; } else { verify_sha256(&cached, sha256)?; } // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano. // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto. // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea // las tres salidas y qué medir para elegir. let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16])); if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; } std::fs::create_dir_all(&work_tree)?; // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo // identificador interno (no afecta a la detección). let strip = recipe.source.strip_components.to_string(); let status = Command::new("tar") .arg("-x") // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma. .arg("--no-same-owner") .arg("-f") .arg(&cached) .arg("--strip-components") .arg(&strip) .arg("-C") .arg(&work_tree) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?; if !status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "tar -x falló (exit {:?})", status.code() ))); } Ok(work_tree) } /// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013). /// /// # Por qué el mirror va primero y no como rescate /// /// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido /// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más /// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que /// **nadie prueba**, y se descubre roto justo el día en que hace falta. /// /// # Por qué NO avisa cuando cae al mirror /// /// Tentación evidente y **equivocada**: si `hammer build` avisara en cada descarga, el aviso sería /// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*, /// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en /// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`. /// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo /// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso. /// /// `HAMMER_MIRROR` es un prefijo de URL que curl entienda (`file://`, `https://`, `sftp://`…); el /// objeto se busca por contenido en `{HAMMER_MIRROR}/{sha256}.tar`. `HAMMER_MIRROR_KEY`, si está, /// se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR` el comportamiento es el de /// siempre: derecho a upstream. fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { if let Ok(base) = std::env::var("HAMMER_MIRROR") { let base = base.trim_end_matches('/'); if !base.is_empty() { let espejo = format!("{base}/{sha256}.tar"); // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente. // Se ignora en silencio y se sigue a upstream. if download_verify(&espejo, sha256, dst).is_ok() { return Ok(()); } } } download_verify(url, sha256, dst) } /// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo /// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché). /// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba — /// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles). fn descargar(url: &str, dst: &Path) -> hammer_core::Result<()> { let mut cmd = Command::new("curl"); cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]); // Clave para el mirror por sftp (Storage Box). Inocua para http/file. if let Ok(key) = std::env::var("HAMMER_MIRROR_KEY") { if !key.is_empty() { cmd.arg("--key").arg(&key); } } let status = cmd .arg("-o") .arg(dst) .arg(url) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?; if !status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "curl {url} falló (exit {:?})", status.code() ))); } Ok(()) } fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { let tmp = dst.with_extension("tar.partial"); if let Err(e) = descargar(url, &tmp) { let _ = std::fs::remove_file(&tmp); return Err(e); } if let Err(e) = verify_sha256(&tmp, sha256) { let _ = std::fs::remove_file(&tmp); return Err(e); } std::fs::rename(&tmp, dst)?; Ok(()) } fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> { use sha2::{Digest, Sha256}; let mut f = std::fs::File::open(path)?; let mut hasher = Sha256::new(); std::io::copy(&mut f, &mut hasher)?; let got = hex::encode(hasher.finalize()); if !got.eq_ignore_ascii_case(expected) { return Err(hammer_core::Error::Other(anyhow::anyhow!( "sha256 mismatch en {}: esperado {expected}, obtenido {got}", path.display() ))); } Ok(()) } /// Vendoriza las dependencias de un proyecto Cargo en `/vendor` y escribe /// `/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`. /// /// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch). /// /// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con /// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está /// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo /// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`: /// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden /// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente /// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8. /// /// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo. pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> hammer_core::Result<()> { let locked = src.join("Cargo.lock").is_file(); let mut args: Vec<&str> = vec!["vendor"]; if locked { args.push("--locked"); } else { tracing::warn!( src = %src.display(), "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \ tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)." ); } // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir` // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo. args.push(vendor_dir); let output = Command::new("cargo") .current_dir(src) .args(&args) .stderr(Stdio::inherit()) .output() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?; if !output.status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "cargo vendor falló (exit {:?})", output.status.code() ))); } // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que // resuelve igual dentro del sandbox donde el source se monta en /src). let cargo_dir = src.join(".cargo"); std::fs::create_dir_all(&cargo_dir)?; std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?; Ok(()) } /// Vendoriza los módulos de un proyecto Go en `/vendor` para que `go build -mod=vendor` /// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como /// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol. /// /// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum` /// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo /// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que /// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo); /// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga. /// /// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo. pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> { // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace. // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz). let has_work = src.join("go.work").is_file(); let (verb, label): ([&str; 2], &str) = if has_work { (["work", "vendor"], "go work vendor") } else { (["mod", "vendor"], "go mod vendor") }; let mut cmd = Command::new("go"); cmd.current_dir(src) .args(verb) .env("GOFLAGS", "-mod=mod") .env("GOTOOLCHAIN", "local"); if !has_work { cmd.env("GOWORK", "off"); } let output = cmd .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .output() .map_err(|e| { hammer_core::Error::Other(anyhow::anyhow!( "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \ cf. scripts/bootstrap-devfs.sh / vps-setup.sh)" )) })?; if !output.status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "{label} falló (exit {:?})", output.status.code() ))); } Ok(()) } /// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`. pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> { for p in &recipe.source.patches { let patch_path = recipe.base_dir.join(p); let bytes = std::fs::read(&patch_path).map_err(|e| { hammer_core::Error::Recipe(format!( "no pude leer patch {}: {e}", patch_path.display() )) })?; let mut child = Command::new("patch") .args(["-p1", "--batch", "--silent"]) .current_dir(tree) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?; if let Some(mut stdin) = child.stdin.take() { use std::io::Write; stdin .write_all(&bytes) .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?; } let status = child .wait() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?; if !status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "patch {} falló (exit {:?})", patch_path.display(), status.code() ))); } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn vendor_cargo_deps_runs_and_writes_config() { // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red. // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`. let tmp = tempfile::tempdir().unwrap(); let src = tmp.path(); std::fs::write( src.join("Cargo.toml"), "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n", ) .unwrap(); std::fs::create_dir_all(src.join("src")).unwrap(); std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap(); // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps). let lock_ok = Command::new("cargo") .args(["generate-lockfile", "--offline"]) .current_dir(src) .status() .map(|s| s.success()) .unwrap_or(false); if !lock_ok { eprintln!("SKIP: cargo no disponible para generate-lockfile"); return; } vendor_cargo_deps(src, "vendor").unwrap(); assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring"); } #[test] fn vendor_cargo_deps_without_committed_lock() { // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked, // generando el lock al vuelo. Para un crate depless es offline. let tmp = tempfile::tempdir().unwrap(); let src = tmp.path(); std::fs::write( src.join("Cargo.toml"), "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n", ) .unwrap(); std::fs::create_dir_all(src.join("src")).unwrap(); std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap(); assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock"); if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) { eprintln!("SKIP: cargo no disponible"); return; } // No debe fallar por falta de --locked; genera el lock y escribe el config. vendor_cargo_deps(src, "vendor").unwrap(); assert!(src.join(".cargo/config.toml").is_file()); assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock"); } } fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> { let mut cmd = Command::new("git"); if let Some(c) = cwd { cmd.arg("-C").arg(c); } cmd.args(args); let status = cmd .stdin(Stdio::null()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?; if !status.success() { return Err(hammer_core::Error::Other(anyhow::anyhow!( "git {args:?} falló (exit {:?})", status.code() ))); } Ok(()) }