//! Thin shell wrappers around `git`. We avoid pulling in libgit2 — the daemon //! shells out for `cargo build` and friends anyway, and `git` is always on the //! MakeMachine. use anyhow::{Context, Result}; use std::path::Path; use tokio::process::Command; const POST_RECEIVE: &str = include_str!("../../hooks/post-receive"); pub async fn ensure_bare_repo(path: &Path) -> Result<()> { init_bare_repo(path).await?; install_hook(path).await?; Ok(()) } /// Like [`ensure_bare_repo`] but installs no `post-receive` hook. For an /// auxiliary repo (e.g. synckit) Sando only ever *fetches* — nobody pushes to /// its bare — so the build-triggering hook would be dead weight, and worse, if /// it ever did fire it would kick an MNW build. Keep the aux bare inert. pub async fn ensure_bare_repo_no_hook(path: &Path) -> Result<()> { init_bare_repo(path).await } /// `git init --bare` at `path` if it isn't already a repo. Idempotent. async fn init_bare_repo(path: &Path) -> Result<()> { if !path.join("HEAD").exists() { tokio::fs::create_dir_all(path).await?; let out = Command::new("git") .args(["init", "--bare", "--initial-branch=main"]) .arg(path) .output() .await .context("spawning git init")?; anyhow::ensure!( out.status.success(), "git init --bare failed: {}", String::from_utf8_lossy(&out.stderr), ); } Ok(()) } async fn install_hook(bare: &Path) -> Result<()> { let hook = bare.join("hooks").join("post-receive"); tokio::fs::create_dir_all(bare.join("hooks")).await?; tokio::fs::write(&hook, POST_RECEIVE).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perm = tokio::fs::metadata(&hook).await?.permissions(); perm.set_mode(0o755); tokio::fs::set_permissions(&hook, perm).await?; } Ok(()) } pub async fn resolve_ref(bare: &Path, refname: &str) -> Result { let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["rev-parse", refname]) .output() .await?; anyhow::ensure!( out.status.success(), "git rev-parse {refname} failed: {}", String::from_utf8_lossy(&out.stderr), ); Ok(String::from_utf8(out.stdout)?.trim().to_string()) } /// Fetch the deploy branch from `upstream` into the bare repo so a /// freshly-pushed sha becomes locally resolvable before `checkout_worktree`. /// Force-updates `refs/heads/` (worktrees are `--detach`, so the /// branch is never checked out). Pull-based deploys: the operator pushes to /// the canonical remote, Sando fetches it here. pub async fn fetch_upstream(bare: &Path, upstream: &str, branch: &str) -> Result<()> { let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["fetch", "--quiet", upstream]) .arg(format!("+refs/heads/{branch}:refs/heads/{branch}")) .output() .await .context("spawning git fetch")?; anyhow::ensure!( out.status.success(), "git fetch {upstream} {branch} failed: {}", String::from_utf8_lossy(&out.stderr), ); Ok(()) } /// True iff `sha` is reachable from `refs/heads/` in the bare repo. /// /// The same provenance seal `deploy/sando-self-update.sh` enforces before it /// builds, evaluated here so `/self-update` can refuse a non-deploy-branch sha /// synchronously instead of returning `accepted: true` and failing with exit 4 /// in the updater unit's journal, where nobody is watching. /// /// Fail-closed: `git merge-base --is-ancestor` exits 1 for a non-ancestor and /// >1 for an unresolvable ref, and both read as "not an ancestor" here. pub async fn is_ancestor(bare: &Path, sha: &str, branch: &str) -> Result { let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["merge-base", "--is-ancestor", sha]) .arg(format!("refs/heads/{branch}")) .output() .await .context("spawning git merge-base --is-ancestor")?; Ok(out.status.success()) } /// True if `sha` resolves to a commit object in the bare repo. Used to give a /// clear "push first" error instead of a cryptic `git worktree add` failure. pub async fn sha_present(bare: &Path, sha: &str) -> Result { let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["cat-file", "-e"]) .arg(format!("{sha}^{{commit}}")) .output() .await .context("spawning git cat-file")?; Ok(out.status.success()) } pub async fn checkout_worktree(bare: &Path, sha: &str, dest: &Path) -> Result<()> { if dest.exists() { // A dir already exists at `dest`. Trust it ONLY if it is a real git // worktree whose checked-out HEAD is exactly `sha`. Otherwise it is a // stale or partial leftover — a crashed `worktree add`, a half-populated // build dir, or a worktree for a different sha — which would silently // get compiled and shipped as if it were `sha`. Remove and recreate. if worktree_head_matches(bare, dest, sha) .await .unwrap_or(false) { return Ok(()); } tracing::warn!( dest = %dest.display(), sha, "existing path is not a valid worktree at this sha; removing and recreating", ); remove_worktree(bare, dest).await?; } tokio::fs::create_dir_all(dest.parent().unwrap()).await?; // Drop admin entries for worktrees whose dirs are gone, so `add` can't // collide with a leftover registration for this path. prune_worktrees(bare).await; let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["worktree", "add", "--detach"]) .arg(dest) .arg(sha) .output() .await?; anyhow::ensure!( out.status.success(), "git worktree add failed: {}", String::from_utf8_lossy(&out.stderr), ); Ok(()) } /// True iff `dest` is a git worktree whose checked-out HEAD commit equals the /// commit `sha` resolves to in `bare`. Any failure (not a worktree, unresolvable /// sha) is reported as "no match" rather than an error — the caller recreates. async fn worktree_head_matches(bare: &Path, dest: &Path, sha: &str) -> Result { let want = resolve_commit(bare, sha).await?; let Ok(have) = worktree_head(dest).await else { return Ok(false); }; Ok(have == want) } /// Resolve `sha` (short or full) to its full commit id in the bare repo. async fn resolve_commit(bare: &Path, sha: &str) -> Result { let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["rev-parse", "--verify", "--quiet"]) .arg(format!("{sha}^{{commit}}")) .output() .await?; anyhow::ensure!( out.status.success(), "sha {sha} does not resolve to a commit in the bare repo" ); Ok(String::from_utf8(out.stdout)?.trim().to_string()) } /// The full HEAD commit id checked out in a worktree dir. Errors if `dest` is /// not a valid git worktree. async fn worktree_head(dest: &Path) -> Result { let out = Command::new("git") .arg("-C") .arg(dest) .args(["rev-parse", "--verify", "--quiet", "HEAD"]) .output() .await?; anyhow::ensure!( out.status.success(), "not a git worktree (rev-parse HEAD failed)" ); Ok(String::from_utf8(out.stdout)?.trim().to_string()) } /// Remove a worktree dir and its admin registration. Prefers `git worktree /// remove --force`; falls back to a filesystem remove + prune when `dest` isn't /// a registered worktree (a partial leftover git won't recognize). async fn remove_worktree(bare: &Path, dest: &Path) -> Result<()> { let out = Command::new("git") .arg("--git-dir") .arg(bare) .args(["worktree", "remove", "--force"]) .arg(dest) .output() .await?; if !out.status.success() { if dest.exists() { tokio::fs::remove_dir_all(dest) .await .with_context(|| format!("removing stale worktree dir {}", dest.display()))?; } prune_worktrees(bare).await; } Ok(()) } /// `git worktree prune` — drop admin entries for worktrees whose dirs are gone. /// Best-effort: a prune failure is logged, never fatal. async fn prune_worktrees(bare: &Path) { match Command::new("git") .arg("--git-dir") .arg(bare) .args(["worktree", "prune"]) .output() .await { Ok(o) if !o.status.success() => { tracing::warn!(stderr = %String::from_utf8_lossy(&o.stderr), "git worktree prune failed"); } Err(e) => tracing::warn!(error = %e, "spawning git worktree prune failed"), _ => {} } } #[cfg(test)] mod tests { use super::*; async fn git(repo: &Path, args: &[&str]) -> std::process::Output { Command::new("git") .args(["-c", "user.email=t@t", "-c", "user.name=t"]) .current_dir(repo) .args(args) .output() .await .unwrap() } async fn rev_parse(repo: &Path, r: &str) -> String { let o = git(repo, &["rev-parse", r]).await; assert!(o.status.success(), "rev-parse {r} failed"); String::from_utf8(o.stdout).unwrap().trim().to_string() } /// A repo with two commits; returns (tmp, gitdir, sha1, sha2). async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path().join("repo"); tokio::fs::create_dir_all(&repo).await.unwrap(); assert!( git(&repo, &["init", "-q", "-b", "main"]) .await .status .success() ); tokio::fs::write(repo.join("a.txt"), b"one").await.unwrap(); assert!(git(&repo, &["add", "."]).await.status.success()); assert!( git(&repo, &["commit", "-q", "-m", "one"]) .await .status .success() ); let sha1 = rev_parse(&repo, "HEAD").await; tokio::fs::write(repo.join("b.txt"), b"two").await.unwrap(); assert!(git(&repo, &["add", "."]).await.status.success()); assert!( git(&repo, &["commit", "-q", "-m", "two"]) .await .status .success() ); let sha2 = rev_parse(&repo, "HEAD").await; let gitdir = repo.join(".git"); (tmp, gitdir, sha1, sha2) } #[tokio::test] async fn ensure_bare_repo_installs_hook_but_no_hook_variant_does_not() { let tmp = tempfile::tempdir().unwrap(); let with_hook = tmp.path().join("hooked.git"); let without = tmp.path().join("bare.git"); ensure_bare_repo(&with_hook).await.unwrap(); ensure_bare_repo_no_hook(&without).await.unwrap(); // Both are real bare repos. assert!(with_hook.join("HEAD").exists()); assert!(without.join("HEAD").exists()); // Only the main-repo variant carries the build-trigger hook. assert!(with_hook.join("hooks/post-receive").exists()); assert!(!without.join("hooks/post-receive").exists()); // Both are idempotent. ensure_bare_repo_no_hook(&without).await.unwrap(); assert!(!without.join("hooks/post-receive").exists()); } #[tokio::test] async fn is_ancestor_seals_the_deploy_branch() { // The provenance seal `/self-update` checks before it triggers the // privileged updater: a sha on the deploy branch passes, anything else // is refused fail-closed. `two_commit_repo` puts both commits on `main`. let (tmp, gitdir, sha1, sha2) = two_commit_repo().await; assert!(is_ancestor(&gitdir, &sha1, "main").await.unwrap()); assert!(is_ancestor(&gitdir, &sha2, "main").await.unwrap()); // A commit that exists but is not on the deploy branch. let repo = tmp.path().join("repo"); assert!( git(&repo, &["checkout", "-q", "-b", "side", &sha1]) .await .status .success() ); tokio::fs::write(repo.join("c.txt"), b"three") .await .unwrap(); assert!(git(&repo, &["add", "."]).await.status.success()); assert!( git(&repo, &["commit", "-q", "-m", "three"]) .await .status .success() ); let side = rev_parse(&repo, "HEAD").await; assert!( !is_ancestor(&gitdir, &side, "main").await.unwrap(), "a feature-branch tip is not deployable", ); // An unresolvable ref is "not an ancestor", never an error that a caller // could mistake for a pass. assert!( !is_ancestor(&gitdir, "0123456789abcdef0123456789abcdef01234567", "main") .await .unwrap() ); assert!(!is_ancestor(&gitdir, &sha1, "no-such-branch").await.unwrap()); } #[tokio::test] async fn checkout_worktree_creates_at_sha() { let (tmp, gitdir, sha1, _sha2) = two_commit_repo().await; let dest = tmp.path().join("wt"); checkout_worktree(&gitdir, &sha1, &dest).await.unwrap(); assert!(dest.join("a.txt").exists()); assert!(!dest.join("b.txt").exists(), "sha1 predates b.txt"); assert_eq!(rev_parse(&dest, "HEAD").await, sha1); } #[tokio::test] async fn checkout_worktree_idempotent_reuses_valid_worktree() { let (tmp, gitdir, sha1, _) = two_commit_repo().await; let dest = tmp.path().join("wt"); checkout_worktree(&gitdir, &sha1, &dest).await.unwrap(); // A marker that an idempotent re-checkout must NOT wipe. tokio::fs::write(dest.join("marker"), b"x").await.unwrap(); checkout_worktree(&gitdir, &sha1, &dest).await.unwrap(); assert!( dest.join("marker").exists(), "valid worktree reused, not recreated" ); assert_eq!(rev_parse(&dest, "HEAD").await, sha1); } #[tokio::test] async fn checkout_worktree_replaces_partial_leftover() { // dest exists but is NOT a worktree (a crashed mid-add). It must be // replaced with a real checkout, not built as-is. let (tmp, gitdir, sha1, _) = two_commit_repo().await; let dest = tmp.path().join("wt"); tokio::fs::create_dir_all(&dest).await.unwrap(); tokio::fs::write(dest.join("garbage"), b"partial") .await .unwrap(); checkout_worktree(&gitdir, &sha1, &dest).await.unwrap(); assert!( dest.join("a.txt").exists(), "real checkout populated the dir" ); assert!(!dest.join("garbage").exists(), "stale leftover removed"); assert_eq!(rev_parse(&dest, "HEAD").await, sha1); } #[tokio::test] async fn checkout_worktree_recreates_when_existing_sha_differs() { let (tmp, gitdir, sha1, sha2) = two_commit_repo().await; let dest = tmp.path().join("wt"); checkout_worktree(&gitdir, &sha2, &dest).await.unwrap(); assert!(dest.join("b.txt").exists()); // Re-checkout the SAME dest at the older sha — the wrong-sha worktree // must be torn down and rebuilt, not silently reused. checkout_worktree(&gitdir, &sha1, &dest).await.unwrap(); assert_eq!(rev_parse(&dest, "HEAD").await, sha1); assert!( !dest.join("b.txt").exists(), "now at sha1, which predates b.txt" ); } }