fix(#1060): dynamic meta commit message from staged files; skip commit on no-op

This commit is contained in:
damocles 2026-06-02 09:22:20 +02:00
commit 3a1cfa26ec

View file

@ -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<String> = 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<bool> {
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<Vec<String>> {
// `--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)