diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-07 21:33:22 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-07 21:33:22 +0000 |
| commit | 0550b3229f35ef3ee125bac47d85bbd08d1250b1 (patch) | |
| tree | 15ff1f689f9c429ceda9f085973df3d06895714d /tests/common/git_server.rs | |
| parent | 7467aa9ace94b4e132eedd03c9daddb2d59813c4 (diff) | |
test: add WIP SmartHttpServer to test
Diffstat (limited to 'tests/common/git_server.rs')
| -rw-r--r-- | tests/common/git_server.rs | 707 |
1 files changed, 689 insertions, 18 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 | ||
| 45 | use std::net::SocketAddr; | 49 | use std::net::SocketAddr; |
| 46 | use std::path::{Path, PathBuf}; | 50 | use 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`. | ||
| 511 | pub 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 | |||
| 526 | impl 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 | |||
| 667 | impl 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. | ||
| 679 | async 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. | ||
| 752 | async 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. | ||
| 835 | async 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)] | ||
| 920 | mod 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 | } | ||