upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-07 21:33:22 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-07 21:33:22 +0000
commit0550b3229f35ef3ee125bac47d85bbd08d1250b1 (patch)
tree15ff1f689f9c429ceda9f085973df3d06895714d /tests
parent7467aa9ace94b4e132eedd03c9daddb2d59813c4 (diff)
test: add WIP SmartHttpServer to test
Diffstat (limited to 'tests')
-rw-r--r--tests/common/git_server.rs707
-rw-r--r--tests/common/mod.rs2
-rw-r--r--tests/purgatory_sync.rs8
3 files changed, 695 insertions, 22 deletions
diff --git a/tests/common/git_server.rs b/tests/common/git_server.rs
index b225084..6121adc 100644
--- a/tests/common/git_server.rs
+++ b/tests/common/git_server.rs
@@ -1,13 +1,21 @@
1//! Simple HTTP Git Server for Testing 1//! HTTP Git Servers for Testing
2//! 2//!
3//! Provides a dumb HTTP server for serving git repositories in integration tests. 3//! This module provides two git server implementations for integration tests:
4//! This server serves static files from a bare git repository, enabling `git fetch` 4//!
5//! operations without requiring a full git HTTP backend. 5//! ## `SimpleGitServer` (Dumb HTTP Protocol)
6//!
7//! Serves static files from a bare git repository using Git's "dumb HTTP" protocol.
8//! This is lightweight but does NOT support shallow fetches (`git fetch --depth=1`).
9//!
10//! ## `SmartGitServer` (Smart HTTP Protocol)
11//!
12//! Implements the Git Smart HTTP protocol by spawning `git upload-pack` subprocesses.
13//! This supports all git fetch operations including shallow fetches.
6//! 14//!
7//! # Usage 15//! # Usage
8//! 16//!
9//! ```ignore 17//! ```ignore
10//! use common::SimpleGitServer; 18//! use common::{SimpleGitServer, SmartGitServer};
11//! 19//!
12//! #[tokio::test] 20//! #[tokio::test]
13//! async fn test_git_fetch() { 21//! async fn test_git_fetch() {
@@ -15,12 +23,12 @@
15//! let temp_dir = tempfile::tempdir().unwrap(); 23//! let temp_dir = tempfile::tempdir().unwrap();
16//! create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest).unwrap(); 24//! create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest).unwrap();
17//! 25//!
18//! // Start the server 26//! // Use SmartGitServer for full protocol support (including shallow fetches)
19//! let server = SimpleGitServer::start(temp_dir.path()).await; 27//! let server = SmartGitServer::start(temp_dir.path()).await;
20//! 28//!
21//! // Git operations work against server.url() 29//! // Git operations work against server.url()
22//! let output = Command::new("git") 30//! let output = Command::new("git")
23//! .args(["ls-remote", server.url()]) 31//! .args(["clone", "--depth=1", server.url(), "/tmp/clone"])
24//! .output() 32//! .output()
25//! .unwrap(); 33//! .unwrap();
26//! assert!(output.status.success()); 34//! assert!(output.status.success());
@@ -30,17 +38,13 @@
30//! } 38//! }
31//! ``` 39//! ```
32//! 40//!
33//! # How It Works 41//! # When to Use Which
34//! 42//!
35//! Git's "dumb HTTP" protocol just needs static file access to: 43//! - **SimpleGitServer**: Fast, lightweight, good for basic `git fetch` without depth limits
36//! - `info/refs` - List of refs (generated by `git update-server-info`) 44//! - **SmartGitServer**: Full protocol support, required for `--depth=1` shallow fetches
37//! - `objects/info/packs` - List of pack files
38//! - `objects/pack/*` - Pack files
39//! - `objects/??/*` - Loose objects
40//! 45//!
41//! The server creates a bare clone of the source repository, runs 46//! The purgatory sync system uses `git fetch --depth=1`, so tests involving purgatory
42//! `git update-server-info` to generate the required metadata files, 47//! sync should use `SmartGitServer`.
43//! and serves them over HTTP.
44 48
45use std::net::SocketAddr; 49use std::net::SocketAddr;
46use std::path::{Path, PathBuf}; 50use std::path::{Path, PathBuf};
@@ -138,11 +142,14 @@ impl SimpleGitServer {
138 .expect("Failed to bind to address"); 142 .expect("Failed to bind to address");
139 143
140 let handle = tokio::spawn(async move { 144 let handle = tokio::spawn(async move {
145 println!("[SmartGitServer] Server loop started on port {}", port);
146 eprintln!("[SmartGitServer] Server loop started on port {}", port);
141 loop { 147 loop {
142 tokio::select! { 148 tokio::select! {
143 accept_result = listener.accept() => { 149 accept_result = listener.accept() => {
144 match accept_result { 150 match accept_result {
145 Ok((stream, _)) => { 151 Ok((stream, addr)) => {
152 eprintln!("[SmartGitServer] Accepted connection from {}", addr);
146 let repo_path = Arc::clone(&repo_path); 153 let repo_path = Arc::clone(&repo_path);
147 let io = TokioIo::new(stream); 154 let io = TokioIo::new(stream);
148 155
@@ -485,3 +492,667 @@ mod tests {
485 assert!(!is_safe_path(Path::new("/tmp/repo/../../etc/passwd"), repo_path)); 492 assert!(!is_safe_path(Path::new("/tmp/repo/../../etc/passwd"), repo_path));
486 } 493 }
487} 494}
495
496// =============================================================================
497// SmartGitServer - Git Smart HTTP Protocol Server
498// =============================================================================
499
500/// Smart HTTP server for serving git repositories with full protocol support.
501///
502/// Unlike `SimpleGitServer` which uses the "dumb HTTP" protocol (static files),
503/// this server implements the Git Smart HTTP protocol by spawning `git upload-pack`
504/// subprocesses. This enables:
505///
506/// - Shallow clones (`git clone --depth=1`)
507/// - Shallow fetches (`git fetch --depth=1`)
508/// - Full protocol negotiation
509///
510/// This is required for testing purgatory sync, which uses `git fetch --depth=1`.
511pub struct SmartGitServer {
512 /// Shutdown signal sender
513 shutdown_tx: Option<oneshot::Sender<()>>,
514 /// Server task handle
515 handle: Option<tokio::task::JoinHandle<()>>,
516 /// Server URL (http://127.0.0.1:<port>)
517 url: String,
518 /// Server port
519 #[allow(dead_code)]
520 port: u16,
521 /// Temporary directory containing the bare repository
522 /// Kept alive for the lifetime of the server
523 _temp_dir: tempfile::TempDir,
524}
525
526impl SmartGitServer {
527 /// Start a smart HTTP git server serving the given repository.
528 ///
529 /// Creates a bare clone of the source repository and starts an HTTP server
530 /// that implements the Git Smart HTTP protocol.
531 ///
532 /// # Arguments
533 /// * `source_repo` - Path to the source git repository (can be non-bare)
534 ///
535 /// # Returns
536 /// A `SmartGitServer` instance with the server running
537 ///
538 /// # Panics
539 /// Panics if the git operations fail or the server cannot start
540 pub async fn start(source_repo: &Path) -> Self {
541 println!("[SmartGitServer::start] Creating temp dir");
542 // 1. Create temp directory for bare repo
543 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git server");
544 let bare_repo_path = temp_dir.path().join("repo.git");
545
546 println!("[SmartGitServer::start] Cloning bare repo from {:?}", source_repo);
547 // 2. Create bare clone
548 let output = Command::new("git")
549 .args(["clone", "--bare"])
550 .arg(source_repo)
551 .arg(&bare_repo_path)
552 .output()
553 .expect("Failed to run git clone --bare");
554
555 if !output.status.success() {
556 panic!(
557 "git clone --bare failed: {}",
558 String::from_utf8_lossy(&output.stderr)
559 );
560 }
561 println!("[SmartGitServer::start] Bare clone created");
562
563 // 3. Find a free port
564 println!("[SmartGitServer::start] Finding free port");
565 let port = find_free_port();
566 println!("[SmartGitServer::start] Found port {}", port);
567 let addr: SocketAddr = ([127, 0, 0, 1], port).into();
568
569 // 4. Create shutdown channel
570 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
571
572 println!("[SmartGitServer::start] Binding to {}", addr);
573 // 5. Start the HTTP server
574 let repo_path = Arc::new(bare_repo_path);
575 let listener = TcpListener::bind(addr)
576 .await
577 .expect("Failed to bind to address");
578 println!("[SmartGitServer::start] Listener bound successfully");
579
580 let handle = tokio::spawn(async move {
581 eprintln!("[SmartGitServer] Server loop started, waiting for connections...");
582 loop {
583 tokio::select! {
584 accept_result = listener.accept() => {
585 match accept_result {
586 Ok((stream, addr)) => {
587 eprintln!("[SmartGitServer] Accepted connection from {}", addr);
588 let repo_path = Arc::clone(&repo_path);
589 let io = TokioIo::new(stream);
590
591 tokio::spawn(async move {
592 eprintln!("[SmartGitServer] Spawning handler for connection");
593 let service = service_fn(move |req| {
594 let repo_path = Arc::clone(&repo_path);
595 async move { handle_smart_request(req, &repo_path).await }
596 });
597
598 eprintln!("[SmartGitServer] About to serve_connection");
599 if let Err(e) = http1::Builder::new()
600 .serve_connection(io, service)
601 .await
602 {
603 // Connection errors are expected when client disconnects
604 if !e.to_string().contains("connection") {
605 eprintln!("SmartGitServer connection error: {}", e);
606 }
607 }
608 eprintln!("[SmartGitServer] Connection handler finished");
609 });
610 }
611 Err(e) => {
612 eprintln!("SmartGitServer accept error: {}", e);
613 }
614 }
615 }
616 _ = &mut shutdown_rx => {
617 // Shutdown signal received
618 eprintln!("[SmartGitServer] Shutdown signal received");
619 break;
620 }
621 }
622 }
623 eprintln!("[SmartGitServer] Server loop exited");
624 });
625
626 let url = format!("http://127.0.0.1:{}", port);
627
628 println!("[SmartGitServer::start] Waiting for server to be ready on port {}", port);
629 // 6. Wait for server to be ready
630 wait_for_server_ready(port).await;
631 println!("[SmartGitServer::start] Server is ready!");
632
633 Self {
634 shutdown_tx: Some(shutdown_tx),
635 handle: Some(handle),
636 url,
637 port,
638 _temp_dir: temp_dir,
639 }
640 }
641
642 /// Get the server URL.
643 ///
644 /// Returns the HTTP URL where the git repository is served.
645 /// Can be used directly with `git clone`, `git fetch`, or `git ls-remote`.
646 pub fn url(&self) -> &str {
647 &self.url
648 }
649
650 /// Stop the server.
651 ///
652 /// Sends a shutdown signal and waits for the server to stop.
653 /// The temporary directory is cleaned up when the server is dropped.
654 pub async fn stop(mut self) {
655 // Send shutdown signal
656 if let Some(tx) = self.shutdown_tx.take() {
657 let _ = tx.send(());
658 }
659
660 // Wait for server task to complete
661 if let Some(handle) = self.handle.take() {
662 let _ = handle.await;
663 }
664 }
665}
666
667impl Drop for SmartGitServer {
668 fn drop(&mut self) {
669 // Send shutdown signal if not already sent
670 if let Some(tx) = self.shutdown_tx.take() {
671 let _ = tx.send(());
672 }
673 // Note: We can't await the handle in drop, but the temp_dir cleanup
674 // will happen automatically when _temp_dir is dropped
675 }
676}
677
678/// Handle an HTTP request using the Git Smart HTTP protocol.
679async fn handle_smart_request(
680 req: Request<hyper::body::Incoming>,
681 repo_path: &Path,
682) -> Result<Response<Full<Bytes>>, hyper::Error> {
683 let path = req.uri().path();
684 let query = req.uri().query().unwrap_or("");
685 let method = req.method();
686
687 println!("[SmartGitServer] {} {} query={}", method, path, query);
688 eprintln!("[SmartGitServer] {} {} query={}", method, path, query);
689
690 // Extract Git-Protocol header (for protocol version 2)
691 // We need to clone it to avoid borrowing issues when moving req
692 let git_protocol = req
693 .headers()
694 .get("Git-Protocol")
695 .and_then(|v| v.to_str().ok())
696 .map(|s| s.to_string());
697
698 if let Some(ref proto) = git_protocol {
699 eprintln!("[SmartGitServer] Git-Protocol: {}", proto);
700 }
701
702 // Route: GET /info/refs?service=git-upload-pack
703 if method == hyper::Method::GET && path.ends_with("/info/refs") {
704 // Parse service from query string
705 let service = query
706 .split('&')
707 .find_map(|param| {
708 let mut parts = param.splitn(2, '=');
709 match (parts.next(), parts.next()) {
710 (Some("service"), Some(svc)) => Some(svc),
711 _ => None,
712 }
713 });
714
715 match service {
716 Some("git-upload-pack") => {
717 eprintln!("[SmartGitServer] Handling info/refs for upload-pack");
718 return handle_info_refs_upload_pack(repo_path, git_protocol.as_deref()).await;
719 }
720 Some("git-receive-pack") => {
721 // We only support upload-pack for testing (fetch/clone)
722 return Ok(Response::builder()
723 .status(StatusCode::FORBIDDEN)
724 .body(Full::new(Bytes::from("receive-pack not supported")))
725 .unwrap());
726 }
727 _ => {
728 return Ok(Response::builder()
729 .status(StatusCode::BAD_REQUEST)
730 .body(Full::new(Bytes::from("Missing or invalid service parameter")))
731 .unwrap());
732 }
733 }
734 }
735
736 // Route: POST /git-upload-pack
737 if method == hyper::Method::POST && path.ends_with("/git-upload-pack") {
738 eprintln!("[SmartGitServer] Handling POST /git-upload-pack");
739 return handle_upload_pack(req, repo_path, git_protocol.as_deref()).await;
740 }
741
742 // Not found
743 Ok(Response::builder()
744 .status(StatusCode::NOT_FOUND)
745 .body(Full::new(Bytes::from("Not Found")))
746 .unwrap())
747}
748
749/// Handle GET /info/refs?service=git-upload-pack
750///
751/// This advertises the repository's refs to the client using the smart protocol.
752async fn handle_info_refs_upload_pack(
753 repo_path: &Path,
754 git_protocol_version: Option<&str>,
755) -> Result<Response<Full<Bytes>>, hyper::Error> {
756 use std::process::Stdio;
757 use tokio::process::Command as TokioCommand;
758 use tokio::io::AsyncReadExt;
759
760 // Spawn git upload-pack --advertise-refs
761 let mut cmd = TokioCommand::new("git");
762 cmd.arg("-c")
763 .arg("uploadpack.allowReachableSHA1InWant=true")
764 .arg("-c")
765 .arg("uploadpack.allowTipSHA1InWant=true")
766 .arg("upload-pack")
767 .arg("--advertise-refs")
768 .arg("--stateless-rpc");
769
770 // Set GIT_PROTOCOL environment variable if version 2 is requested
771 if let Some(version) = git_protocol_version {
772 cmd.env("GIT_PROTOCOL", version);
773 }
774
775 cmd.arg(repo_path)
776 .stdin(Stdio::null())
777 .stdout(Stdio::piped())
778 .stderr(Stdio::piped());
779
780 let mut child = match cmd.spawn()
781 {
782 Ok(child) => child,
783 Err(e) => {
784 eprintln!("Failed to spawn git upload-pack: {}", e);
785 return Ok(Response::builder()
786 .status(StatusCode::INTERNAL_SERVER_ERROR)
787 .body(Full::new(Bytes::from("Failed to spawn git process")))
788 .unwrap());
789 }
790 };
791
792 // Read stdout
793 let mut output = Vec::new();
794 if let Some(mut stdout) = child.stdout.take() {
795 if let Err(e) = stdout.read_to_end(&mut output).await {
796 eprintln!("Failed to read git output: {}", e);
797 }
798 }
799
800 // Wait for process
801 let status = child.wait().await;
802 if let Ok(s) = &status {
803 if !s.success() {
804 eprintln!("git upload-pack --advertise-refs failed");
805 }
806 }
807
808 // Build response with pkt-line header
809 // Format: pkt-line("# service=git-upload-pack\n") + flush + git output
810 let mut response_body = Vec::new();
811
812 // First line: service advertisement
813 let service_line = "# service=git-upload-pack\n";
814 let len = service_line.len() + 4;
815 response_body.extend_from_slice(format!("{:04x}", len).as_bytes());
816 response_body.extend_from_slice(service_line.as_bytes());
817
818 // Flush packet
819 response_body.extend_from_slice(b"0000");
820
821 // Then the git output
822 response_body.extend_from_slice(&output);
823
824 Ok(Response::builder()
825 .status(StatusCode::OK)
826 .header("Content-Type", "application/x-git-upload-pack-advertisement")
827 .header("Cache-Control", "no-cache")
828 .body(Full::new(Bytes::from(response_body)))
829 .unwrap())
830}
831
832/// Handle POST /git-upload-pack
833///
834/// This handles the actual fetch negotiation and pack data transfer.
835async fn handle_upload_pack(
836 req: Request<hyper::body::Incoming>,
837 repo_path: &Path,
838 git_protocol_version: Option<&str>,
839) -> Result<Response<Full<Bytes>>, hyper::Error> {
840 use http_body_util::BodyExt;
841 use std::process::Stdio;
842 use tokio::io::{AsyncReadExt, AsyncWriteExt};
843 use tokio::process::Command as TokioCommand;
844
845 // Read request body
846 let body_bytes = req.collect().await?.to_bytes();
847
848 // Spawn git upload-pack
849 let mut cmd = TokioCommand::new("git");
850 cmd.arg("-c")
851 .arg("uploadpack.allowReachableSHA1InWant=true")
852 .arg("-c")
853 .arg("uploadpack.allowTipSHA1InWant=true")
854 .arg("upload-pack")
855 .arg("--stateless-rpc");
856
857 // Set GIT_PROTOCOL environment variable if version 2 is requested
858 if let Some(version) = git_protocol_version {
859 cmd.env("GIT_PROTOCOL", version);
860 }
861
862 cmd.arg(repo_path)
863 .stdin(Stdio::piped())
864 .stdout(Stdio::piped())
865 .stderr(Stdio::piped());
866
867 let mut child = match cmd.spawn()
868 {
869 Ok(child) => child,
870 Err(e) => {
871 eprintln!("Failed to spawn git upload-pack: {}", e);
872 return Ok(Response::builder()
873 .status(StatusCode::INTERNAL_SERVER_ERROR)
874 .body(Full::new(Bytes::from("Failed to spawn git process")))
875 .unwrap());
876 }
877 };
878
879 // Write request body to stdin
880 if let Some(mut stdin) = child.stdin.take() {
881 if let Err(e) = stdin.write_all(&body_bytes).await {
882 eprintln!("Failed to write to git stdin: {}", e);
883 }
884 // Close stdin to signal end of input
885 drop(stdin);
886 }
887
888 // Read stdout
889 let mut output = Vec::new();
890 if let Some(mut stdout) = child.stdout.take() {
891 if let Err(e) = stdout.read_to_end(&mut output).await {
892 eprintln!("Failed to read git output: {}", e);
893 }
894 }
895
896 // Read stderr for debugging
897 let mut stderr_output = Vec::new();
898 if let Some(mut stderr) = child.stderr.take() {
899 let _ = stderr.read_to_end(&mut stderr_output).await;
900 }
901
902 // Wait for process
903 let status = child.wait().await;
904 if let Ok(s) = &status {
905 if !s.success() {
906 let stderr_str = String::from_utf8_lossy(&stderr_output);
907 eprintln!("git upload-pack failed: {}", stderr_str);
908 }
909 }
910
911 Ok(Response::builder()
912 .status(StatusCode::OK)
913 .header("Content-Type", "application/x-git-upload-pack-result")
914 .header("Cache-Control", "no-cache")
915 .body(Full::new(Bytes::from(output)))
916 .unwrap())
917}
918
919#[cfg(test)]
920mod smart_git_server_tests {
921 use super::*;
922 use crate::common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant};
923
924 #[tokio::test]
925 async fn test_smart_git_server_starts_and_stops() {
926 // Create a test repo
927 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
928 create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
929 .expect("Failed to create test repo");
930
931 // Start server
932 let server = SmartGitServer::start(temp_dir.path()).await;
933
934 // Verify URL is set
935 assert!(server.url().starts_with("http://127.0.0.1:"));
936
937 // Stop server
938 server.stop().await;
939 }
940
941 #[tokio::test]
942 async fn test_smart_git_server_info_refs() {
943 // Create a test repo
944 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
945 create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
946 .expect("Failed to create test repo");
947
948 // Start server
949 let server = SmartGitServer::start(temp_dir.path()).await;
950
951 // Fetch info/refs with service parameter
952 let info_refs_url = format!("{}/info/refs?service=git-upload-pack", server.url());
953 let response = reqwest::get(&info_refs_url)
954 .await
955 .expect("Failed to fetch info/refs");
956
957 assert!(
958 response.status().is_success(),
959 "info/refs should be accessible"
960 );
961
962 // Check content type
963 let content_type = response
964 .headers()
965 .get("content-type")
966 .and_then(|v| v.to_str().ok())
967 .unwrap_or("");
968 assert!(
969 content_type.contains("application/x-git-upload-pack-advertisement"),
970 "Content-Type should be git-upload-pack-advertisement, got: {}",
971 content_type
972 );
973
974 let body = response.bytes().await.expect("Failed to read response body");
975
976 // Should start with service advertisement pkt-line
977 let body_str = String::from_utf8_lossy(&body);
978 assert!(
979 body_str.contains("# service=git-upload-pack"),
980 "Response should contain service advertisement, got: {}",
981 body_str
982 );
983
984 server.stop().await;
985 }
986
987 #[tokio::test]
988 async fn test_smart_git_server_ls_remote() {
989 println!("[TEST] Starting test_smart_git_server_ls_remote");
990 eprintln!("[TEST] Starting test_smart_git_server_ls_remote");
991
992 // Create a test repo
993 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
994 println!("[TEST] Created temp dir");
995 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
996 .expect("Failed to create test repo");
997 println!("[TEST] Created test repo with commit {}", commit_hash);
998
999 // Start server
1000 println!("[TEST] About to start SmartGitServer");
1001 let server = SmartGitServer::start(temp_dir.path()).await;
1002 println!("[TEST] Server started at {}", server.url());
1003
1004 // Give the server loop task a chance to print
1005 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1006
1007 // Run git ls-remote against the server
1008 println!("[TEST] Running git ls-remote {}", server.url());
1009 let output = Command::new("git")
1010 .args(["ls-remote", server.url()])
1011 .output()
1012 .expect("Failed to run git ls-remote");
1013 println!("[TEST] git ls-remote completed");
1014
1015 assert!(
1016 output.status.success(),
1017 "git ls-remote should succeed: {}",
1018 String::from_utf8_lossy(&output.stderr)
1019 );
1020
1021 let stdout = String::from_utf8_lossy(&output.stdout);
1022
1023 // Should list the main branch with the correct commit
1024 assert!(
1025 stdout.contains(&commit_hash),
1026 "ls-remote output should contain commit {}, got: {}",
1027 commit_hash,
1028 stdout
1029 );
1030 assert!(
1031 stdout.contains("refs/heads/main"),
1032 "ls-remote output should contain refs/heads/main, got: {}",
1033 stdout
1034 );
1035
1036 server.stop().await;
1037 }
1038
1039 #[tokio::test]
1040 async fn test_smart_git_server_fetch() {
1041 // Create a source repo with a commit
1042 let source_dir = tempfile::tempdir().expect("Failed to create source dir");
1043 let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest)
1044 .expect("Failed to create test repo");
1045
1046 // Start server serving the source repo
1047 let server = SmartGitServer::start(source_dir.path()).await;
1048
1049 // Create a destination repo to fetch into
1050 let dest_dir = tempfile::tempdir().expect("Failed to create dest dir");
1051
1052 // Initialize empty repo
1053 let output = Command::new("git")
1054 .args(["init"])
1055 .current_dir(dest_dir.path())
1056 .output()
1057 .expect("Failed to init dest repo");
1058 assert!(output.status.success());
1059
1060 // Add the server as a remote
1061 let output = Command::new("git")
1062 .args(["remote", "add", "origin", server.url()])
1063 .current_dir(dest_dir.path())
1064 .output()
1065 .expect("Failed to add remote");
1066 assert!(output.status.success());
1067
1068 // Fetch from the server
1069 let output = Command::new("git")
1070 .args(["fetch", "origin"])
1071 .current_dir(dest_dir.path())
1072 .output()
1073 .expect("Failed to fetch");
1074
1075 assert!(
1076 output.status.success(),
1077 "git fetch should succeed: {}",
1078 String::from_utf8_lossy(&output.stderr)
1079 );
1080
1081 // Verify the commit was fetched
1082 let output = Command::new("git")
1083 .args(["rev-parse", "origin/main"])
1084 .current_dir(dest_dir.path())
1085 .output()
1086 .expect("Failed to rev-parse");
1087
1088 assert!(output.status.success());
1089 let fetched_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
1090 assert_eq!(
1091 fetched_commit, commit_hash,
1092 "Fetched commit should match source commit"
1093 );
1094
1095 server.stop().await;
1096 }
1097
1098 #[tokio::test]
1099 async fn test_smart_git_server_shallow_fetch() {
1100 // This is the KEY test - shallow fetch requires smart HTTP protocol
1101
1102 // Create a source repo with a commit
1103 let source_dir = tempfile::tempdir().expect("Failed to create source dir");
1104 let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest)
1105 .expect("Failed to create test repo");
1106
1107 // Start server serving the source repo
1108 let server = SmartGitServer::start(source_dir.path()).await;
1109
1110 // Create a destination repo to fetch into
1111 let dest_dir = tempfile::tempdir().expect("Failed to create dest dir");
1112
1113 // Initialize empty repo
1114 let output = Command::new("git")
1115 .args(["init"])
1116 .current_dir(dest_dir.path())
1117 .output()
1118 .expect("Failed to init dest repo");
1119 assert!(output.status.success());
1120
1121 // Add the server as a remote
1122 let output = Command::new("git")
1123 .args(["remote", "add", "origin", server.url()])
1124 .current_dir(dest_dir.path())
1125 .output()
1126 .expect("Failed to add remote");
1127 assert!(output.status.success());
1128
1129 // Shallow fetch from the server - THIS IS WHAT PURGATORY SYNC USES
1130 let output = Command::new("git")
1131 .args(["fetch", "--depth=1", "origin", &commit_hash])
1132 .current_dir(dest_dir.path())
1133 .output()
1134 .expect("Failed to fetch");
1135
1136 assert!(
1137 output.status.success(),
1138 "git fetch --depth=1 should succeed with smart HTTP: {}",
1139 String::from_utf8_lossy(&output.stderr)
1140 );
1141
1142 // Verify the commit was fetched
1143 let output = Command::new("git")
1144 .args(["cat-file", "-t", &commit_hash])
1145 .current_dir(dest_dir.path())
1146 .output()
1147 .expect("Failed to cat-file");
1148
1149 assert!(
1150 output.status.success(),
1151 "Commit should exist after shallow fetch"
1152 );
1153 let object_type = String::from_utf8_lossy(&output.stdout).trim().to_string();
1154 assert_eq!(object_type, "commit", "Object should be a commit");
1155
1156 server.stop().await;
1157 }
1158}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 32ce1b7..b5077d3 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -8,7 +8,7 @@ pub mod purgatory_helpers;
8pub mod relay; 8pub mod relay;
9pub mod sync_helpers; 9pub mod sync_helpers;
10 10
11pub use git_server::SimpleGitServer; 11pub use git_server::{SimpleGitServer, SmartGitServer};
12pub use mock_relay::MockRelay; 12pub use mock_relay::MockRelay;
13pub use purgatory_helpers::*; 13pub use purgatory_helpers::*;
14pub use relay::TestRelay; 14pub use relay::TestRelay;
diff --git a/tests/purgatory_sync.rs b/tests/purgatory_sync.rs
index fe03455..3f52e4c 100644
--- a/tests/purgatory_sync.rs
+++ b/tests/purgatory_sync.rs
@@ -31,7 +31,7 @@ use common::{
31 add_commit_to_repo, build_repo_coord, check_ref_at_commit, create_pr_event, 31 add_commit_to_repo, build_repo_coord, check_ref_at_commit, create_pr_event,
32 create_pr_event_with_clone, create_repo_announcement, create_state_event, 32 create_pr_event_with_clone, create_repo_announcement, create_state_event,
33 create_test_repo_with_commit, push_ref_to_relay, push_to_relay, verify_event_not_served, 33 create_test_repo_with_commit, push_ref_to_relay, push_to_relay, verify_event_not_served,
34 wait_for_event_served, wait_for_sync_connection, CommitVariant, MockRelay, SimpleGitServer, 34 wait_for_event_served, wait_for_sync_connection, CommitVariant, MockRelay, SmartGitServer,
35 TestRelay, 35 TestRelay,
36}; 36};
37use nostr_sdk::prelude::*; 37use nostr_sdk::prelude::*;
@@ -759,8 +759,10 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
759 // 2. mock_relay - rust-nostr relay for PR event (no validation, no purgatory) 759 // 2. mock_relay - rust-nostr relay for PR event (no validation, no purgatory)
760 let mock_relay = MockRelay::start().await; 760 let mock_relay = MockRelay::start().await;
761 761
762 // 3. git_server - SimpleGitServer with PR commit only 762 // 3. git_server - SmartGitServer with PR commit only
763 let git_server = SimpleGitServer::start(repo_b.path()).await; 763 // Using SmartGitServer because purgatory sync uses `git fetch --depth=1`
764 // which requires the Git Smart HTTP protocol (not dumb HTTP)
765 let git_server = SmartGitServer::start(repo_b.path()).await;
764 766
765 // 4. Pre-allocate syncing_relay port for announcement tags 767 // 4. Pre-allocate syncing_relay port for announcement tags
766 let syncing_port = TestRelay::find_free_port(); 768 let syncing_port = TestRelay::find_free_port();