From 3a1cfa26ec9b022421cbd06532aa5b7ffa506d71 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 2 Jun 2026 09:22:20 +0200 Subject: [PATCH] fix(#1060): dynamic meta commit message from staged files; skip commit on no-op --- hive-c0re/src/meta.rs | 69 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index a155e1b0..758df508 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -90,7 +90,7 @@ pub async fn sync_agents( // only fills in missing entries. Idempotent; when nothing changed // the file isn't touched. let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); - let topology_changed = crate::topology::reconcile(&agent_names) + crate::topology::reconcile(&agent_names) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; // Refresh /var/lib/hyperhive/agent-ports.json so the hive-gateway @@ -168,12 +168,35 @@ pub async fn sync_agents( if std::path::Path::new(&dir).join("flake.lock").exists() { git(&dir, &["add", "flake.lock"]).await?; } + // Build the commit message from what's actually staged so it + // reflects reality — and skip the commit entirely when nothing + // changed (avoids "nothing to commit" errors on redundant syncs). + let staged = git_staged_names(&dir).await?; + if staged.is_empty() { + return Ok(()); + } let msg = if initial { format!("seed meta from {} agent(s)", agents.len()) - } else if topology_changed { - "regenerate meta flake + topology".to_owned() } else { - "regenerate meta flake".to_owned() + // Compose a message that names every file that actually changed, + // mapping the on-disk filename to a short human label. + let labels: Vec<&str> = staged + .iter() + .filter_map(|f| match f.as_str() { + "flake.nix" => Some("flake"), + "flake.lock" => Some("lock"), + "topology.json" => Some("topology"), + "capabilities.json" => Some("capabilities"), + "tool-groups.json" => Some("tool-groups"), + "roles.json" => Some("roles"), + _ => None, + }) + .collect(); + if labels.is_empty() { + "meta: update".to_owned() + } else { + format!("meta: update {}", labels.join(", ")) + } }; git_commit(&dir, &msg).await?; Ok(()) @@ -625,6 +648,44 @@ async fn git_is_clean(dir: &Path) -> Result { Ok(out.stdout.iter().all(u8::is_ascii_whitespace)) } +/// Return the list of file names that are currently staged (index differs +/// from HEAD). On the initial commit (`HEAD` doesn't exist yet) falls back +/// to `git diff --cached --name-only HEAD` failing gracefully by using +/// `git status --porcelain` and collecting the `A ` / `M ` prefix lines. +async fn git_staged_names(dir: &Path) -> Result> { + // `--diff-filter=ACM` skips deleted entries — we only care about + // additions and modifications for message-building purposes. + let out = lifecycle::git_command() + .current_dir(dir) + .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"]) + .output() + .await + .with_context(|| format!("git diff --cached in {}", dir.display()))?; + if out.status.success() { + let names = String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(ToOwned::to_owned) + .collect(); + return Ok(names); + } + // Fallback for initial commit (no HEAD yet): parse `git status --porcelain`. + let st = lifecycle::git_command() + .current_dir(dir) + .args(["status", "--porcelain"]) + .output() + .await + .with_context(|| format!("git status in {}", dir.display()))?; + let names = String::from_utf8_lossy(&st.stdout) + .lines() + .filter(|l| l.starts_with("A ") || l.starts_with("M ")) + .filter_map(|l| l.get(3..)) + .map(ToOwned::to_owned) + .collect(); + Ok(names) +} + async fn git(dir: &Path, args: &[&str]) -> Result<()> { let out = lifecycle::git_command() .current_dir(dir)