//! Git bundle operations. //! //! Wraps `git bundle` CLI commands to create and apply bundles. //! Bundles are the serialization format used to store repository //! snapshots as PDS blobs. use std::path::Path; use tokio::process::Command; /// Result of creating a bundle, including the raw bytes and metadata /// needed to build a `BundleEntry`. #[derive(Debug)] pub struct CreatedBundle { /// raw bundle file bytes pub data: Vec, /// commit SHAs that the receiver must already have pub prerequisites: Vec, /// commit SHAs this bundle provides up to (the tip commits) pub tips: Vec, } /// Creates a full bundle containing the entire repository history. /// /// Equivalent to `git bundle create --all`. The bundle includes /// all branches and tags, and has no prerequisites. pub async fn create_full_bundle(repo_path: &Path) -> Result { let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; let bundle_path = tmp.path(); // create a full bundle with all refs let output = Command::new("git") .args(["bundle", "create", bundle_path.to_str().unwrap(), "--all"]) .current_dir(repo_path) .output() .await .map_err(|e| format!("failed to run git bundle create: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("git bundle create failed: {}", stderr.trim())); } let data = tokio::fs::read(bundle_path) .await .map_err(|e| format!("failed to read bundle file: {}", e))?; // get the tip commits (all branch heads) let tips = get_branch_tips(repo_path).await?; Ok(CreatedBundle { data, prerequisites: vec![], tips, }) } /// Creates an incremental bundle containing only commits since the given SHAs. /// /// `refs` is the list of refs to include (e.g. `["refs/heads/main"]`). /// `since_commits` are the prerequisite commits the receiver must already /// have — the bundle contains everything reachable from `refs` that is /// not reachable from `since_commits`. pub async fn create_incremental_bundle( repo_path: &Path, refs: &[&str], since_commits: &[&str], ) -> Result { let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; let bundle_path = tmp.path(); // build args: git bundle create ^ let mut args = vec![ "bundle".to_string(), "create".to_string(), bundle_path.to_str().unwrap().to_string(), ]; for r in refs { args.push(r.to_string()); } for sha in since_commits { args.push(format!("^{}", sha)); } let output = Command::new("git") .args(&args) .current_dir(repo_path) .output() .await .map_err(|e| format!("failed to run git bundle create: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "git bundle create (incremental) failed: {}", stderr.trim() )); } let data = tokio::fs::read(bundle_path) .await .map_err(|e| format!("failed to read bundle file: {}", e))?; // resolve ref names to their current SHAs for the tips let tips = resolve_refs_to_shas(repo_path, refs).await?; Ok(CreatedBundle { data, prerequisites: since_commits.iter().map(|s| s.to_string()).collect(), tips, }) } /// Applies a bundle to a repository. /// /// Writes the bundle bytes to a temp file and runs `git bundle unbundle`. /// The caller should set up refs afterward if needed. pub async fn apply_bundle(repo_path: &Path, bundle_bytes: &[u8]) -> Result<(), String> { let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; let bundle_path = tmp.path(); tokio::fs::write(bundle_path, bundle_bytes) .await .map_err(|e| format!("failed to write bundle to temp file: {}", e))?; let output = Command::new("git") .args(["bundle", "unbundle", bundle_path.to_str().unwrap()]) .current_dir(repo_path) .output() .await .map_err(|e| format!("failed to run git bundle unbundle: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("git bundle unbundle failed: {}", stderr.trim())); } Ok(()) } /// Verifies that a bundle's prerequisites are satisfied by the local repo. /// /// Returns Ok(true) if the bundle can be applied, Ok(false) if /// prerequisites are missing. pub async fn verify_bundle(repo_path: &Path, bundle_bytes: &[u8]) -> Result { let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; let bundle_path = tmp.path(); tokio::fs::write(bundle_path, bundle_bytes) .await .map_err(|e| format!("failed to write bundle to temp file: {}", e))?; let output = Command::new("git") .args(["bundle", "verify", bundle_path.to_str().unwrap()]) .current_dir(repo_path) .output() .await .map_err(|e| format!("failed to run git bundle verify: {}", e))?; Ok(output.status.success()) } /// Returns the current tip commit SHAs for all local branches. async fn get_branch_tips(repo_path: &Path) -> Result, String> { let output = Command::new("git") .args(["for-each-ref", "--format=%(objectname)", "refs/heads/"]) .current_dir(repo_path) .output() .await .map_err(|e| format!("failed to run git for-each-ref: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("git for-each-ref failed: {}", stderr.trim())); } let stdout = String::from_utf8_lossy(&output.stdout); let tips: Vec = stdout.lines().map(|l| l.trim().to_string()).collect(); Ok(tips) } /// Resolves ref names to their current commit SHAs. async fn resolve_refs_to_shas(repo_path: &Path, refs: &[&str]) -> Result, String> { let mut shas = Vec::with_capacity(refs.len()); for r in refs { let output = Command::new("git") .args(["rev-parse", r]) .current_dir(repo_path) .output() .await .map_err(|e| format!("failed to run git rev-parse {}: {}", r, e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("git rev-parse {} failed: {}", r, stderr.trim())); } let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); shas.push(sha); } Ok(shas) }