//! HTTP Git Servers for Testing //! //! This module provides two git server implementations for integration tests: //! //! ## `SimpleGitServer` (Dumb HTTP Protocol) //! //! Serves static files from a bare git repository using Git's "dumb HTTP" protocol. //! This is lightweight but does NOT support shallow fetches (`git fetch --depth=1`). //! //! ## `SmartGitServer` (Smart HTTP Protocol) //! //! Implements the Git Smart HTTP protocol by spawning `git upload-pack` subprocesses. //! This supports all git fetch operations including shallow fetches. //! //! # Usage //! //! ```ignore //! use common::{SimpleGitServer, SmartGitServer}; //! //! #[tokio::test] //! async fn test_git_fetch() { //! // Create a test repo //! let temp_dir = tempfile::tempdir().unwrap(); //! create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest).unwrap(); //! //! // Use SmartGitServer for full protocol support (including shallow fetches) //! let server = SmartGitServer::start(temp_dir.path()).await; //! //! // Git operations work against server.url() //! let output = Command::new("git") //! .args(["clone", "--depth=1", server.url(), "/tmp/clone"]) //! .output() //! .unwrap(); //! assert!(output.status.success()); //! //! // Server cleans up on drop //! server.stop().await; //! } //! ``` //! //! # When to Use Which //! //! - **SimpleGitServer**: Fast, lightweight, good for basic `git fetch` without depth limits //! - **SmartGitServer**: Full protocol support, required for `--depth=1` shallow fetches //! //! The purgatory sync system uses `git fetch --depth=1`, so tests involving purgatory //! sync should use `SmartGitServer`. use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; use http_body_util::Full; use hyper::body::Bytes; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; use tokio::sync::oneshot; /// Simple HTTP server for serving git repositories. /// /// Creates a bare clone of a source repository and serves it over HTTP /// using git's "dumb HTTP" protocol. Useful for testing git fetch operations /// without needing a full git HTTP backend. pub struct SimpleGitServer { /// Shutdown signal sender shutdown_tx: Option>, /// Server task handle handle: Option>, /// Server URL (http://127.0.0.1:) url: String, /// Server port #[allow(dead_code)] port: u16, /// Temporary directory containing the bare repository /// Kept alive for the lifetime of the server _temp_dir: tempfile::TempDir, } impl SimpleGitServer { /// Start a simple HTTP git server serving the given repository. /// /// Creates a bare clone of the source repository, runs `git update-server-info`, /// and starts an HTTP server to serve the repository files. /// /// # Arguments /// * `source_repo` - Path to the source git repository (can be non-bare) /// /// # Returns /// A `SimpleGitServer` instance with the server running /// /// # Panics /// Panics if the git operations fail or the server cannot start pub async fn start(source_repo: &Path) -> Self { // 1. Create temp directory for bare repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git server"); let bare_repo_path = temp_dir.path().join("repo.git"); // 2. Create bare clone let output = Command::new("git") .args(["clone", "--bare"]) .arg(source_repo) .arg(&bare_repo_path) .output() .expect("Failed to run git clone --bare"); if !output.status.success() { panic!( "git clone --bare failed: {}", String::from_utf8_lossy(&output.stderr) ); } // 3. Run git update-server-info to generate info/refs and objects/info/packs let output = Command::new("git") .args(["update-server-info"]) .current_dir(&bare_repo_path) .output() .expect("Failed to run git update-server-info"); if !output.status.success() { panic!( "git update-server-info failed: {}", String::from_utf8_lossy(&output.stderr) ); } // 4. Create and bind listener (eliminates port race condition) let std_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); let port = std_listener .local_addr() .expect("Failed to get local addr") .port(); // Convert to tokio listener (keeps port bound) std_listener .set_nonblocking(true) .expect("Failed to set non-blocking"); let listener = TcpListener::from_std(std_listener).expect("Failed to convert to tokio listener"); // 5. Create shutdown channel let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); // 6. Start the HTTP server let repo_path = Arc::new(bare_repo_path); let handle = tokio::spawn(async move { println!("[SmartGitServer] Server loop started on port {}", port); eprintln!("[SmartGitServer] Server loop started on port {}", port); loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, addr)) => { eprintln!("[SmartGitServer] Accepted connection from {}", addr); let repo_path = Arc::clone(&repo_path); let io = TokioIo::new(stream); tokio::spawn(async move { let service = service_fn(move |req| { let repo_path = Arc::clone(&repo_path); async move { handle_request(req, &repo_path).await } }); if let Err(e) = http1::Builder::new() .serve_connection(io, service) .await { // Connection errors are expected when client disconnects if !e.to_string().contains("connection") { eprintln!("SimpleGitServer connection error: {}", e); } } }); } Err(e) => { eprintln!("SimpleGitServer accept error: {}", e); } } } _ = &mut shutdown_rx => { // Shutdown signal received break; } } } }); let url = format!("http://127.0.0.1:{}", port); // 7. Wait for server to be ready wait_for_server_ready(port).await; Self { shutdown_tx: Some(shutdown_tx), handle: Some(handle), url, port, _temp_dir: temp_dir, } } /// Get the server URL. /// /// Returns the HTTP URL where the git repository is served. /// Can be used directly with `git clone`, `git fetch`, or `git ls-remote`. pub fn url(&self) -> &str { &self.url } /// Stop the server. /// /// Sends a shutdown signal and waits for the server to stop. /// The temporary directory is cleaned up when the server is dropped. pub async fn stop(mut self) { // Send shutdown signal if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Wait for server task to complete if let Some(handle) = self.handle.take() { let _ = handle.await; } } } impl Drop for SimpleGitServer { fn drop(&mut self) { // Send shutdown signal if not already sent if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Note: We can't await the handle in drop, but the temp_dir cleanup // will happen automatically when _temp_dir is dropped } } /// Handle an HTTP request by serving files from the git repository. async fn handle_request( req: Request, repo_path: &Path, ) -> Result>, hyper::Error> { let path = req.uri().path(); // Remove leading slash and construct file path let relative_path = path.trim_start_matches('/'); let file_path = repo_path.join(relative_path); // Security: ensure the path doesn't escape the repo directory if !is_safe_path(&file_path, repo_path) { return Ok(Response::builder() .status(StatusCode::FORBIDDEN) .body(Full::new(Bytes::from("Forbidden"))) .unwrap()); } // Try to read the file match tokio::fs::read(&file_path).await { Ok(contents) => { let content_type = guess_content_type(&file_path); Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", content_type) .body(Full::new(Bytes::from(contents))) .unwrap()) } Err(_) => Ok(Response::builder() .status(StatusCode::NOT_FOUND) .body(Full::new(Bytes::from("Not Found"))) .unwrap()), } } /// Check if a path is safe (doesn't escape the repository directory). fn is_safe_path(path: &Path, repo_path: &Path) -> bool { match path.canonicalize() { Ok(canonical) => canonical.starts_with(repo_path), Err(_) => { // If canonicalize fails, check if the path would escape // by looking for .. components !path.to_string_lossy().contains("..") } } } /// Guess the content type for a git-related file. fn guess_content_type(path: &Path) -> &'static str { let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if filename == "info/refs" || filename == "refs" { "text/plain; charset=utf-8" } else if filename.ends_with(".pack") { "application/x-git-packed-objects" } else if filename.ends_with(".idx") { "application/x-git-packed-objects-toc" } else { "application/octet-stream" } } /// Wait for the server to be ready to accept connections. async fn wait_for_server_ready(port: u16) { let max_attempts = 50; // 5 seconds total let delay = std::time::Duration::from_millis(100); for attempt in 0..max_attempts { match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { Ok(_) => { // Connection successful, server is ready tokio::time::sleep(std::time::Duration::from_millis(50)).await; return; } Err(_) => { if attempt == max_attempts - 1 { panic!( "SimpleGitServer failed to start after {} attempts", max_attempts ); } tokio::time::sleep(delay).await; } } } } #[cfg(test)] mod tests { use super::*; use crate::common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant}; #[tokio::test] async fn test_simple_git_server_starts_and_stops() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SimpleGitServer::start(temp_dir.path()).await; // Verify URL is set assert!(server.url().starts_with("http://127.0.0.1:")); // Stop server server.stop().await; } #[tokio::test] async fn test_simple_git_server_serves_git_info_refs() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SimpleGitServer::start(temp_dir.path()).await; // Fetch info/refs let info_refs_url = format!("{}/info/refs", server.url()); let response = reqwest::get(&info_refs_url) .await .expect("Failed to fetch info/refs"); assert!( response.status().is_success(), "info/refs should be accessible" ); let body = response.text().await.expect("Failed to read response body"); // Should contain at least one ref (HEAD or refs/heads/main) assert!( body.contains("refs/heads/main") || body.contains("HEAD"), "info/refs should contain refs, got: {}", body ); server.stop().await; } #[tokio::test] async fn test_git_ls_remote_from_simple_server() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SimpleGitServer::start(temp_dir.path()).await; // Run git ls-remote against the server (using tokio::process::Command) let output = tokio::process::Command::new("git") .args(["ls-remote", server.url()]) .output() .await .expect("Failed to run git ls-remote"); assert!( output.status.success(), "git ls-remote should succeed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); // Should list the main branch with the correct commit assert!( stdout.contains(&commit_hash), "ls-remote output should contain commit {}, got: {}", commit_hash, stdout ); assert!( stdout.contains("refs/heads/main"), "ls-remote output should contain refs/heads/main, got: {}", stdout ); server.stop().await; } #[tokio::test] async fn test_git_fetch_from_simple_server() { // Create a source repo with a commit let source_dir = tempfile::tempdir().expect("Failed to create source dir"); let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server serving the source repo let server = SimpleGitServer::start(source_dir.path()).await; // Create a destination repo to fetch into let dest_dir = tempfile::tempdir().expect("Failed to create dest dir"); // Initialize empty repo (using tokio::process::Command) let output = tokio::process::Command::new("git") .args(["init"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to init dest repo"); assert!(output.status.success()); // Add the server as a remote let output = tokio::process::Command::new("git") .args(["remote", "add", "origin", server.url()]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to add remote"); assert!(output.status.success()); // Fetch from the server let output = tokio::process::Command::new("git") .args(["fetch", "origin"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to fetch"); assert!( output.status.success(), "git fetch should succeed: {}", String::from_utf8_lossy(&output.stderr) ); // Verify the commit was fetched let output = tokio::process::Command::new("git") .args(["rev-parse", "origin/main"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to rev-parse"); assert!(output.status.success()); let fetched_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!( fetched_commit, commit_hash, "Fetched commit should match source commit" ); server.stop().await; } #[test] fn test_is_safe_path_blocks_traversal() { let repo_path = Path::new("/tmp/repo"); // Safe paths assert!(is_safe_path(Path::new("/tmp/repo/info/refs"), repo_path)); assert!(is_safe_path( Path::new("/tmp/repo/objects/pack/file.pack"), repo_path )); // Unsafe paths (path traversal) assert!(!is_safe_path( Path::new("/tmp/repo/../etc/passwd"), repo_path )); assert!(!is_safe_path( Path::new("/tmp/repo/../../etc/passwd"), repo_path )); } } // ============================================================================= // SmartGitServer - Git Smart HTTP Protocol Server // ============================================================================= /// Smart HTTP server for serving git repositories with full protocol support. /// /// Unlike `SimpleGitServer` which uses the "dumb HTTP" protocol (static files), /// this server implements the Git Smart HTTP protocol by spawning `git upload-pack` /// subprocesses. This enables: /// /// - Shallow clones (`git clone --depth=1`) /// - Shallow fetches (`git fetch --depth=1`) /// - Full protocol negotiation /// /// This is required for testing purgatory sync, which uses `git fetch --depth=1`. pub struct SmartGitServer { /// Shutdown signal sender shutdown_tx: Option>, /// Server task handle handle: Option>, /// Server URL (http://127.0.0.1:) url: String, /// Server port #[allow(dead_code)] port: u16, /// Temporary directory containing the bare repository /// Kept alive for the lifetime of the server _temp_dir: tempfile::TempDir, } impl SmartGitServer { /// Start a smart HTTP git server serving the given repository. /// /// Creates a bare clone of the source repository and starts an HTTP server /// that implements the Git Smart HTTP protocol. /// /// # Arguments /// * `source_repo` - Path to the source git repository (can be non-bare) /// /// # Returns /// A `SmartGitServer` instance with the server running /// /// # Panics /// Panics if the git operations fail or the server cannot start pub async fn start(source_repo: &Path) -> Self { // 1. Create temp directory for bare repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git server"); let bare_repo_path = temp_dir.path().join("repo.git"); // 2. Create bare clone let output = Command::new("git") .args(["clone", "--bare"]) .arg(source_repo) .arg(&bare_repo_path) .output() .expect("Failed to run git clone --bare"); if !output.status.success() { panic!( "git clone --bare failed: {}", String::from_utf8_lossy(&output.stderr) ); } // 3. Create and bind listener (eliminates port race condition) let std_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); let port = std_listener .local_addr() .expect("Failed to get local addr") .port(); // Convert to tokio listener (keeps port bound) std_listener .set_nonblocking(true) .expect("Failed to set non-blocking"); let listener = TcpListener::from_std(std_listener).expect("Failed to convert to tokio listener"); // 4. Create shutdown channel let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); // 5. Start the HTTP server let repo_path = Arc::new(bare_repo_path); let handle = tokio::spawn(async move { loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, _addr)) => { let repo_path = Arc::clone(&repo_path); let io = TokioIo::new(stream); tokio::spawn(async move { let service = service_fn(move |req| { let repo_path = Arc::clone(&repo_path); async move { handle_smart_request(req, &repo_path).await } }); if let Err(e) = http1::Builder::new() .serve_connection(io, service) .await { // Connection errors are expected when client disconnects if !e.to_string().contains("connection") { eprintln!("SmartGitServer connection error: {}", e); } } }); } Err(e) => { eprintln!("SmartGitServer accept error: {}", e); } } } _ = &mut shutdown_rx => { // Shutdown signal received break; } } } }); let url = format!("http://127.0.0.1:{}", port); // 6. Wait for server to be ready wait_for_server_ready(port).await; Self { shutdown_tx: Some(shutdown_tx), handle: Some(handle), url, port, _temp_dir: temp_dir, } } /// Get the server URL. /// /// Returns the HTTP URL where the git repository is served. /// Can be used directly with `git clone`, `git fetch`, or `git ls-remote`. pub fn url(&self) -> &str { &self.url } /// Stop the server. /// /// Sends a shutdown signal and waits for the server to stop. /// The temporary directory is cleaned up when the server is dropped. pub async fn stop(mut self) { // Send shutdown signal if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Wait for server task to complete if let Some(handle) = self.handle.take() { let _ = handle.await; } } } impl Drop for SmartGitServer { fn drop(&mut self) { // Send shutdown signal if not already sent if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Note: We can't await the handle in drop, but the temp_dir cleanup // will happen automatically when _temp_dir is dropped } } /// Handle an HTTP request using the Git Smart HTTP protocol. async fn handle_smart_request( req: Request, repo_path: &Path, ) -> Result>, hyper::Error> { let path = req.uri().path(); let query = req.uri().query().unwrap_or(""); let method = req.method(); // Extract Git-Protocol header (for protocol version 2) // We need to clone it to avoid borrowing issues when moving req let git_protocol = req .headers() .get("Git-Protocol") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); // Route: GET /info/refs?service=git-upload-pack if method == hyper::Method::GET && path.ends_with("/info/refs") { // Parse service from query string let service = query.split('&').find_map(|param| { let mut parts = param.splitn(2, '='); match (parts.next(), parts.next()) { (Some("service"), Some(svc)) => Some(svc), _ => None, } }); match service { Some("git-upload-pack") => { return handle_info_refs_upload_pack(repo_path, git_protocol.as_deref()).await; } Some("git-receive-pack") => { // We only support upload-pack for testing (fetch/clone) return Ok(Response::builder() .status(StatusCode::FORBIDDEN) .body(Full::new(Bytes::from("receive-pack not supported"))) .unwrap()); } _ => { return Ok(Response::builder() .status(StatusCode::BAD_REQUEST) .body(Full::new(Bytes::from( "Missing or invalid service parameter", ))) .unwrap()); } } } // Route: POST /git-upload-pack if method == hyper::Method::POST && path.ends_with("/git-upload-pack") { return handle_upload_pack(req, repo_path, git_protocol.as_deref()).await; } // Not found Ok(Response::builder() .status(StatusCode::NOT_FOUND) .body(Full::new(Bytes::from("Not Found"))) .unwrap()) } /// Handle GET /info/refs?service=git-upload-pack /// /// This advertises the repository's refs to the client using the smart protocol. async fn handle_info_refs_upload_pack( repo_path: &Path, git_protocol_version: Option<&str>, ) -> Result>, hyper::Error> { use std::process::Stdio; use tokio::io::AsyncReadExt; use tokio::process::Command as TokioCommand; // Spawn git upload-pack --advertise-refs let mut cmd = TokioCommand::new("git"); cmd.arg("-c") .arg("uploadpack.allowReachableSHA1InWant=true") .arg("-c") .arg("uploadpack.allowTipSHA1InWant=true") .arg("-c") .arg("uploadpack.allowFilter=true") .arg("upload-pack") .arg("--advertise-refs") .arg("--stateless-rpc"); // Set GIT_PROTOCOL environment variable if version 2 is requested if let Some(version) = git_protocol_version { cmd.env("GIT_PROTOCOL", version); } cmd.arg(repo_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { eprintln!("Failed to spawn git upload-pack: {}", e); return Ok(Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Full::new(Bytes::from("Failed to spawn git process"))) .unwrap()); } }; // Read stdout let mut output = Vec::new(); if let Some(mut stdout) = child.stdout.take() { if let Err(e) = stdout.read_to_end(&mut output).await { eprintln!("Failed to read git output: {}", e); } } // Wait for process let status = child.wait().await; if let Ok(s) = &status { if !s.success() { eprintln!("git upload-pack --advertise-refs failed"); } } // Build response with pkt-line header // Format: pkt-line("# service=git-upload-pack\n") + flush + git output let mut response_body = Vec::new(); // First line: service advertisement let service_line = "# service=git-upload-pack\n"; let len = service_line.len() + 4; response_body.extend_from_slice(format!("{:04x}", len).as_bytes()); response_body.extend_from_slice(service_line.as_bytes()); // Flush packet response_body.extend_from_slice(b"0000"); // Then the git output response_body.extend_from_slice(&output); Ok(Response::builder() .status(StatusCode::OK) .header( "Content-Type", "application/x-git-upload-pack-advertisement", ) .header("Cache-Control", "no-cache") .body(Full::new(Bytes::from(response_body))) .unwrap()) } /// Handle POST /git-upload-pack /// /// This handles the actual fetch negotiation and pack data transfer. async fn handle_upload_pack( req: Request, repo_path: &Path, git_protocol_version: Option<&str>, ) -> Result>, hyper::Error> { use http_body_util::BodyExt; use std::process::Stdio; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command as TokioCommand; // Read request body let body_bytes = req.collect().await?.to_bytes(); // Spawn git upload-pack let mut cmd = TokioCommand::new("git"); cmd.arg("-c") .arg("uploadpack.allowReachableSHA1InWant=true") .arg("-c") .arg("uploadpack.allowTipSHA1InWant=true") .arg("-c") .arg("uploadpack.allowFilter=true") .arg("upload-pack") .arg("--stateless-rpc"); // Set GIT_PROTOCOL environment variable if version 2 is requested if let Some(version) = git_protocol_version { cmd.env("GIT_PROTOCOL", version); } cmd.arg(repo_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { eprintln!("Failed to spawn git upload-pack: {}", e); return Ok(Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Full::new(Bytes::from("Failed to spawn git process"))) .unwrap()); } }; // Write request body to stdin if let Some(mut stdin) = child.stdin.take() { if let Err(e) = stdin.write_all(&body_bytes).await { eprintln!("Failed to write to git stdin: {}", e); } // Close stdin to signal end of input drop(stdin); } // Read stdout let mut output = Vec::new(); if let Some(mut stdout) = child.stdout.take() { if let Err(e) = stdout.read_to_end(&mut output).await { eprintln!("Failed to read git output: {}", e); } } // Read stderr for debugging let mut stderr_output = Vec::new(); if let Some(mut stderr) = child.stderr.take() { let _ = stderr.read_to_end(&mut stderr_output).await; } // Wait for process let status = child.wait().await; if let Ok(s) = &status { if !s.success() { let stderr_str = String::from_utf8_lossy(&stderr_output); eprintln!("git upload-pack failed: {}", stderr_str); } } Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "application/x-git-upload-pack-result") .header("Cache-Control", "no-cache") .body(Full::new(Bytes::from(output))) .unwrap()) } #[cfg(test)] mod smart_git_server_tests { use super::*; use crate::common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant}; #[tokio::test] async fn test_smart_git_server_starts_and_stops() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SmartGitServer::start(temp_dir.path()).await; // Verify URL is set assert!(server.url().starts_with("http://127.0.0.1:")); // Stop server server.stop().await; } #[tokio::test] async fn test_smart_git_server_info_refs() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SmartGitServer::start(temp_dir.path()).await; // Fetch info/refs with service parameter let info_refs_url = format!("{}/info/refs?service=git-upload-pack", server.url()); let response = reqwest::get(&info_refs_url) .await .expect("Failed to fetch info/refs"); assert!( response.status().is_success(), "info/refs should be accessible" ); // Check content type let content_type = response .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( content_type.contains("application/x-git-upload-pack-advertisement"), "Content-Type should be git-upload-pack-advertisement, got: {}", content_type ); let body = response .bytes() .await .expect("Failed to read response body"); // Should start with service advertisement pkt-line let body_str = String::from_utf8_lossy(&body); assert!( body_str.contains("# service=git-upload-pack"), "Response should contain service advertisement, got: {}", body_str ); server.stop().await; } #[tokio::test] async fn test_smart_git_server_ls_remote() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SmartGitServer::start(temp_dir.path()).await; // Run git ls-remote against the server (using tokio::process::Command) let output = tokio::process::Command::new("git") .args(["ls-remote", server.url()]) .output() .await .expect("Failed to run git ls-remote"); assert!( output.status.success(), "git ls-remote should succeed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); // Should list the main branch with the correct commit assert!( stdout.contains(&commit_hash), "ls-remote output should contain commit {}, got: {}", commit_hash, stdout ); assert!( stdout.contains("refs/heads/main"), "ls-remote output should contain refs/heads/main, got: {}", stdout ); server.stop().await; } #[tokio::test] async fn test_smart_git_server_fetch() { // Create a source repo with a commit let source_dir = tempfile::tempdir().expect("Failed to create source dir"); let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server serving the source repo let server = SmartGitServer::start(source_dir.path()).await; // Create a destination repo to fetch into let dest_dir = tempfile::tempdir().expect("Failed to create dest dir"); // Initialize empty repo let output = tokio::process::Command::new("git") .args(["init"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to init dest repo"); assert!(output.status.success()); // Add the server as a remote let output = tokio::process::Command::new("git") .args(["remote", "add", "origin", server.url()]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to add remote"); assert!(output.status.success()); // Fetch from the server let output = tokio::process::Command::new("git") .args(["fetch", "origin"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to fetch"); assert!( output.status.success(), "git fetch should succeed: {}", String::from_utf8_lossy(&output.stderr) ); // Verify the commit was fetched let output = tokio::process::Command::new("git") .args(["rev-parse", "origin/main"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to rev-parse"); assert!(output.status.success()); let fetched_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!( fetched_commit, commit_hash, "Fetched commit should match source commit" ); server.stop().await; } #[tokio::test] async fn test_smart_git_server_shallow_fetch() { // This is the KEY test - shallow fetch requires smart HTTP protocol // Create a source repo with a commit let source_dir = tempfile::tempdir().expect("Failed to create source dir"); let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server serving the source repo let server = SmartGitServer::start(source_dir.path()).await; // Create a destination repo to fetch into let dest_dir = tempfile::tempdir().expect("Failed to create dest dir"); // Initialize empty repo let output = tokio::process::Command::new("git") .args(["init"]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to init dest repo"); assert!(output.status.success()); // Add the server as a remote let output = tokio::process::Command::new("git") .args(["remote", "add", "origin", server.url()]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to add remote"); assert!(output.status.success()); // Shallow fetch from the server - THIS IS WHAT PURGATORY SYNC USES let output = tokio::process::Command::new("git") .args(["fetch", "--depth=1", "origin", &commit_hash]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to fetch"); assert!( output.status.success(), "git fetch --depth=1 should succeed with smart HTTP: {}", String::from_utf8_lossy(&output.stderr) ); // Verify the commit was fetched let output = tokio::process::Command::new("git") .args(["cat-file", "-t", &commit_hash]) .current_dir(dest_dir.path()) .output() .await .expect("Failed to cat-file"); assert!( output.status.success(), "Commit should exist after shallow fetch" ); let object_type = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(object_type, "commit", "Object should be a commit"); server.stop().await; } }