From 817ce37a5ee8d6279a44cf8cce3cc6a1e4bab576 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 14:05:51 +0000 Subject: feat: add uploadpack.allowFilter support for GRASP-01 compliance Add mandatory uploadpack.allowFilter capability to support partial clones and fetches as required by GRASP-01 specification. This enables efficient git operations for bandwidth-constrained clients (e.g., browser-based git clients like git-natural-api). Changes: - Add uploadpack.allowFilter=true to git subprocess configuration - Update SmartGitServer test helper with filter support - Add integration tests for filter capability advertisement and functionality - Update documentation to reflect filter as required capability Tests verify: - Filter capability is advertised in info/refs - Filtered clones with blob:none work correctly - Filtered fetches with tree:0 work correctly --- README.md | 1 + docs/learnings/grasp-01-implementation.md | 3 +- docs/reference/git-protocol.md | 38 +++++++ src/git/subprocess.rs | 7 +- tests/common/git_server.rs | 4 + tests/test_filter_support.rs | 158 ++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 tests/test_filter_support.rs diff --git a/README.md b/README.md index 7077bc5..06f3f7f 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f - ✅ Push validation against Nostr state events - ✅ Multi-maintainer support via recursive maintainer sets - ✅ Support for `refs/nostr/` for PRs +- ✅ Git capabilities: `allow-tip-sha1-in-want`, `allow-reachable-sha1-in-want`, `uploadpack.allowFilter` - ✅ CORS support for web-based Git clients - ✅ NIP-11 relay information document - ✅ **Purgatory**: Events without git data held for 30 minutes, auto-released when data arrives diff --git a/docs/learnings/grasp-01-implementation.md b/docs/learnings/grasp-01-implementation.md index 14ab452..27124af 100644 --- a/docs/learnings/grasp-01-implementation.md +++ b/docs/learnings/grasp-01-implementation.md @@ -42,7 +42,8 @@ - ✅ Recursive maintainer chain support - ✅ HEAD set from state events - ✅ `refs/nostr/` support for PRs -- ✅ `allow-tip-sha1-in-want` and `allow-reachable-sha1-in-want` +- ✅ `allow-tip-sha1-in-want` and `allow-reachable-sha1-in-want` (GRASP-01 requirement) +- ✅ `uploadpack.allowFilter` for partial clone support (required by git-natural-api) --- diff --git a/docs/reference/git-protocol.md b/docs/reference/git-protocol.md index 172a7bc..c0ecb3b 100644 --- a/docs/reference/git-protocol.md +++ b/docs/reference/git-protocol.md @@ -4,6 +4,44 @@ This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. +## Required Git Capabilities + +### GRASP-01 Requirements (MUST) + +Per the [GRASP-01 specification](https://github.com/DanConwayDev/grasp/blob/main/01.md), implementations **MUST** advertise and support the following git capabilities: + +- **`allow-reachable-sha1-in-want`**: Allows clients to request commits reachable from any ref +- **`allow-tip-sha1-in-want`**: Allows clients to request specific commit SHAs directly +- **`uploadpack.allowFilter`**: Enables partial clone/fetch with `--filter` options + +These are essential for supporting `refs/nostr/` (PR refs) and bandwidth-efficient partial clones. + +**Implementation:** `src/git/subprocess.rs:36-42` + +### How Capabilities are Advertised + +Git capabilities are advertised during the initial `GET /info/refs?service=git-upload-pack` request. The server spawns `git upload-pack --advertise-refs` with configuration flags: + +```bash +git -c uploadpack.allowReachableSHA1InWant=true \ + -c uploadpack.allowTipSHA1InWant=true \ + -c uploadpack.allowFilter=true \ + upload-pack --advertise-refs --stateless-rpc /path/to/repo.git +``` + +Clients parse the capability list from the response and only use features the server advertises. + +**Verification:** Test with `git ls-remote`: + +```bash +GIT_TRACE_PACKET=1 git ls-remote https://ngit.danconwaydev.com/npub.../repo.git 2>&1 | grep -E "allow-|filter" +``` + +Expected output should include: +``` +pkt-line: ... allow-tip-sha1-in-want allow-reachable-sha1-in-want filter ... +``` + ## Protocol Flow ### Clone/Fetch (Upload Pack) diff --git a/src/git/subprocess.rs b/src/git/subprocess.rs index acee726..37fa382 100644 --- a/src/git/subprocess.rs +++ b/src/git/subprocess.rs @@ -33,13 +33,16 @@ impl GitSubprocess { let mut cmd = Command::new("git"); - // GRASP-01 requirement: MUST include `allow-reachable-sha1-in-want` and - // `allow-tip-sha1-in-want` in advertisement and serve available oids. + // GRASP-01 requirement: MUST include `allow-reachable-sha1-in-want`, + // `allow-tip-sha1-in-want`, and `uploadpack.allowFilter` in advertisement + // and serve available oids and filtered requests. // These config options must be passed before the command name. cmd.arg("-c"); cmd.arg("uploadpack.allowReachableSHA1InWant=true"); cmd.arg("-c"); cmd.arg("uploadpack.allowTipSHA1InWant=true"); + cmd.arg("-c"); + cmd.arg("uploadpack.allowFilter=true"); cmd.arg(service.command_name()); diff --git a/tests/common/git_server.rs b/tests/common/git_server.rs index 3190901..7634968 100644 --- a/tests/common/git_server.rs +++ b/tests/common/git_server.rs @@ -764,6 +764,8 @@ async fn handle_info_refs_upload_pack( .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"); @@ -854,6 +856,8 @@ async fn handle_upload_pack( .arg("uploadpack.allowReachableSHA1InWant=true") .arg("-c") .arg("uploadpack.allowTipSHA1InWant=true") + .arg("-c") + .arg("uploadpack.allowFilter=true") .arg("upload-pack") .arg("--stateless-rpc"); diff --git a/tests/test_filter_support.rs b/tests/test_filter_support.rs new file mode 100644 index 0000000..58c6352 --- /dev/null +++ b/tests/test_filter_support.rs @@ -0,0 +1,158 @@ +//! Integration test for git filter support (--filter=blob:none) + +use tempfile::TempDir; +use tokio::process::Command; + +mod common; +use common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant}; +use common::SmartGitServer; + +/// Test that the server advertises filter capability +#[tokio::test] +async fn test_filter_capability_advertised() { + // Create a test repo + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) + .expect("Failed to create test repo"); + + // Start smart git server + let server = SmartGitServer::start(temp_dir.path()).await; + + // Run git ls-remote to see advertised capabilities + let output = Command::new("git") + .env("GIT_TRACE_PACKET", "1") + .args(["ls-remote", server.url()]) + .output() + .await + .expect("Failed to run git ls-remote"); + + // Capture stderr which contains GIT_TRACE_PACKET output + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check for filter capability in the advertisement + // The capability is advertised as "filter" in the pkt-line + assert!( + stderr.contains("filter") || stderr.contains("allow"), + "Expected to find 'filter' capability in git protocol advertisement.\nStderr:\n{}", + stderr + ); + + // Also verify the command succeeded + assert!( + output.status.success(), + "git ls-remote failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + server.stop().await; +} + +/// Test that filtered clones work (--filter=blob:none) +#[tokio::test] +async fn test_filtered_clone_succeeds() { + // Create a test repo with files + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) + .expect("Failed to create test repo"); + + // Start smart git server + let server = SmartGitServer::start(temp_dir.path()).await; + + // Create a clone destination + let clone_dir = TempDir::new().expect("Failed to create clone dir"); + let clone_path = clone_dir.path().join("cloned-repo"); + + // Attempt a filtered clone + let output = Command::new("git") + .args([ + "clone", + "--filter=blob:none", + server.url(), + clone_path.to_str().unwrap(), + ]) + .output() + .await + .expect("Failed to run git clone"); + + // Check if clone succeeded + if !output.status.success() { + eprintln!("git clone --filter=blob:none failed!"); + eprintln!("Stdout: {}", String::from_utf8_lossy(&output.stdout)); + eprintln!("Stderr: {}", String::from_utf8_lossy(&output.stderr)); + panic!("Filtered clone failed"); + } + + // Verify the clone worked + assert!(clone_path.exists(), "Clone directory should exist"); + assert!( + clone_path.join(".git").exists(), + "Cloned repo should have .git directory" + ); + + // In a filtered clone, we should be able to list files + let ls_output = Command::new("git") + .current_dir(&clone_path) + .args(["ls-files"]) + .output() + .await + .expect("Failed to list files"); + + assert!( + ls_output.status.success(), + "Should be able to list files in filtered clone" + ); + + let files = String::from_utf8_lossy(&ls_output.stdout); + assert!( + !files.trim().is_empty(), + "Should have files in the filtered clone" + ); + + server.stop().await; +} + +/// Test that filtered fetches work +#[tokio::test] +async fn test_filtered_fetch_succeeds() { + // Create a test repo + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) + .expect("Failed to create test repo"); + + // Start smart git server + let server = SmartGitServer::start(temp_dir.path()).await; + + // First, clone normally (to set up tracking) + let clone_dir = TempDir::new().expect("Failed to create clone dir"); + let clone_path = clone_dir.path().join("repo"); + + let clone_output = Command::new("git") + .args(["clone", server.url(), clone_path.to_str().unwrap()]) + .output() + .await + .expect("Failed to run git clone"); + + assert!( + clone_output.status.success(), + "Initial clone failed: {}", + String::from_utf8_lossy(&clone_output.stderr) + ); + + // Now try a filtered fetch + let fetch_output = Command::new("git") + .current_dir(&clone_path) + .args(["fetch", "--filter=blob:none", "origin"]) + .output() + .await + .expect("Failed to run git fetch"); + + // Check if fetch succeeded + if !fetch_output.status.success() { + eprintln!("git fetch --filter=blob:none failed!"); + eprintln!("Stdout: {}", String::from_utf8_lossy(&fetch_output.stdout)); + eprintln!("Stderr: {}", String::from_utf8_lossy(&fetch_output.stderr)); + panic!("Filtered fetch failed"); + } + + server.stop().await; +} -- cgit v1.2.3