hyperhive/hive-c0re/src/lifecycle/git.rs

169 lines
5.9 KiB
Rust

//! 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<String> {
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<String> {
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/<id>` (body = build error) and `denied/<id>` (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
}