diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 14:05:51 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 14:05:51 +0000 |
| commit | 817ce37a5ee8d6279a44cf8cce3cc6a1e4bab576 (patch) | |
| tree | 9fd5a6d3969afc33baa900bdab25bff81c5a83a4 | |
| parent | f25eea8cc3b940cbcaa96224485826bfaae82449 (diff) | |
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
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | docs/learnings/grasp-01-implementation.md | 3 | ||||
| -rw-r--r-- | docs/reference/git-protocol.md | 38 | ||||
| -rw-r--r-- | src/git/subprocess.rs | 7 | ||||
| -rw-r--r-- | tests/common/git_server.rs | 4 | ||||
| -rw-r--r-- | tests/test_filter_support.rs | 158 |
6 files changed, 208 insertions, 3 deletions
| @@ -114,6 +114,7 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f | |||
| 114 | - ✅ Push validation against Nostr state events | 114 | - ✅ Push validation against Nostr state events |
| 115 | - ✅ Multi-maintainer support via recursive maintainer sets | 115 | - ✅ Multi-maintainer support via recursive maintainer sets |
| 116 | - ✅ Support for `refs/nostr/<event-id>` for PRs | 116 | - ✅ Support for `refs/nostr/<event-id>` for PRs |
| 117 | - ✅ Git capabilities: `allow-tip-sha1-in-want`, `allow-reachable-sha1-in-want`, `uploadpack.allowFilter` | ||
| 117 | - ✅ CORS support for web-based Git clients | 118 | - ✅ CORS support for web-based Git clients |
| 118 | - ✅ NIP-11 relay information document | 119 | - ✅ NIP-11 relay information document |
| 119 | - ✅ **Purgatory**: Events without git data held for 30 minutes, auto-released when data arrives | 120 | - ✅ **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 @@ | |||
| 42 | - ✅ Recursive maintainer chain support | 42 | - ✅ Recursive maintainer chain support |
| 43 | - ✅ HEAD set from state events | 43 | - ✅ HEAD set from state events |
| 44 | - ✅ `refs/nostr/<event-id>` support for PRs | 44 | - ✅ `refs/nostr/<event-id>` support for PRs |
| 45 | - ✅ `allow-tip-sha1-in-want` and `allow-reachable-sha1-in-want` | 45 | - ✅ `allow-tip-sha1-in-want` and `allow-reachable-sha1-in-want` (GRASP-01 requirement) |
| 46 | - ✅ `uploadpack.allowFilter` for partial clone support (required by git-natural-api) | ||
| 46 | 47 | ||
| 47 | --- | 48 | --- |
| 48 | 49 | ||
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 @@ | |||
| 4 | 4 | ||
| 5 | This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. | 5 | This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. |
| 6 | 6 | ||
| 7 | ## Required Git Capabilities | ||
| 8 | |||
| 9 | ### GRASP-01 Requirements (MUST) | ||
| 10 | |||
| 11 | Per the [GRASP-01 specification](https://github.com/DanConwayDev/grasp/blob/main/01.md), implementations **MUST** advertise and support the following git capabilities: | ||
| 12 | |||
| 13 | - **`allow-reachable-sha1-in-want`**: Allows clients to request commits reachable from any ref | ||
| 14 | - **`allow-tip-sha1-in-want`**: Allows clients to request specific commit SHAs directly | ||
| 15 | - **`uploadpack.allowFilter`**: Enables partial clone/fetch with `--filter` options | ||
| 16 | |||
| 17 | These are essential for supporting `refs/nostr/<event-id>` (PR refs) and bandwidth-efficient partial clones. | ||
| 18 | |||
| 19 | **Implementation:** `src/git/subprocess.rs:36-42` | ||
| 20 | |||
| 21 | ### How Capabilities are Advertised | ||
| 22 | |||
| 23 | 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: | ||
| 24 | |||
| 25 | ```bash | ||
| 26 | git -c uploadpack.allowReachableSHA1InWant=true \ | ||
| 27 | -c uploadpack.allowTipSHA1InWant=true \ | ||
| 28 | -c uploadpack.allowFilter=true \ | ||
| 29 | upload-pack --advertise-refs --stateless-rpc /path/to/repo.git | ||
| 30 | ``` | ||
| 31 | |||
| 32 | Clients parse the capability list from the response and only use features the server advertises. | ||
| 33 | |||
| 34 | **Verification:** Test with `git ls-remote`: | ||
| 35 | |||
| 36 | ```bash | ||
| 37 | GIT_TRACE_PACKET=1 git ls-remote https://ngit.danconwaydev.com/npub.../repo.git 2>&1 | grep -E "allow-|filter" | ||
| 38 | ``` | ||
| 39 | |||
| 40 | Expected output should include: | ||
| 41 | ``` | ||
| 42 | pkt-line: ... allow-tip-sha1-in-want allow-reachable-sha1-in-want filter ... | ||
| 43 | ``` | ||
| 44 | |||
| 7 | ## Protocol Flow | 45 | ## Protocol Flow |
| 8 | 46 | ||
| 9 | ### Clone/Fetch (Upload Pack) | 47 | ### 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 { | |||
| 33 | 33 | ||
| 34 | let mut cmd = Command::new("git"); | 34 | let mut cmd = Command::new("git"); |
| 35 | 35 | ||
| 36 | // GRASP-01 requirement: MUST include `allow-reachable-sha1-in-want` and | 36 | // GRASP-01 requirement: MUST include `allow-reachable-sha1-in-want`, |
| 37 | // `allow-tip-sha1-in-want` in advertisement and serve available oids. | 37 | // `allow-tip-sha1-in-want`, and `uploadpack.allowFilter` in advertisement |
| 38 | // and serve available oids and filtered requests. | ||
| 38 | // These config options must be passed before the command name. | 39 | // These config options must be passed before the command name. |
| 39 | cmd.arg("-c"); | 40 | cmd.arg("-c"); |
| 40 | cmd.arg("uploadpack.allowReachableSHA1InWant=true"); | 41 | cmd.arg("uploadpack.allowReachableSHA1InWant=true"); |
| 41 | cmd.arg("-c"); | 42 | cmd.arg("-c"); |
| 42 | cmd.arg("uploadpack.allowTipSHA1InWant=true"); | 43 | cmd.arg("uploadpack.allowTipSHA1InWant=true"); |
| 44 | cmd.arg("-c"); | ||
| 45 | cmd.arg("uploadpack.allowFilter=true"); | ||
| 43 | 46 | ||
| 44 | cmd.arg(service.command_name()); | 47 | cmd.arg(service.command_name()); |
| 45 | 48 | ||
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( | |||
| 764 | .arg("uploadpack.allowReachableSHA1InWant=true") | 764 | .arg("uploadpack.allowReachableSHA1InWant=true") |
| 765 | .arg("-c") | 765 | .arg("-c") |
| 766 | .arg("uploadpack.allowTipSHA1InWant=true") | 766 | .arg("uploadpack.allowTipSHA1InWant=true") |
| 767 | .arg("-c") | ||
| 768 | .arg("uploadpack.allowFilter=true") | ||
| 767 | .arg("upload-pack") | 769 | .arg("upload-pack") |
| 768 | .arg("--advertise-refs") | 770 | .arg("--advertise-refs") |
| 769 | .arg("--stateless-rpc"); | 771 | .arg("--stateless-rpc"); |
| @@ -854,6 +856,8 @@ async fn handle_upload_pack( | |||
| 854 | .arg("uploadpack.allowReachableSHA1InWant=true") | 856 | .arg("uploadpack.allowReachableSHA1InWant=true") |
| 855 | .arg("-c") | 857 | .arg("-c") |
| 856 | .arg("uploadpack.allowTipSHA1InWant=true") | 858 | .arg("uploadpack.allowTipSHA1InWant=true") |
| 859 | .arg("-c") | ||
| 860 | .arg("uploadpack.allowFilter=true") | ||
| 857 | .arg("upload-pack") | 861 | .arg("upload-pack") |
| 858 | .arg("--stateless-rpc"); | 862 | .arg("--stateless-rpc"); |
| 859 | 863 | ||
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 @@ | |||
| 1 | //! Integration test for git filter support (--filter=blob:none) | ||
| 2 | |||
| 3 | use tempfile::TempDir; | ||
| 4 | use tokio::process::Command; | ||
| 5 | |||
| 6 | mod common; | ||
| 7 | use common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant}; | ||
| 8 | use common::SmartGitServer; | ||
| 9 | |||
| 10 | /// Test that the server advertises filter capability | ||
| 11 | #[tokio::test] | ||
| 12 | async fn test_filter_capability_advertised() { | ||
| 13 | // Create a test repo | ||
| 14 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); | ||
| 15 | create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 16 | .expect("Failed to create test repo"); | ||
| 17 | |||
| 18 | // Start smart git server | ||
| 19 | let server = SmartGitServer::start(temp_dir.path()).await; | ||
| 20 | |||
| 21 | // Run git ls-remote to see advertised capabilities | ||
| 22 | let output = Command::new("git") | ||
| 23 | .env("GIT_TRACE_PACKET", "1") | ||
| 24 | .args(["ls-remote", server.url()]) | ||
| 25 | .output() | ||
| 26 | .await | ||
| 27 | .expect("Failed to run git ls-remote"); | ||
| 28 | |||
| 29 | // Capture stderr which contains GIT_TRACE_PACKET output | ||
| 30 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 31 | |||
| 32 | // Check for filter capability in the advertisement | ||
| 33 | // The capability is advertised as "filter" in the pkt-line | ||
| 34 | assert!( | ||
| 35 | stderr.contains("filter") || stderr.contains("allow"), | ||
| 36 | "Expected to find 'filter' capability in git protocol advertisement.\nStderr:\n{}", | ||
| 37 | stderr | ||
| 38 | ); | ||
| 39 | |||
| 40 | // Also verify the command succeeded | ||
| 41 | assert!( | ||
| 42 | output.status.success(), | ||
| 43 | "git ls-remote failed: {}", | ||
| 44 | String::from_utf8_lossy(&output.stderr) | ||
| 45 | ); | ||
| 46 | |||
| 47 | server.stop().await; | ||
| 48 | } | ||
| 49 | |||
| 50 | /// Test that filtered clones work (--filter=blob:none) | ||
| 51 | #[tokio::test] | ||
| 52 | async fn test_filtered_clone_succeeds() { | ||
| 53 | // Create a test repo with files | ||
| 54 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); | ||
| 55 | create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 56 | .expect("Failed to create test repo"); | ||
| 57 | |||
| 58 | // Start smart git server | ||
| 59 | let server = SmartGitServer::start(temp_dir.path()).await; | ||
| 60 | |||
| 61 | // Create a clone destination | ||
| 62 | let clone_dir = TempDir::new().expect("Failed to create clone dir"); | ||
| 63 | let clone_path = clone_dir.path().join("cloned-repo"); | ||
| 64 | |||
| 65 | // Attempt a filtered clone | ||
| 66 | let output = Command::new("git") | ||
| 67 | .args([ | ||
| 68 | "clone", | ||
| 69 | "--filter=blob:none", | ||
| 70 | server.url(), | ||
| 71 | clone_path.to_str().unwrap(), | ||
| 72 | ]) | ||
| 73 | .output() | ||
| 74 | .await | ||
| 75 | .expect("Failed to run git clone"); | ||
| 76 | |||
| 77 | // Check if clone succeeded | ||
| 78 | if !output.status.success() { | ||
| 79 | eprintln!("git clone --filter=blob:none failed!"); | ||
| 80 | eprintln!("Stdout: {}", String::from_utf8_lossy(&output.stdout)); | ||
| 81 | eprintln!("Stderr: {}", String::from_utf8_lossy(&output.stderr)); | ||
| 82 | panic!("Filtered clone failed"); | ||
| 83 | } | ||
| 84 | |||
| 85 | // Verify the clone worked | ||
| 86 | assert!(clone_path.exists(), "Clone directory should exist"); | ||
| 87 | assert!( | ||
| 88 | clone_path.join(".git").exists(), | ||
| 89 | "Cloned repo should have .git directory" | ||
| 90 | ); | ||
| 91 | |||
| 92 | // In a filtered clone, we should be able to list files | ||
| 93 | let ls_output = Command::new("git") | ||
| 94 | .current_dir(&clone_path) | ||
| 95 | .args(["ls-files"]) | ||
| 96 | .output() | ||
| 97 | .await | ||
| 98 | .expect("Failed to list files"); | ||
| 99 | |||
| 100 | assert!( | ||
| 101 | ls_output.status.success(), | ||
| 102 | "Should be able to list files in filtered clone" | ||
| 103 | ); | ||
| 104 | |||
| 105 | let files = String::from_utf8_lossy(&ls_output.stdout); | ||
| 106 | assert!( | ||
| 107 | !files.trim().is_empty(), | ||
| 108 | "Should have files in the filtered clone" | ||
| 109 | ); | ||
| 110 | |||
| 111 | server.stop().await; | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Test that filtered fetches work | ||
| 115 | #[tokio::test] | ||
| 116 | async fn test_filtered_fetch_succeeds() { | ||
| 117 | // Create a test repo | ||
| 118 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); | ||
| 119 | create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 120 | .expect("Failed to create test repo"); | ||
| 121 | |||
| 122 | // Start smart git server | ||
| 123 | let server = SmartGitServer::start(temp_dir.path()).await; | ||
| 124 | |||
| 125 | // First, clone normally (to set up tracking) | ||
| 126 | let clone_dir = TempDir::new().expect("Failed to create clone dir"); | ||
| 127 | let clone_path = clone_dir.path().join("repo"); | ||
| 128 | |||
| 129 | let clone_output = Command::new("git") | ||
| 130 | .args(["clone", server.url(), clone_path.to_str().unwrap()]) | ||
| 131 | .output() | ||
| 132 | .await | ||
| 133 | .expect("Failed to run git clone"); | ||
| 134 | |||
| 135 | assert!( | ||
| 136 | clone_output.status.success(), | ||
| 137 | "Initial clone failed: {}", | ||
| 138 | String::from_utf8_lossy(&clone_output.stderr) | ||
| 139 | ); | ||
| 140 | |||
| 141 | // Now try a filtered fetch | ||
| 142 | let fetch_output = Command::new("git") | ||
| 143 | .current_dir(&clone_path) | ||
| 144 | .args(["fetch", "--filter=blob:none", "origin"]) | ||
| 145 | .output() | ||
| 146 | .await | ||
| 147 | .expect("Failed to run git fetch"); | ||
| 148 | |||
| 149 | // Check if fetch succeeded | ||
| 150 | if !fetch_output.status.success() { | ||
| 151 | eprintln!("git fetch --filter=blob:none failed!"); | ||
| 152 | eprintln!("Stdout: {}", String::from_utf8_lossy(&fetch_output.stdout)); | ||
| 153 | eprintln!("Stderr: {}", String::from_utf8_lossy(&fetch_output.stderr)); | ||
| 154 | panic!("Filtered fetch failed"); | ||
| 155 | } | ||
| 156 | |||
| 157 | server.stop().await; | ||
| 158 | } | ||