From 220e9b4af6f40bda5428a26ef04c7b57e87c3957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 00:59:35 +0200 Subject: [PATCH] =?UTF-8?q?meta:=20commit=20before=20lock=20=E2=80=94=20gi?= =?UTF-8?q?t+file://=20only=20sees=20tracked=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime error on first deploy attempt: 'source tree referenced by git+file:///var/lib/hyperhive/meta does not contain /flake.nix'. cause: sync_agents wrote flake.nix then ran 'nix flake lock' against a directory nix had just discovered as a git repo (auto-upgraded to git+file://), which only sees TRACKED content. fresh flake.nix was untracked, so nix saw an empty source tree. fix: commit flake.nix before locking. sync_agents now does write → init (if first) → git add + commit → nix flake lock → commit lock if changed. two commits per change — one 'regenerate meta flake' and one 'lock update' — instead of one combined; cleaner history. same git+file:// gotcha bit the two-phase deploy: prepare_ deploy used to write the lock without committing, expecting nixos-container update to read the working tree. it doesn't — it reads the tracked commit. prepare_deploy now commits with a placeholder 'deploy (building)' message; finalize_deploy amends to 'deploy deployed/ ' on success; abort_deploy git-reset --hard HEAD~1's it on failure. meta history still records only successful deploys. --- hive-c0re/src/meta.rs | 100 +++++++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 023ded4..cfd2f30 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -73,50 +73,110 @@ pub async fn sync_agents( if initial { git(&dir, &["init", "--initial-branch=main"]).await?; } - nix(&dir, &["flake", "lock"]).await?; - git(&dir, &["add", "-A"]).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'". + git(&dir, &["add", "flake.nix"]).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. **Doesn't commit** — caller must follow with -/// `finalize_deploy` on build success or `abort_deploy` on failure. +/// 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. #[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 -} - -/// Phase 2-success. Commits the staged `flake.lock` change with a -/// deploy-shaped message. No-op (clean working tree) is tolerated — -/// some lock-updates resolve to the same rev that's already locked. -#[allow(dead_code)] -pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> { - let dir = meta_dir(); + 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?; - let short = &sha[..sha.len().min(12)]; - git_commit(&dir, &format!("deploy {name} {tag} {short}")).await + git_commit(&dir, &format!("deploy {name} (building)")).await } -/// Phase 2-failure. Drops the uncommitted `flake.lock` change so meta -/// stays pinned at the previously-deployed shas. The failed proposal -/// is still captured in `applied/`'s annotated `failed/` tag — -/// meta's history only carries successful deploys. +/// 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). +#[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(()) + } +} + +/// Phase 2-failure. Drop the prepare-deploy commit so meta history +/// only carries successful deploys. 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(); - git(&dir, &["restore", "flake.lock"]).await + 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 + } +} + +async fn git_head_msg(dir: &Path) -> Result { + let out = lifecycle::git_command() + .current_dir(dir) + .args(["log", "-1", "--format=%s"]) + .output() + .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() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) } /// One-shot used by the manual-rebuild path: relock just one