//! Git shellout helpers for the per-agent proposed/applied repos: run //! `git` with the hive-c0re identity, resolve/plant refs and tags, and //! fetch proposal commits into the applied repo. use std::path::Path; use anyhow::{Context, Result, bail}; use tokio::process::Command; const GIT_NAME: &str = "c0re"; const GIT_EMAIL: &str = "c0re@hyperhive.local"; /// Return the SHA of the root (oldest, no-parent) commit in a repo. /// Used to seed the applied repo at the template baseline rather than at /// `main`, so `deployed/0` records the template, not the manager's first commit. pub(super) async fn git_root_commit(dir: &Path) -> Result { let out = git_command() .current_dir(dir) .args(["rev-list", "--max-parents=0", "HEAD"]) .output() .await .with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?; if !out.status.success() { anyhow::bail!( "git rev-list --max-parents=0 failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) } pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> { git( dir, &[ "-c", &format!("user.name={GIT_NAME}"), "-c", &format!("user.email={GIT_EMAIL}"), "commit", "-m", message, ], ) .await } /// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in /// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise. /// /// `kill_on_drop(true)`: if the caller's future is dropped before the child /// exits — e.g. a `tokio::time::timeout` around startup migration fires — the /// git child is killed instead of orphaned (left retrying an unreachable /// forge). No-op on normal completion, where the child has already exited. #[must_use] pub fn git_command() -> Command { let exe = std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "git".into()); let mut cmd = Command::new(exe); cmd.kill_on_drop(true); cmd } pub async fn git(dir: &Path, args: &[&str]) -> Result<()> { let out = git_command() .current_dir(dir) .args(args) .output() .await .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; if !out.status.success() { bail!( "git {} failed ({}): {}", args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(()) } /// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { let out = git_command() .current_dir(dir) .args(["rev-parse", refname]) .output() .await .with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?; if !out.status.success() { bail!( "git rev-parse {refname} failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) } /// Plant a lightweight tag at `target`. Errors if the tag already /// exists — we want loud failures on id reuse, not silent /// overwrites. pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { git(dir, &["tag", name, target]).await } /// Plant an annotated tag with `body` as the message. Used for /// `failed/` (body = build error) and `denied/` (body = /// operator note). Multi-line bodies handled via stdin so we don't /// have to escape anything. pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> { use tokio::io::AsyncWriteExt; // Annotated tags are git objects, so they need a tagger identity // (same constraint as a commit). Pass the hive-c0re identity // inline rather than relying on a global git config — applied // repos are hive-c0re-owned and the host's user might not have // user.email set. let mut child = git_command() .current_dir(dir) .args([ "-c", &format!("user.name={GIT_NAME}"), "-c", &format!("user.email={GIT_EMAIL}"), "tag", "-a", name, target, "-F", "-", ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(body.as_bytes()) .await .context("write tag body to git stdin")?; // Drop closes stdin so git can finish reading. drop(stdin); } let out = child.wait_with_output().await.context("wait git tag -a")?; if !out.status.success() { bail!( "git tag -a {name} failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(()) } /// Replace working tree + index with the tree at `target` without /// moving HEAD. `applied/main` stays pointing at the last known-good /// `deployed/*` while we let `nixos-container update` evaluate the /// candidate. On build failure callers reset back to HEAD; on /// success they fast-forward main to `target`. pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { git(dir, &["read-tree", "--reset", "-u", target]).await } /// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main` /// to the just-deployed proposal commit. Uses `update-ref`, not /// `branch -f`, so it works regardless of where HEAD currently sits. pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> { git(dir, &["update-ref", refname, target]).await }