upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 14:05:51 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 14:05:51 +0000
commit817ce37a5ee8d6279a44cf8cce3cc6a1e4bab576 (patch)
tree9fd5a6d3969afc33baa900bdab25bff81c5a83a4
parentf25eea8cc3b940cbcaa96224485826bfaae82449 (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.md1
-rw-r--r--docs/learnings/grasp-01-implementation.md3
-rw-r--r--docs/reference/git-protocol.md38
-rw-r--r--src/git/subprocess.rs7
-rw-r--r--tests/common/git_server.rs4
-rw-r--r--tests/test_filter_support.rs158
6 files changed, 208 insertions, 3 deletions
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
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
5This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. 5This 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
11Per 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
17These 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
23Git 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
26git -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
32Clients 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
37GIT_TRACE_PACKET=1 git ls-remote https://ngit.danconwaydev.com/npub.../repo.git 2>&1 | grep -E "allow-|filter"
38```
39
40Expected output should include:
41```
42pkt-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
3use tempfile::TempDir;
4use tokio::process::Command;
5
6mod common;
7use common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant};
8use common::SmartGitServer;
9
10/// Test that the server advertises filter capability
11#[tokio::test]
12async 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]
52async 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]
116async 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}