diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index 4f0f8bd0..445886b5 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -415,27 +415,28 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { } } -/// Mirror agent `name`'s applied config repo — `main` plus every tag +/// Mirror agent `name`'s applied config repo — every status tag /// (`proposal` / `approved` / `building` / `deployed` / `failed` / -/// `denied`) — to `agent-configs/` on the local forge. -/// Best-effort: returns Err which callers log + ignore. No-op when the -/// forge isn't seeded or the applied repo doesn't exist yet. +/// `denied`) plus a best-effort `main` — to `agent-configs/` on +/// the local forge. Best-effort: returns Err which callers log + ignore. +/// No-op when the forge isn't seeded or the applied repo doesn't exist. /// /// Call this after every hive-c0re mutation of an applied repo's refs /// so the forge copy always reflects what core actually did. /// -/// Never force-pushes. The status tags are id-suffixed -/// (`proposal/`, `deployed/`, …) and therefore add-only, and -/// `main` is published history — after a failed deploy rolls the LOCAL -/// applied `main` back to last-good, the forge `main` may legitimately -/// be ahead (e.g. an operator-merged config PR whose rebuild failed). -/// Rewinding it would erase that merged commit from the forge, which -/// is exactly the incident this guards against: the local repo tracks -/// "what last built", the forge tracks "what was approved", and the -/// `failed/` tag records the divergence. A non-fast-forward -/// rejection of `main` is therefore expected + logged at info; the -/// tags in the same push still land (git pushes refspecs -/// independently). Any other failure is a real error. +/// **Two separate pushes, not one.** The status tags are id-suffixed +/// (`deployed/`, …) and add-only, so they must always land. `main`, +/// by contrast, is core-only branch-protected and authoritatively +/// advanced by `pr_merge`'s ff-only merge API — so a mirror push of an +/// already-established `main` is routinely rejected (protected-branch, +/// or non-fast-forward after a rolled-back deploy). Git's pre-receive +/// hook is all-or-nothing: bundling both refspecs in one push means that +/// `main` reject declines the whole push, dropping the tags too. +/// So we push the tags on their own first, then attempt `main` +/// separately and swallow the expected reject. The `main` push still +/// matters for the initial seed of a fresh (empty) config repo, where it +/// creates `main`. Operator-driven divergence fix: the `hivectl forge +/// reconcile-config` command. /// /// The tokenised URL is passed straight to `git push` and deliberately /// never stored as a named remote: the applied repo is bind-mounted @@ -450,29 +451,34 @@ pub async fn push_config(name: &str) -> Result<()> { return Ok(()); } let url = forge_git_url(&token, &format!("{CONFIG_ORG}/{name}")); - let out = crate::lifecycle::git_command() - .current_dir(&dir) - .args([ - "push", - &url, - "refs/heads/main:refs/heads/main", - "refs/tags/*:refs/tags/*", - ]) - .output() - .await - .context("invoke git push agent-configs")?; + // Tags first, in their own push, so they land regardless of main's fate. + let out = run_config_push(&dir, &url, "refs/tags/*:refs/tags/*").await?; + if !out.status.success() { + anyhow::bail!( + "git push tags {CONFIG_ORG}/{name} failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + // Then main on its own — a protected-branch / non-ff reject of an + // established main is expected (pr_merge owns main); only the initial + // empty-repo seed actually advances it here. + let out = run_config_push(&dir, &url, "refs/heads/main:refs/heads/main").await?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("non-fast-forward") { + if stderr.contains("non-fast-forward") + || stderr.contains("protected branch") + || stderr.contains("pre-receive hook declined") + { tracing::info!( %name, - "forge: mirror push of main rejected (non-fast-forward) — forge main is \ - ahead of local applied main (rolled-back deploy); leaving forge history intact" + "forge: mirror push of main skipped — forge main is protected / \ + owned by the config-PR ff-merge (expected); tags mirrored" ); return Ok(()); } anyhow::bail!( - "git push {CONFIG_ORG}/{name} failed ({}): {}", + "git push main {CONFIG_ORG}/{name} failed ({}): {}", out.status, stderr.trim() ); @@ -481,6 +487,22 @@ pub async fn push_config(name: &str) -> Result<()> { Ok(()) } +/// Run a single `git push ` in the applied repo `dir` and +/// return the raw output for the caller to classify. Split out so +/// [`push_config`] can push tags and `main` as independent pushes. +async fn run_config_push( + dir: &std::path::Path, + url: &str, + refspec: &str, +) -> Result { + crate::lifecycle::git_command() + .current_dir(dir) + .args(["push", url, refspec]) + .output() + .await + .context("invoke git push agent-configs") +} + /// Create an org named `name` (`org_create`). Idempotent: HTTP 422 /// ("user already exists") / 409 is treated as success. pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {