From 63ef69674b19cdcad80a61e106ee396230a50804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 22:52:23 +0200 Subject: [PATCH] lifecycle: git helpers for tag-driven applied repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new plumbing for the upcoming flow: git_fetch_to_tag (pulls a sha from proposed into applied and pins it as a tag in one shot), git_rev_parse (normalises shas + reads back tag targets), git_tag / git_tag_annotated (lightweight vs body- carrying for failed/denied), git_read_tree_reset (replace working tree without moving HEAD — lets main stay on last known-good across an in-flight build), git_update_ref (ff main on deploy). annotated tag bodies go via stdin to avoid escape games. all dead-code-allowed; callers land in subsequent commits. --- hive-c0re/src/lifecycle.rs | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index e67fa93..3daee54 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -457,6 +457,99 @@ async fn git(dir: &Path, args: &[&str]) -> Result<()> { Ok(()) } +/// Fetch `sha` from the `src` git repo into `dst` and pin it as +/// `refs/tags/`. Used at request_apply_commit time so hive-c0re +/// captures an immutable handle on the manager's commit; subsequent +/// amendments / force-pushes in `src` no longer affect what gets +/// built. Returns the resolved sha (which equals `sha` on success +/// but normalised — short shas get expanded). +#[allow(dead_code)] // wired up by manager_server in a later commit +pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result { + let src_str = src.display().to_string(); + let refspec = format!("{sha}:refs/tags/{tag}"); + git(dst, &["fetch", "--no-tags", &src_str, &refspec]).await?; + git_rev_parse(dst, &format!("refs/tags/{tag}")).await +} + +/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. +#[allow(dead_code)] +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. +#[allow(dead_code)] +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. +#[allow(dead_code)] +pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> { + use tokio::io::AsyncWriteExt; + let mut child = git_command() + .current_dir(dir) + .args(["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`. +#[allow(dead_code)] +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. +#[allow(dead_code)] +pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> { + git(dir, &["update-ref", refname, target]).await +} + /// Returns true if the command exits 0. async fn git_status(dir: &Path, args: &[&str]) -> Result { let st = git_command()