//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from //! `agent-configs/`. The typed API-client constructor + org-name //! constants live in the module root (`super`). use std::path::Path; use super::{CONFIG_ORG, forge_git_url, reader_token}; /// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn, /// or a revival after `destroy --purge`), by fetching `agent-configs/`'s /// `main` from the forge. Forge `main` advances with every operator-merged /// config PR, so this reflects the last merged config. A config deployed /// through hive-c0re without a forge merge never reaches it. /// /// Best-effort: returns `false` (not an error) rather than bailing when there /// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the /// forge has no `main` for the agent yet (the normal case right after /// `swarm-controller` creates it, before it has pushed anything), or any git /// step fails. Callers fall back to the template or the original hard error. A /// failed seed leaves the directory empty, so a later "is it seeded" check reads /// the same as before the attempt. Only `true` once `applied` is seeded and /// tagged `deployed/0`. pub async fn fetch_config_main_into_applied(name: &str) -> bool { let Some(reader) = reader_token().await else { return false; }; let applied = crate::paths::applied_dir(name); if applied.join(".git").exists() { return false; } let seeded = async { if let Err(e) = std::fs::create_dir_all(&applied) { tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed"); return false; } if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await { tracing::warn!(%name, error = ?e, "forge: applied reseed init failed"); return false; } let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); let auth = reader.auth_header(); let out = crate::lifecycle::git_command_authed(&auth) .current_dir(&applied) .args([ "fetch", "--no-tags", "--update-head-ok", &url, "refs/heads/main:refs/heads/main", ]) .output() .await; match out { Ok(o) if o.status.success() => {} Ok(o) => { // `info`, not `warn`: the normal case right after // `swarm-controller` creates the agent, before it has pushed // anything into the repo it just made, and nothing is wrong. tracing::info!( %name, stderr = %String::from_utf8_lossy(&o.stderr).trim(), "forge: no config to fetch for this agent yet" ); return false; } Err(e) => { tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed"); return false; } } if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await { tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed"); return false; } if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await { tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed"); return false; } tracing::info!(%name, "forge: seeded applied repo from agent-configs main"); true } .await; if !seeded { clear_dir(&applied); } seeded } /// Empty `dir`, keeping the directory itself: a failed seed may have written /// the working tree as well as the `.git`, and the template fallback must start /// from nothing rather than adopt files that were meant for the forge's commit. fn clear_dir(dir: &Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let _ = if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; } } #[cfg(test)] mod tests { use super::clear_dir; #[test] fn clear_dir_empties_a_dir_but_keeps_it() { let dir = tempfile::tempdir().expect("tempdir"); std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git"); std::fs::write(dir.path().join(".git/HEAD"), "ref").expect("write HEAD"); std::fs::write(dir.path().join("agent.nix"), "{}").expect("write agent.nix"); clear_dir(dir.path()); assert!(dir.path().is_dir(), "the directory itself stays"); assert_eq!(std::fs::read_dir(dir.path()).expect("read_dir").count(), 0); } }