From 63e8a98df217d7ca69859f18767e93b3e567f59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 01:02:47 +0200 Subject: [PATCH] meta: stage before lock, single commit per change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git+file://'s dirty-tree fetcher reads tracked + staged content from the index (not the working tree, not untracked files). so staging is enough to make a new flake.nix or flake.lock visible to nix without committing first. sync_agents now stages flake .nix, runs lock, stages the resulting flake.lock, then commits both together in a single 'regenerate meta flake' (or 'seed meta from N agents') commit — no more two-commit churn. prepare_deploy applies the same trick to the two-phase deploy: runs nix flake update, stages flake.lock so nixos-container update sees it, doesn't commit yet. finalize_deploy commits with the deployed/ message on build success; abort_deploy git-restores the staged lock back to HEAD on failure. meta history continues to record only successful deploys (and now one commit per success instead of one + amend). --- hive-c0re/src/meta.rs | 127 ++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 78 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index cfd2f30..afb30b9 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -73,110 +73,81 @@ pub async fn sync_agents( if initial { git(&dir, &["init", "--initial-branch=main"]).await?; } - // Commit flake.nix *before* running nix flake lock — when meta is - // a git repo, nix treats it as a `git+file://` self-reference and - // only sees TRACKED files. Locking against an untracked flake.nix - // surfaces as "source tree does not contain '/flake.nix'". + // Stage flake.nix *before* running nix flake lock. When meta is + // a git repo, nix treats it as a `git+file://` self-reference; + // its dirty-tree fetcher includes index entries (tracked + + // staged) but skips untracked files, so without the stage step + // an untracked flake.nix surfaces as "source tree does not + // contain '/flake.nix'". Lock then commit once with both + // flake.nix and flake.lock — single commit per change. git(&dir, &["add", "flake.nix"]).await?; + nix(&dir, &["flake", "lock"]).await?; + if std::path::Path::new(&dir).join("flake.lock").exists() { + git(&dir, &["add", "flake.lock"]).await?; + } let msg = if initial { format!("seed meta from {} agent(s)", agents.len()) } else { "regenerate meta flake".to_owned() }; git_commit(&dir, &msg).await?; - nix(&dir, &["flake", "lock"]).await?; - if !git_is_clean(&dir).await? { - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, "lock update").await?; - } Ok(()) } /// Phase 1 of an apply-commit deploy. Updates the locked rev of /// `agent-` to whatever `applied//main` currently points -/// at **and commits** the bump immediately — `git+file://` semantics -/// mean nixos-container would otherwise build against the previously -/// tracked lock, ignoring the working-tree change. `finalize_deploy` -/// later amends the message with the deployed tag; `abort_deploy` -/// drops the commit entirely so meta history shows only successful -/// deploys. +/// at and **stages** the lock so `nixos-container update --flake +/// meta#` (which reads via `git+file://`) sees the new rev via +/// the index. Doesn't commit — `finalize_deploy` commits on build +/// success, `abort_deploy` drops the staged change on failure so +/// meta history only carries successful deploys. #[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit pub async fn prepare_deploy(name: &str) -> Result<()> { let dir = meta_dir(); let input = format!("agent-{name}"); nix(&dir, &["flake", "update", &input]).await?; - if git_is_clean(&dir).await? { - // Lock unchanged (rev already matches). Nothing to commit; - // finalize_deploy will be a no-op too. - return Ok(()); - } - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, &format!("deploy {name} (building)")).await + // Stage the new lock — git+file://'s dirty-tree fetcher reads + // index entries, so the upcoming nixos-container update sees the + // bumped rev without a commit yet. + git(&dir, &["add", "flake.lock"]).await } -/// Phase 2-success. Amend the prepare-deploy commit's message with -/// the canonical deployed tag + sha. No-op when prepare didn't -/// commit (input was already at the right rev). +/// Phase 2-success. Commit the staged lock with the deployed tag + +/// sha as the message. No-op when the rev was already at the right +/// place (nothing staged → nothing to commit). #[allow(dead_code)] pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> { let dir = meta_dir(); - // Detect prepare's commit by its placeholder message; if HEAD is - // some other commit (e.g. prepare no-op'd, or a concurrent change - // landed) just add a fresh commit instead of amending. - let head_msg = git_head_msg(&dir).await.unwrap_or_default(); - let short = &sha[..sha.len().min(12)]; - let new_msg = format!("deploy {name} {tag} {short}"); - if head_msg.starts_with(&format!("deploy {name} (building)")) { - git( - &dir, - &[ - "-c", - &format!("user.name={GIT_NAME}"), - "-c", - &format!("user.email={GIT_EMAIL}"), - "commit", - "--amend", - "-m", - &new_msg, - ], - ) - .await - } else { - Ok(()) + if !has_staged_changes(&dir).await? { + return Ok(()); } + let short = &sha[..sha.len().min(12)]; + git_commit(&dir, &format!("deploy {name} {tag} {short}")).await } -/// Phase 2-failure. Drop the prepare-deploy commit so meta history -/// only carries successful deploys. The failed proposal is still +/// Phase 2-failure. Unstage + restore the lock so meta returns to +/// the previously-committed shas. The failed proposal is still /// captured in `applied/`'s annotated `failed/` tag. #[allow(dead_code)] pub async fn abort_deploy() -> Result<()> { let dir = meta_dir(); - let head_msg = git_head_msg(&dir).await.unwrap_or_default(); - if head_msg.starts_with("deploy ") && head_msg.contains("(building)") { - // hard reset drops the commit + its working-tree changes. - git(&dir, &["reset", "--hard", "HEAD~1"]).await - } else { - // Prepare no-op'd (lock unchanged); also restore any lingering - // lock-only changes as a safety belt. - git(&dir, &["restore", "flake.lock"]).await - } + git(&dir, &["restore", "--staged", "flake.lock"]).await?; + git(&dir, &["restore", "flake.lock"]).await } -async fn git_head_msg(dir: &Path) -> Result { - let out = lifecycle::git_command() +async fn has_staged_changes(dir: &Path) -> Result { + let st = lifecycle::git_command() .current_dir(dir) - .args(["log", "-1", "--format=%s"]) - .output() + .args(["diff", "--cached", "--quiet"]) + .status() .await - .with_context(|| format!("git log in {}", dir.display()))?; - if !out.status.success() { - bail!( - "git log -1 failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); + .with_context(|| format!("git diff --cached in {}", dir.display()))?; + // exit 1 = differences present, 0 = no diff, other = error + match st.code() { + Some(0) => Ok(false), + Some(1) => Ok(true), + _ => bail!("git diff --cached exited unexpectedly"), } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) } /// One-shot used by the manual-rebuild path: relock just one @@ -188,11 +159,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { let dir = meta_dir(); let input = format!("agent-{name}"); nix(&dir, &["flake", "update", &input]).await?; - if !git_is_clean(&dir).await? { - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, &format!("rebuild {name}: lock update")).await?; + if git_is_clean(&dir).await? { + return Ok(()); } - Ok(()) + git(&dir, &["add", "flake.lock"]).await?; + git_commit(&dir, &format!("rebuild {name}: lock update")).await } /// One-shot used by the auto-update path: pin the latest hyperhive @@ -202,11 +173,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { pub async fn lock_update_hyperhive() -> Result<()> { let dir = meta_dir(); nix(&dir, &["flake", "update", "hyperhive"]).await?; - if !git_is_clean(&dir).await? { - git(&dir, &["add", "flake.lock"]).await?; - git_commit(&dir, "bump hyperhive").await?; + if git_is_clean(&dir).await? { + return Ok(()); } - Ok(()) + git(&dir, &["add", "flake.lock"]).await?; + git_commit(&dir, "bump hyperhive").await } fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]) -> String {