use anyhow::{Context as _, Result, bail}; use log::{debug, error, info, warn}; use std::path::Path; use std::time::Duration; use tokio::process::Command; /// Default timeout for git operations (used when not configured). pub const GIT_TIMEOUT_DEFAULT: Duration = Duration::from_secs(60); /// Default git clone depth (shallow clone with 1 commit). pub const GIT_DEPTH_DEFAULT: u32 = 1; /// Timeout for LFS operations (longer due to large file downloads) const LFS_TIMEOUT: Duration = Duration::from_secs(300); /// LFS pointer file signature (per Git LFS spec) const LFS_POINTER_SIGNATURE: &str = "version https://git-lfs.github.com/spec/v1"; /// Maximum size for a valid LFS pointer file (per spec) const LFS_POINTER_MAX_SIZE: u64 = 1024; /// Create a git Command with clean environment isolation. /// /// Strips `GIT_DIR`, `GIT_WORK_TREE`, and `GIT_INDEX_FILE` so that git /// discovers the repository from the working directory set via /// `.current_dir()`, not from inherited environment variables. /// /// This is defensive: in production these vars are never set, but it /// prevents failures when tests run inside git hooks (e.g., a pre-commit /// hook that invokes `cargo test`). fn git_command() -> Command { let mut cmd = Command::new("git"); cmd.env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE"); cmd } /// Create a git Command that allows the file:// protocol. /// /// Git ≥ 2.38.1 disables file:// by default (CVE-2022-39253), but the /// restriction targets local-clone hardlink attacks, not file:// transport. /// Submodule URLs come from the trusted config, so this is safe. /// Used only for submodule operations whose internal clones may use file://. fn git_command_allow_file_transport() -> Command { let mut cmd = git_command(); cmd.env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "protocol.file.allow") .env("GIT_CONFIG_VALUE_0", "always"); cmd } /// Run a git command with timeout and standard error handling. /// /// Builds a `git` `Command`, optionally sets the working directory, /// enforces a timeout, and converts non-zero exit into an `anyhow` error. async fn run_git(args: &[&str], dir: Option<&Path>, timeout: Duration, op: &str) -> Result<()> { run_git_cmd(git_command(), args, dir, timeout, op).await } /// Like [`run_git`] but uses a pre-built `Command` (e.g. one that allows /// the file:// protocol for submodule clones). async fn run_git_cmd( mut cmd: Command, args: &[&str], dir: Option<&Path>, timeout: Duration, op: &str, ) -> Result<()> { cmd.args(args); if let Some(d) = dir { cmd.current_dir(d); } let output = tokio::time::timeout(timeout, cmd.output()) .await .with_context(|| format!("{op} timed out"))? .with_context(|| format!("failed to execute {op}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("{op} failed: {}", stderr.trim()); } Ok(()) } /// Synchronize a Git repository: clone if not exists, pull if exists. /// Automatically initializes submodules and fetches LFS objects if needed. /// /// # Errors /// /// Returns an error if the clone, pull, submodule init, or LFS fetch fails. pub async fn sync_repo( repo_url: &str, branch: &str, clone_dir: &Path, timeout: Duration, depth: u32, ) -> Result<()> { let is_pull = clone_dir.exists(); if is_pull { pull(clone_dir, branch, timeout, depth).await?; } else if let Err(e) = clone(repo_url, branch, clone_dir, timeout, depth).await { if clone_dir.exists() { warn!( "cleaning up partial clone after failure: {}", clone_dir.display() ); if let Err(cleanup_err) = tokio::fs::remove_dir_all(clone_dir).await { error!( "failed to clean up partial clone: path={} error={cleanup_err}", clone_dir.display() ); } } return Err(e); } // Initialize submodules before LFS (submodule files may contain LFS pointers) maybe_init_submodules(clone_dir, timeout, depth, is_pull).await?; // Handle LFS after clone/pull + submodules maybe_fetch_lfs(clone_dir).await?; Ok(()) } /// Check if the remote branch has new commits compared to local HEAD. /// Returns `Ok(true)` if new commits are available, `Ok(false)` if up-to-date. /// /// This function: /// 1. Returns true if `clone_dir` doesn't exist (needs initial clone) /// 2. Runs `git fetch` to update remote refs (with `--depth` if depth > 0) /// 3. Compares local HEAD with `origin/{branch}` /// 4. Does NOT modify the working directory (no reset/checkout) /// /// # Errors /// /// Returns an error if git fetch or rev-parse fails. pub async fn has_remote_changes( clone_dir: &Path, branch: &str, timeout: Duration, depth: u32, ) -> Result { // If clone directory doesn't exist, treat as "needs update" if !clone_dir.exists() { debug!( "clone directory does not exist, needs initial clone: {}", clone_dir.display() ); return Ok(true); } // Fetch from remote (update refs only, no working tree changes) debug!( "fetching remote refs: path={} branch={branch}", clone_dir.display() ); let depth_str = depth.to_string(); let mut fetch_args = vec!["fetch"]; if depth > 0 { fetch_args.push("--depth"); fetch_args.push(&depth_str); } fetch_args.extend_from_slice(&["origin", branch]); run_git(&fetch_args, Some(clone_dir), timeout, "git fetch").await?; // Get local HEAD commit let local_head = get_commit_hash(clone_dir, "HEAD").await?; // Get remote branch commit let remote_ref = format!("origin/{branch}"); let remote_head = get_commit_hash(clone_dir, &remote_ref).await?; debug!( "comparing commits: path={} local={local_head} remote={remote_head}", clone_dir.display() ); Ok(local_head != remote_head) } /// Get the full commit hash for a ref (HEAD, branch name, etc.) async fn get_commit_hash(clone_dir: &Path, ref_name: &str) -> Result { let output = git_command() .args(["rev-parse", ref_name]) .current_dir(clone_dir) .output() .await .context("failed to execute git rev-parse")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git rev-parse {} failed: {}", ref_name, stderr.trim()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } async fn clone( repo_url: &str, branch: &str, clone_dir: &Path, timeout: Duration, depth: u32, ) -> Result<()> { info!( "cloning repository: repo={repo_url} branch={branch} path={}", clone_dir.display() ); // Create parent directory if needed if let Some(parent) = clone_dir.parent() { tokio::fs::create_dir_all(parent) .await .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; } let clone_dir_str = clone_dir.display().to_string(); let depth_str = depth.to_string(); let mut args = vec!["clone", "--branch", branch, "--single-branch"]; if depth > 0 { args.push("--depth"); args.push(&depth_str); } args.push(repo_url); args.push(clone_dir_str.as_str()); run_git(&args, None, timeout, "git clone").await?; debug!("clone completed: {}", clone_dir.display()); Ok(()) } async fn pull(clone_dir: &Path, branch: &str, timeout: Duration, depth: u32) -> Result<()> { info!( "pulling latest changes: branch={branch} path={}", clone_dir.display() ); // Fetch from origin (shallow or full depending on depth) let depth_str = depth.to_string(); let mut fetch_args = vec!["fetch"]; if depth > 0 { fetch_args.push("--depth"); fetch_args.push(&depth_str); } fetch_args.extend_from_slice(&["origin", branch]); run_git(&fetch_args, Some(clone_dir), timeout, "git fetch").await?; // Reset to origin/branch to discard any local changes let reset_ref = format!("origin/{branch}"); run_git( &["reset", "--hard", &reset_ref], Some(clone_dir), timeout, "git reset", ) .await?; debug!("pull completed: {}", clone_dir.display()); Ok(()) } /// Check if the repository has LFS configured via .gitattributes. async fn has_lfs_configured(clone_dir: &Path) -> bool { let gitattributes = clone_dir.join(".gitattributes"); tokio::fs::read_to_string(&gitattributes) .await .is_ok_and(|content| content.contains("filter=lfs")) } /// Scan repository for LFS pointer files. /// Returns true if any tracked file matches the LFS pointer signature. async fn has_lfs_pointers(clone_dir: &Path) -> Result { // Use git ls-files to get tracked files let output = git_command() .args(["ls-files", "-z"]) // -z for null-separated output .current_dir(clone_dir) .output() .await .context("failed to list git files")?; if !output.status.success() { // If ls-files fails, assume pointers might exist (conservative) return Ok(true); } let files_str = String::from_utf8_lossy(&output.stdout); for file_path in files_str.split('\0').filter(|s| !s.is_empty()) { let full_path = clone_dir.join(file_path); // Check file size first (pointer files are < 1024 bytes) let Ok(metadata) = tokio::fs::metadata(&full_path).await else { continue; }; if metadata.len() >= LFS_POINTER_MAX_SIZE || !metadata.is_file() { continue; } // Read and check for LFS signature let Ok(content) = tokio::fs::read_to_string(&full_path).await else { continue; }; if content.starts_with(LFS_POINTER_SIGNATURE) { debug!("found LFS pointer: {file_path}"); return Ok(true); } } Ok(false) } async fn is_lfs_available() -> bool { git_command() .args(["lfs", "version"]) .output() .await .map(|o| o.status.success()) .unwrap_or(false) } async fn lfs_pull(clone_dir: &Path) -> Result<()> { info!("fetching LFS objects: {}", clone_dir.display()); run_git( &["lfs", "pull"], Some(clone_dir), LFS_TIMEOUT, "git lfs pull", ) .await?; debug!("LFS pull completed: {}", clone_dir.display()); Ok(()) } /// Detect and fetch LFS objects if needed. /// /// Detection strategy: /// 1. Check .gitattributes for `filter=lfs` /// 2. If configured, scan for actual pointer files /// 3. If pointers exist, verify git-lfs is available /// 4. Run `git lfs pull` to fetch objects async fn maybe_fetch_lfs(clone_dir: &Path) -> Result<()> { // Step 1: Quick check for LFS configuration if !has_lfs_configured(clone_dir).await { debug!("no LFS configuration found: {}", clone_dir.display()); return Ok(()); } info!( "LFS configured, checking for pointers: {}", clone_dir.display() ); // Step 2: Scan for actual pointer files match has_lfs_pointers(clone_dir).await { Ok(true) => { // Pointers found, need to fetch } Ok(false) => { debug!("no LFS pointers found: {}", clone_dir.display()); return Ok(()); } Err(e) => { // If scan fails, try to fetch anyway (conservative approach) debug!("LFS pointer scan failed, attempting fetch: {e}"); } } // Step 3: Verify git-lfs is available if !is_lfs_available().await { bail!("repository requires git-lfs but git-lfs is not installed"); } // Step 4: Fetch LFS objects lfs_pull(clone_dir).await } /// Check if the repository has submodules configured via .gitmodules. async fn has_submodules(clone_dir: &Path) -> bool { let gitmodules = clone_dir.join(".gitmodules"); tokio::fs::read_to_string(&gitmodules) .await .is_ok_and(|content| !content.trim().is_empty()) } /// Detect and initialize submodules if needed. /// /// Detection: checks for `.gitmodules` (single stat call when absent). /// On pull: runs `git submodule sync --recursive` first to handle URL changes. /// Then: `git submodule update --init --recursive [--depth 1]`. async fn maybe_init_submodules( clone_dir: &Path, timeout: Duration, depth: u32, is_pull: bool, ) -> Result<()> { if !has_submodules(clone_dir).await { debug!("no submodules configured: {}", clone_dir.display()); return Ok(()); } info!("submodules detected, initializing: {}", clone_dir.display()); // On pull, sync URLs first (handles upstream submodule URL changes) if is_pull { run_git( &["submodule", "sync", "--recursive"], Some(clone_dir), timeout, "git submodule sync", ) .await?; } // Initialize and update submodules. // Uses file-transport-allowing command because `git submodule update` // internally clones each submodule, and URLs may use the file:// scheme. let depth_str = depth.to_string(); let mut args = vec!["submodule", "update", "--init", "--recursive"]; if depth > 0 { args.push("--depth"); args.push(&depth_str); } run_git_cmd( git_command_allow_file_transport(), &args, Some(clone_dir), timeout, "git submodule update", ) .await?; debug!( "submodule initialization completed: {}", clone_dir.display() ); Ok(()) } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::expect_used)] mod tests { use super::*; use crate::test_support::{cleanup, temp_dir}; use tokio::fs; use tokio::process::Command; /// Alias for `git_command_allow_file_transport()` — tests use file:// /// URLs for bare repos, so the file protocol must be allowed. fn git_cmd() -> Command { git_command_allow_file_transport() } async fn configure_test_git_user(dir: &Path) { git_cmd() .args(["config", "user.email", "test@test.com"]) .current_dir(dir) .output() .await .unwrap(); git_cmd() .args(["config", "user.name", "Test"]) .current_dir(dir) .output() .await .unwrap(); } /// Create a local bare git repository with an initial commit on the specified branch. /// Returns a file:// URL that works with git clone --depth 1. async fn create_local_repo(temp: &Path, branch: &str) -> String { let bare_repo = temp.join("origin.git"); fs::create_dir_all(&bare_repo).await.unwrap(); // Initialize bare repo with explicit initial branch let output = git_cmd() .args(["init", "--bare", "--initial-branch", branch]) .current_dir(&bare_repo) .output() .await .unwrap(); assert!(output.status.success(), "git init failed"); // Create a working copy to make initial commit let work_dir = temp.join("work"); let output = git_cmd() .args([ "clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap(), ]) .output() .await .unwrap(); assert!( output.status.success(), "git clone failed: {}", String::from_utf8_lossy(&output.stderr) ); // Configure git user for commit configure_test_git_user(&work_dir).await; // Checkout the target branch (in case clone defaulted to something else) let output = git_cmd() .args(["checkout", "-B", branch]) .current_dir(&work_dir) .output() .await .unwrap(); assert!( output.status.success(), "git checkout failed: {}", String::from_utf8_lossy(&output.stderr) ); // Create initial commit fs::write(work_dir.join("README.md"), "# Test Repo") .await .unwrap(); let output = git_cmd() .args(["add", "README.md"]) .current_dir(&work_dir) .output() .await .unwrap(); assert!(output.status.success(), "git add failed"); let output = git_cmd() .args(["commit", "-m", "Initial commit"]) .current_dir(&work_dir) .output() .await .unwrap(); assert!( output.status.success(), "git commit failed: {}", String::from_utf8_lossy(&output.stderr) ); // Push to origin let output = git_cmd() .args(["push", "-u", "origin", branch]) .current_dir(&work_dir) .output() .await .unwrap(); assert!( output.status.success(), "git push failed: {}", String::from_utf8_lossy(&output.stderr) ); // Clean up working copy let _ = fs::remove_dir_all(&work_dir).await; // Return file:// URL so --depth works correctly format!("file://{}", bare_repo.to_str().unwrap()) } #[tokio::test] async fn clone_creates_directory_and_clones_repo() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("test-repo"); let result = clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1).await; assert!(result.is_ok(), "clone should succeed: {result:?}"); assert!(clone_dir.exists(), "clone directory should exist"); assert!( clone_dir.join(".git").exists(), ".git directory should exist" ); cleanup(&temp).await; } #[tokio::test] async fn clone_invalid_url_returns_error() { let temp = temp_dir("git-test").await; let clone_dir = temp.join("invalid-repo"); let result = clone( "/nonexistent/path/to/repo.git", "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1, ) .await; assert!(result.is_err(), "clone should fail for invalid URL"); cleanup(&temp).await; } #[tokio::test] async fn clone_invalid_branch_returns_error() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("invalid-branch"); let result = clone( &repo_url, "nonexistent-branch-xyz", &clone_dir, GIT_TIMEOUT_DEFAULT, 1, ) .await; assert!(result.is_err(), "clone should fail for invalid branch"); cleanup(&temp).await; } #[tokio::test] async fn pull_updates_existing_repo() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("pull-test"); // First clone clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("initial clone should succeed"); // Push a new commit to origin let work_dir = temp.join("work-pull"); push_new_commit(&repo_url, &work_dir, "pulled.txt", "pulled content").await; // Pull should fetch the new commit pull(&clone_dir, "main", GIT_TIMEOUT_DEFAULT, 1) .await .expect("pull should succeed"); // Verify the new file appeared in the working copy let pulled_file = clone_dir.join("pulled.txt"); assert!(pulled_file.exists(), "pulled file should exist after pull"); let content = fs::read_to_string(&pulled_file).await.unwrap(); assert_eq!(content, "pulled content"); cleanup(&temp).await; } #[tokio::test] async fn pull_invalid_branch_returns_error() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("pull-invalid-branch"); // First clone clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("initial clone should succeed"); // Pull with invalid branch let result = pull(&clone_dir, "nonexistent-branch-xyz", GIT_TIMEOUT_DEFAULT, 1).await; assert!(result.is_err(), "pull should fail for invalid branch"); cleanup(&temp).await; } #[tokio::test] async fn sync_repo_clones_when_not_exists() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("sync-clone"); let result = sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1).await; assert!(result.is_ok(), "sync should succeed: {result:?}"); assert!(clone_dir.exists(), "clone directory should exist"); cleanup(&temp).await; } #[tokio::test] async fn sync_repo_pulls_when_exists() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("sync-pull"); // First sync (clone) sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("initial sync should succeed"); // Push a new commit to origin let work_dir = temp.join("work-sync"); push_new_commit(&repo_url, &work_dir, "synced.txt", "synced content").await; // Second sync should pull the new commit sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("second sync should succeed"); // Verify the new file appeared let synced_file = clone_dir.join("synced.txt"); assert!(synced_file.exists(), "synced file should exist after pull"); let content = fs::read_to_string(&synced_file).await.unwrap(); assert_eq!(content, "synced content"); cleanup(&temp).await; } // LFS tests #[tokio::test] async fn has_lfs_configured_with_lfs() { let temp = temp_dir("git-test").await; fs::write( temp.join(".gitattributes"), "*.bin filter=lfs diff=lfs merge=lfs -text\n", ) .await .unwrap(); assert!(has_lfs_configured(&temp).await); cleanup(&temp).await; } #[tokio::test] async fn has_lfs_configured_without_lfs() { let temp = temp_dir("git-test").await; fs::write(temp.join(".gitattributes"), "*.txt text\n") .await .unwrap(); assert!(!has_lfs_configured(&temp).await); cleanup(&temp).await; } #[tokio::test] async fn has_lfs_configured_no_file() { let temp = temp_dir("git-test").await; // No .gitattributes file assert!(!has_lfs_configured(&temp).await); cleanup(&temp).await; } #[tokio::test] async fn has_lfs_pointers_detects_pointer() { let temp = temp_dir("git-test").await; // Initialize git repo init_git_repo(&temp).await; // Create LFS pointer file let pointer_content = "version https://git-lfs.github.com/spec/v1\n\ oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n\ size 12345\n"; fs::write(temp.join("large.bin"), pointer_content) .await .unwrap(); // Stage the file stage_file(&temp, "large.bin").await; let result = has_lfs_pointers(&temp).await; assert!(result.is_ok()); assert!(result.unwrap()); cleanup(&temp).await; } #[tokio::test] async fn has_lfs_pointers_ignores_non_pointers() { let temp = temp_dir("git-test").await; // Initialize git repo init_git_repo(&temp).await; // Create normal small file fs::write(temp.join("readme.txt"), "Hello World") .await .unwrap(); stage_file(&temp, "readme.txt").await; let result = has_lfs_pointers(&temp).await; assert!(result.is_ok()); assert!(!result.unwrap()); cleanup(&temp).await; } #[tokio::test] async fn has_lfs_pointers_ignores_large_files() { let temp = temp_dir("git-test").await; init_git_repo(&temp).await; // Create large file that starts with LFS signature (edge case) let mut content = String::from("version https://git-lfs.github.com/spec/v1\n"); content.push_str(&"x".repeat(2000)); // > 1024 bytes fs::write(temp.join("large.txt"), &content).await.unwrap(); stage_file(&temp, "large.txt").await; let result = has_lfs_pointers(&temp).await; assert!(result.is_ok()); assert!(!result.unwrap()); // Should be ignored due to size cleanup(&temp).await; } #[tokio::test] async fn maybe_fetch_lfs_no_config() { let temp = temp_dir("git-test").await; init_git_repo(&temp).await; // No .gitattributes = no LFS let result = maybe_fetch_lfs(&temp).await; assert!(result.is_ok()); cleanup(&temp).await; } // Helper functions for LFS tests async fn init_git_repo(dir: &Path) { git_cmd() .args(["init"]) .current_dir(dir) .output() .await .unwrap(); configure_test_git_user(dir).await; } async fn stage_file(dir: &Path, filename: &str) { git_cmd() .args(["add", filename]) .current_dir(dir) .output() .await .unwrap(); } /// Clone a bare repo into `work_dir`, commit a new file, and push it. async fn push_new_commit(repo_url: &str, work_dir: &Path, filename: &str, content: &str) { git_cmd() .args(["clone", repo_url, work_dir.to_str().unwrap()]) .output() .await .unwrap(); configure_test_git_user(work_dir).await; fs::write(work_dir.join(filename), content).await.unwrap(); git_cmd() .args(["add", filename]) .current_dir(work_dir) .output() .await .unwrap(); git_cmd() .args(["commit", "-m", "New commit"]) .current_dir(work_dir) .output() .await .unwrap(); git_cmd() .args(["push"]) .current_dir(work_dir) .output() .await .unwrap(); } // has_remote_changes tests #[tokio::test] async fn has_remote_changes_nonexistent_dir_returns_true() { let temp = temp_dir("git-test").await; let nonexistent = temp.join("does-not-exist"); let result = has_remote_changes(&nonexistent, "main", GIT_TIMEOUT_DEFAULT, 1).await; assert!(result.is_ok()); assert!(result.unwrap(), "nonexistent directory should return true"); cleanup(&temp).await; } #[tokio::test] async fn has_remote_changes_up_to_date_returns_false() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("test-clone"); // Clone the repo clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .unwrap(); // Check for changes - should be false since we just cloned let result = has_remote_changes(&clone_dir, "main", GIT_TIMEOUT_DEFAULT, 1).await; assert!(result.is_ok(), "has_remote_changes failed: {result:?}"); assert!(!result.unwrap(), "freshly cloned repo should be up-to-date"); cleanup(&temp).await; } #[tokio::test] async fn has_remote_changes_detects_new_commits() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("test-clone"); // Clone the repo clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .unwrap(); // Push a new commit to the origin let work_dir = temp.join("work2"); push_new_commit(&repo_url, &work_dir, "new-file.txt", "new content").await; // Now check for changes - should detect the new commit let result = has_remote_changes(&clone_dir, "main", GIT_TIMEOUT_DEFAULT, 1).await; assert!(result.is_ok(), "has_remote_changes failed: {result:?}"); assert!(result.unwrap(), "should detect new commits on remote"); cleanup(&temp).await; } // git_depth tests #[tokio::test] async fn clone_full_depth_creates_complete_history() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; // Push a second commit so we have more than 1 commit in history let work_dir = temp.join("work-depth"); push_new_commit(&repo_url, &work_dir, "second.txt", "second commit").await; let clone_dir = temp.join("full-clone"); // Clone with depth=0 (full clone) clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 0) .await .expect("full clone should succeed"); // Verify we have more than 1 commit (full history) let output = git_cmd() .args(["rev-list", "--count", "HEAD"]) .current_dir(&clone_dir) .output() .await .unwrap(); let count: u32 = String::from_utf8_lossy(&output.stdout) .trim() .parse() .unwrap(); assert!( count > 1, "full clone should have multiple commits, got {count}" ); cleanup(&temp).await; } #[tokio::test] async fn sync_repo_full_depth_preserves_history() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; // Push a second commit let work_dir = temp.join("work-depth2"); push_new_commit(&repo_url, &work_dir, "second.txt", "second").await; let clone_dir = temp.join("sync-full"); // sync_repo with depth=0 should do a full clone sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 0) .await .expect("sync with full depth should succeed"); let output = git_cmd() .args(["rev-list", "--count", "HEAD"]) .current_dir(&clone_dir) .output() .await .unwrap(); let count: u32 = String::from_utf8_lossy(&output.stdout) .trim() .parse() .unwrap(); assert!( count > 1, "full sync should have multiple commits, got {count}" ); cleanup(&temp).await; } // Submodule tests #[tokio::test] async fn has_submodules_with_gitmodules_file() { let temp = temp_dir("git-test").await; fs::write( temp.join(".gitmodules"), "[submodule \"lib\"]\n\tpath = lib\n\turl = ../lib.git\n", ) .await .unwrap(); assert!(has_submodules(&temp).await); cleanup(&temp).await; } #[tokio::test] async fn has_submodules_without_gitmodules() { let temp = temp_dir("git-test").await; assert!(!has_submodules(&temp).await); cleanup(&temp).await; } #[tokio::test] async fn has_submodules_empty_gitmodules() { let temp = temp_dir("git-test").await; fs::write(temp.join(".gitmodules"), "").await.unwrap(); assert!(!has_submodules(&temp).await); cleanup(&temp).await; } #[tokio::test] async fn maybe_init_submodules_no_submodules_is_noop() { let temp = temp_dir("git-test").await; let repo_url = create_local_repo(&temp, "main").await; let clone_dir = temp.join("no-submodules"); clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("clone should succeed"); // No .gitmodules → should be a no-op let result = maybe_init_submodules(&clone_dir, GIT_TIMEOUT_DEFAULT, 1, false).await; assert!( result.is_ok(), "noop submodule init should succeed: {result:?}" ); cleanup(&temp).await; } /// Create a parent repo with a submodule wired up. /// Returns `(parent_url, submodule_url)`. async fn create_repo_with_submodule(temp: &Path, branch: &str) -> (String, String) { // 1. Create bare submodule repo with a file let sub_bare = temp.join("sub.git"); fs::create_dir_all(&sub_bare).await.unwrap(); git_cmd() .args(["init", "--bare", "--initial-branch", branch]) .current_dir(&sub_bare) .output() .await .unwrap(); let sub_work = temp.join("sub-work"); git_cmd() .args([ "clone", sub_bare.to_str().unwrap(), sub_work.to_str().unwrap(), ]) .output() .await .unwrap(); configure_test_git_user(&sub_work).await; git_cmd() .args(["checkout", "-B", branch]) .current_dir(&sub_work) .output() .await .unwrap(); fs::write(sub_work.join("sub-file.txt"), "submodule content") .await .unwrap(); git_cmd() .args(["add", "sub-file.txt"]) .current_dir(&sub_work) .output() .await .unwrap(); let output = git_cmd() .args(["commit", "-m", "sub initial"]) .current_dir(&sub_work) .output() .await .unwrap(); assert!( output.status.success(), "sub commit failed: {}", String::from_utf8_lossy(&output.stderr) ); let output = git_cmd() .args(["push", "-u", "origin", branch]) .current_dir(&sub_work) .output() .await .unwrap(); assert!( output.status.success(), "sub push failed: {}", String::from_utf8_lossy(&output.stderr) ); // 2. Create bare parent repo with a submodule reference let parent_bare = temp.join("parent.git"); fs::create_dir_all(&parent_bare).await.unwrap(); git_cmd() .args(["init", "--bare", "--initial-branch", branch]) .current_dir(&parent_bare) .output() .await .unwrap(); let parent_work = temp.join("parent-work"); git_cmd() .args([ "clone", parent_bare.to_str().unwrap(), parent_work.to_str().unwrap(), ]) .output() .await .unwrap(); configure_test_git_user(&parent_work).await; git_cmd() .args(["checkout", "-B", branch]) .current_dir(&parent_work) .output() .await .unwrap(); fs::write(parent_work.join("README.md"), "# Parent") .await .unwrap(); git_cmd() .args(["add", "README.md"]) .current_dir(&parent_work) .output() .await .unwrap(); // Add submodule using file:// URL let sub_url = format!("file://{}", sub_bare.to_str().unwrap()); let output = git_cmd() .args(["submodule", "add", &sub_url, "lib"]) .current_dir(&parent_work) .output() .await .unwrap(); assert!( output.status.success(), "git submodule add failed: {}", String::from_utf8_lossy(&output.stderr) ); git_cmd() .args(["commit", "-m", "add submodule"]) .current_dir(&parent_work) .output() .await .unwrap(); let output = git_cmd() .args(["push", "-u", "origin", branch]) .current_dir(&parent_work) .output() .await .unwrap(); assert!( output.status.success(), "git push failed: {}", String::from_utf8_lossy(&output.stderr) ); let _ = fs::remove_dir_all(&sub_work).await; let _ = fs::remove_dir_all(&parent_work).await; let parent_url = format!("file://{}", parent_bare.to_str().unwrap()); (parent_url, sub_url) } #[tokio::test] async fn sync_repo_initializes_submodules() { let temp = temp_dir("git-test").await; let (parent_url, _sub_url) = create_repo_with_submodule(&temp, "main").await; let clone_dir = temp.join("clone-with-sub"); sync_repo(&parent_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("sync should succeed"); // Verify submodule content is present let sub_file = clone_dir.join("lib").join("sub-file.txt"); assert!(sub_file.exists(), "submodule file should exist after sync"); let content = fs::read_to_string(&sub_file).await.unwrap(); assert_eq!(content, "submodule content"); cleanup(&temp).await; } #[tokio::test] async fn sync_repo_updates_submodules_on_pull() { let temp = temp_dir("git-test").await; let (parent_url, sub_url) = create_repo_with_submodule(&temp, "main").await; let clone_dir = temp.join("pull-sub"); // First sync (clone + submodule init) sync_repo(&parent_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("initial sync should succeed"); // Push a new commit to the submodule let sub_work = temp.join("sub-update"); git_cmd() .args(["clone", &sub_url, sub_work.to_str().unwrap()]) .output() .await .unwrap(); configure_test_git_user(&sub_work).await; fs::write(sub_work.join("new-sub-file.txt"), "updated submodule") .await .unwrap(); git_cmd() .args(["add", "new-sub-file.txt"]) .current_dir(&sub_work) .output() .await .unwrap(); let output = git_cmd() .args(["commit", "-m", "update sub"]) .current_dir(&sub_work) .output() .await .unwrap(); assert!( output.status.success(), "sub commit failed: {}", String::from_utf8_lossy(&output.stderr) ); let output = git_cmd() .args(["push"]) .current_dir(&sub_work) .output() .await .unwrap(); assert!( output.status.success(), "sub push failed: {}", String::from_utf8_lossy(&output.stderr) ); // Update parent to point to new submodule commit let parent_work = temp.join("parent-update"); let parent_bare = temp.join("parent.git"); git_cmd() .args([ "clone", parent_bare.to_str().unwrap(), parent_work.to_str().unwrap(), ]) .output() .await .unwrap(); configure_test_git_user(&parent_work).await; // Init submodule in parent work copy, then update to latest git_cmd() .args(["submodule", "update", "--init", "--remote", "lib"]) .current_dir(&parent_work) .output() .await .unwrap(); git_cmd() .args(["add", "lib"]) .current_dir(&parent_work) .output() .await .unwrap(); let output = git_cmd() .args(["commit", "-m", "bump submodule"]) .current_dir(&parent_work) .output() .await .unwrap(); assert!( output.status.success(), "parent bump commit failed: {}", String::from_utf8_lossy(&output.stderr) ); let output = git_cmd() .args(["push"]) .current_dir(&parent_work) .output() .await .unwrap(); assert!( output.status.success(), "parent push failed: {}", String::from_utf8_lossy(&output.stderr) ); // Second sync (pull + submodule update) sync_repo(&parent_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) .await .expect("second sync should succeed"); // Verify the new submodule content is present let new_sub_file = clone_dir.join("lib").join("new-sub-file.txt"); assert!( new_sub_file.exists(), "updated submodule file should exist after pull" ); let content = fs::read_to_string(&new_sub_file).await.unwrap(); assert_eq!(content, "updated submodule"); cleanup(&temp).await; } }