//! Reconcile an agent's local applied config checkout against its forge //! `agent-configs/` `main`. Backs `hivectl forge reconcile-config` //! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re //! owns the applied checkout + the forge credential, so the reconcile op //! lives here rather than in the CLI. //! //! `Status` is read-only (fetches forge `main` into a scratch ref, reports //! the divergence). `Apply(Forge)` resets the local applied checkout to //! forge `main` — the change takes effect on the next deploy (the deploy's //! `--override-input` re-locks against the reset local tree); it does NOT //! auto-deploy. `Apply(Local)` is not supported: forge `main` is core-only //! branch-protected (advanced solely by the config-PR ff-merge API). use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_host_sock::{HostResponse, ReconcileDirection}; use super::{CONFIG_ORG, core_auth_header, core_token, forge_git_url, is_present}; /// Scratch ref the forge `main` is fetched into — outside the normal /// branch/tag namespace so it never collides with real refs. const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main"; /// Fetch `agent-configs/` `main` into the applied repo's scratch /// ref (read-only, no working-tree change) and return the applied dir. async fn fetch_forge_main(agent: &str) -> Result { if !is_present().await { anyhow::bail!("forge is not running"); } let Some(token) = core_token() else { anyhow::bail!("forge core token not available"); }; let dir = crate::paths::applied_dir(agent); if !dir.join(".git").exists() { anyhow::bail!("agent `{agent}` has no applied config checkout"); } let url = forge_git_url(&format!("{CONFIG_ORG}/{agent}")); crate::lifecycle::git_authed( &dir, &["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")], &core_auth_header(&token), ) .await .context("fetch forge config main (does forge main exist yet?)")?; Ok(dir) } /// Run a git command in `dir` and capture stdout, erroring on non-zero. async fn git_out(dir: &Path, args: &[&str]) -> Result { let out = crate::lifecycle::git_command() .current_dir(dir) .args(args) .output() .await .with_context(|| format!("invoke git {args:?}"))?; if !out.status.success() { anyhow::bail!( "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// Append `lines` (from a git text block) to `messages`, each indented. fn push_indented(messages: &mut Vec, block: &str) { messages.extend(block.lines().map(|l| format!(" {l}"))); } /// Report the divergence between the local applied checkout and forge /// `main` (host request `ReconcileConfigStatus`). Read-only. pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result { let dir = fetch_forge_main(agent).await?; let counts = git_out( &dir, &[ "rev-list", "--left-right", "--count", &format!("HEAD...{FORGE_MAIN_REF}"), ], ) .await?; let mut parts = counts.split_whitespace(); let local_ahead = parts.next().unwrap_or("0"); let forge_ahead = parts.next().unwrap_or("0"); let mut messages = vec![ format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"), format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"), ]; if local_ahead == "0" && forge_ahead == "0" { messages.push(" in sync — nothing to reconcile".to_owned()); return Ok(HostResponse::messages(messages)); } if forge_ahead != "0" { messages.push(" forge-only commits (applied by --from forge):".to_owned()); push_indented( &mut messages, &git_out( &dir, &["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")], ) .await?, ); } if local_ahead != "0" { messages.push(" local-only commits (discarded by --from forge):".to_owned()); push_indented( &mut messages, &git_out( &dir, &["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")], ) .await?, ); } messages.push(" diff --stat (local HEAD -> forge main):".to_owned()); push_indented( &mut messages, &git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?, ); if verbose { messages.push(" full diff:".to_owned()); push_indented( &mut messages, &git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?, ); } Ok(HostResponse::messages(messages)) } /// Apply a reconcile in `direction` (host request `ReconcileConfigApply`). /// `Forge` resets the local applied checkout to forge `main`; `Local` is /// unsupported and returns a clear error. pub async fn reconcile_config_apply( agent: &str, direction: ReconcileDirection, ) -> Result { match direction { ReconcileDirection::Local => Ok(HostResponse::error( "reconciling forge from local is not supported yet: forge main is core-only \ branch-protected (no-push, ff-merge-API-only). Resolve via a config PR, or use \ `--from forge` to reset the local checkout to forge main.", )), ReconcileDirection::Forge => { let dir = fetch_forge_main(agent).await?; crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF]) .await .context("reset applied main to forge main")?; Ok(HostResponse::messages(vec![ format!("reconciled applied/{agent} to forge agent-configs/{agent} main"), format!("effective on next deploy — rebuild {agent} to apply now"), ])) } } }