//! Per-mission repo checkout. //! //! Missions execute their coding / benchmark / security phases against //! a filesystem checkout of `missions.repo_id` at //! `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`. That path is what //! `security_scan::exec_target` + `benchmark_runner::exec_target` //! both `docker exec -w` into. //! //! `ensure_checkout` is called from `mission_orchestrator::on_launch` //! and is idempotent: //! - no repo_id → no-op (Ok(None)) //! - dir already a git repo → `fetch + reset --hard origin/` //! to bring it in sync //! - dir missing → `git clone --depth 1 ` //! //! Auth: relies on the ambient git credential setup (SSH agent, //! .netrc, or git-credential helper) in the process environment. We //! deliberately don't embed tokens in URLs — footgun risk outweighs //! the ergonomics, and prod already runs with a helper configured. use std::path::PathBuf; use tokio::process::Command; use uuid::Uuid; fn missions_root() -> PathBuf { std::env::var("CLAWMATES_MISSIONS_ROOT") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions")) } pub fn checkout_path(mission_id: Uuid) -> PathBuf { missions_root().join(mission_id.to_string()).join("repo") } /// Ensure the mission's repo is checked out at `checkout_path`. /// Returns Ok(None) when the mission has no repo bound, Ok(Some(path)) /// when a checkout is in place (freshly cloned or brought up-to-date). pub async fn ensure_checkout( pool: &sqlx::PgPool, workspace_id: cm_domain::WorkspaceId, mission_id: Uuid, ) -> Result, String> { let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid()) .await .map_err(|e| format!("load mission: {e}"))? .ok_or_else(|| "mission not found".to_string())?; let Some(repo_id) = mission.repo_id else { return Ok(None); }; let repo = cm_db::repo::repos::get(pool, repo_id, workspace_id) .await .map_err(|e| format!("load repo {repo_id}: {e}"))?; let clone_url = repo .clone_url .as_deref() .ok_or_else(|| format!("repo {repo_id} has no clone_url"))?; let default_branch = repo.default_branch.as_deref().unwrap_or("main"); let path = checkout_path(mission_id); if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("mkdir {}: {e}", parent.display()))?; } if path.join(".git").exists() { fetch_and_reset(&path, default_branch).await?; } else { clone(&path, clone_url).await?; } Ok(Some(path)) } async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> { let out = Command::new("git") .args([ "clone", "--depth", "1", url, &path.display().to_string(), ]) .output() .await .map_err(|e| format!("spawn git clone: {e}"))?; if !out.status.success() { return Err(format!( "git clone {url} → exit {}: {}", out.status, String::from_utf8_lossy(&out.stderr) .chars() .take(400) .collect::() )); } Ok(()) } async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> { let fetch = Command::new("git") .args(["-C", &path.display().to_string(), "fetch", "--depth", "1", "origin", branch]) .output() .await .map_err(|e| format!("spawn git fetch: {e}"))?; if !fetch.status.success() { return Err(format!( "git fetch origin {branch} → exit {}: {}", fetch.status, String::from_utf8_lossy(&fetch.stderr) .chars() .take(400) .collect::() )); } let reset = Command::new("git") .args([ "-C", &path.display().to_string(), "reset", "--hard", &format!("origin/{branch}"), ]) .output() .await .map_err(|e| format!("spawn git reset: {e}"))?; if !reset.status.success() { return Err(format!( "git reset --hard origin/{branch} → exit {}: {}", reset.status, String::from_utf8_lossy(&reset.stderr) .chars() .take(400) .collect::() )); } Ok(()) }