upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 13:37:57 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 13:37:57 +0000
commit9a8c551adfada379704d594a6ff3862f13857b8e (patch)
treea902c6b313ab40a8914a34380ee194524dd67604 /src/git
parent12756fa66e3ec7f9dd24c66598085772829a8063 (diff)
add git http handling
Diffstat (limited to 'src/git')
-rw-r--r--src/git/handlers.rs209
-rw-r--r--src/git/mod.rs127
-rw-r--r--src/git/protocol.rs254
-rw-r--r--src/git/subprocess.rs140
4 files changed, 730 insertions, 0 deletions
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
new file mode 100644
index 0000000..0efc9d0
--- /dev/null
+++ b/src/git/handlers.rs
@@ -0,0 +1,209 @@
1//! Git HTTP Protocol Handlers
2//!
3//! This module implements the HTTP handlers for Git Smart HTTP protocol.
4
5use std::path::PathBuf;
6use hyper::{body::Bytes, Response, StatusCode};
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tracing::{debug, error, warn};
9
10use super::protocol::{GitService, PktLine};
11use super::subprocess::GitSubprocess;
12
13/// Handle GET /info/refs?service=git-{upload,receive}-pack
14///
15/// This advertises the repository's refs to the client.
16pub async fn handle_info_refs(
17 repo_path: PathBuf,
18 service: GitService,
19) -> Result<Response<String>, GitError> {
20 debug!("Handling info/refs for {:?} with service {:?}", repo_path, service);
21
22 // Check if repository exists
23 if !repo_path.exists() {
24 warn!("Repository not found: {:?}", repo_path);
25 return Err(GitError::RepositoryNotFound);
26 }
27
28 // Spawn git with --advertise-refs
29 let mut git = GitSubprocess::spawn(service, &repo_path, true)
30 .map_err(|e| {
31 error!("Failed to spawn git process: {}", e);
32 GitError::ProcessSpawnFailed(e)
33 })?;
34
35 // Read the output from git
36 let mut output = Vec::new();
37 if let Some(stdout) = git.take_stdout() {
38 let mut stdout = stdout;
39 stdout.read_to_end(&mut output).await
40 .map_err(|e| {
41 error!("Failed to read git output: {}", e);
42 GitError::IoError(e)
43 })?;
44 }
45
46 // Wait for process to complete
47 let status = git.wait().await
48 .map_err(|e| {
49 error!("Failed to wait for git process: {}", e);
50 GitError::IoError(e)
51 })?;
52
53 if !status.success() {
54 error!("Git process failed with status: {:?}", status);
55 return Err(GitError::GitFailed(status.code()));
56 }
57
58 // Build response with pkt-line header
59 let mut response_body = Vec::new();
60
61 // First line: service advertisement
62 let service_line = format!("# service={}\n", service.as_str());
63 response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode());
64 response_body.extend_from_slice(&PktLine::flush().encode());
65
66 // Then the git output
67 response_body.extend_from_slice(&output);
68
69 Ok(Response::builder()
70 .status(StatusCode::OK)
71 .header("content-type", service.advertisement_content_type())
72 .header("cache-control", "no-cache")
73 .body(String::from_utf8_lossy(&response_body).to_string())
74 .unwrap())
75}
76
77/// Handle POST /git-upload-pack (clone/fetch)
78pub async fn handle_upload_pack(
79 repo_path: PathBuf,
80 request_body: Bytes,
81) -> Result<Response<String>, GitError> {
82 debug!("Handling upload-pack for {:?}", repo_path);
83
84 if !repo_path.exists() {
85 return Err(GitError::RepositoryNotFound);
86 }
87
88 // Spawn git upload-pack
89 let mut git = GitSubprocess::spawn(GitService::UploadPack, &repo_path, false)
90 .map_err(GitError::ProcessSpawnFailed)?;
91
92 // Write request to git's stdin
93 if let Some(mut stdin) = git.take_stdin() {
94 stdin.write_all(&request_body).await
95 .map_err(GitError::IoError)?;
96 // Close stdin to signal end of input
97 drop(stdin);
98 }
99
100 // Read response from git's stdout
101 let mut output = Vec::new();
102 if let Some(stdout) = git.take_stdout() {
103 let mut stdout = stdout;
104 stdout.read_to_end(&mut output).await
105 .map_err(GitError::IoError)?;
106 }
107
108 // Wait for process
109 let status = git.wait().await
110 .map_err(GitError::IoError)?;
111
112 if !status.success() {
113 return Err(GitError::GitFailed(status.code()));
114 }
115
116 Ok(Response::builder()
117 .status(StatusCode::OK)
118 .header("content-type", GitService::UploadPack.result_content_type())
119 .header("cache-control", "no-cache")
120 .body(String::from_utf8_lossy(&output).to_string())
121 .unwrap())
122}
123
124/// Handle POST /git-receive-pack (push)
125///
126/// This includes an authorization hook point where GRASP validation will be added.
127pub async fn handle_receive_pack(
128 repo_path: PathBuf,
129 request_body: Bytes,
130) -> Result<Response<String>, GitError> {
131 debug!("Handling receive-pack for {:?}", repo_path);
132
133 if !repo_path.exists() {
134 return Err(GitError::RepositoryNotFound);
135 }
136
137 // TODO: Add GRASP authorization here
138 // For now, we'll accept all pushes to enable testing
139 debug!("Authorization check would go here (currently accepting all pushes)");
140
141 // Spawn git receive-pack
142 let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false)
143 .map_err(GitError::ProcessSpawnFailed)?;
144
145 // Write request to git's stdin
146 if let Some(mut stdin) = git.take_stdin() {
147 stdin.write_all(&request_body).await
148 .map_err(GitError::IoError)?;
149 drop(stdin);
150 }
151
152 // Read response from git's stdout
153 let mut output = Vec::new();
154 if let Some(stdout) = git.take_stdout() {
155 let mut stdout = stdout;
156 stdout.read_to_end(&mut output).await
157 .map_err(GitError::IoError)?;
158 }
159
160 // Wait for process
161 let status = git.wait().await
162 .map_err(GitError::IoError)?;
163
164 if !status.success() {
165 return Err(GitError::GitFailed(status.code()));
166 }
167
168 Ok(Response::builder()
169 .status(StatusCode::OK)
170 .header("content-type", GitService::ReceivePack.result_content_type())
171 .header("cache-control", "no-cache")
172 .body(String::from_utf8_lossy(&output).to_string())
173 .unwrap())
174}
175
176/// Errors that can occur in Git handlers
177#[derive(Debug)]
178pub enum GitError {
179 RepositoryNotFound,
180 ProcessSpawnFailed(std::io::Error),
181 IoError(std::io::Error),
182 GitFailed(Option<i32>),
183 Unauthorized,
184}
185
186impl std::fmt::Display for GitError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 Self::RepositoryNotFound => write!(f, "repository not found"),
190 Self::ProcessSpawnFailed(e) => write!(f, "failed to spawn git process: {}", e),
191 Self::IoError(e) => write!(f, "IO error: {}", e),
192 Self::GitFailed(code) => write!(f, "git process failed with code: {:?}", code),
193 Self::Unauthorized => write!(f, "unauthorized"),
194 }
195 }
196}
197
198impl std::error::Error for GitError {}
199
200impl GitError {
201 /// Convert to HTTP status code
202 pub fn status_code(&self) -> StatusCode {
203 match self {
204 Self::RepositoryNotFound => StatusCode::NOT_FOUND,
205 Self::Unauthorized => StatusCode::FORBIDDEN,
206 _ => StatusCode::INTERNAL_SERVER_ERROR,
207 }
208 }
209} \ No newline at end of file
diff --git a/src/git/mod.rs b/src/git/mod.rs
new file mode 100644
index 0000000..bd3b9e8
--- /dev/null
+++ b/src/git/mod.rs
@@ -0,0 +1,127 @@
1//! Git Smart HTTP Backend
2//!
3//! This module implements Git Smart HTTP protocol support for ngit-grasp.
4//! It provides handlers for clone, fetch, and push operations over HTTP.
5//!
6//! # Architecture
7//!
8//! - `protocol` - Git pkt-line format parsing and utilities
9//! - `subprocess` - Git process spawning and management
10//! - `handlers` - HTTP request handlers for Git operations
11//!
12//! # URL Patterns
13//!
14//! The following URL patterns are supported:
15//! - `GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack` - Clone/fetch advertisement
16//! - `GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack` - Push advertisement
17//! - `POST /<npub>/<identifier>.git/git-upload-pack` - Clone/fetch operation
18//! - `POST /<npub>/<identifier>.git/git-receive-pack` - Push operation
19
20pub mod handlers;
21pub mod protocol;
22pub mod subprocess;
23
24use std::path::PathBuf;
25
26/// Parse a Git repository path from URL components
27///
28/// Converts /<npub>/<identifier>.git/* to a filesystem path
29///
30/// # Arguments
31/// * `git_data_path` - Base directory for Git repositories
32/// * `npub` - The npub (Nostr public key in bech32 format)
33/// * `identifier` - The repository identifier
34///
35/// # Returns
36/// Path to the bare Git repository
37pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf {
38 // Remove .git suffix if present
39 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier);
40
41 PathBuf::from(git_data_path)
42 .join(npub)
43 .join(format!("{}.git", identifier))
44}
45
46/// Extract npub and identifier from a Git URL path
47///
48/// Parses paths like `/<npub>/<identifier>.git/info/refs`
49///
50/// Returns (npub, identifier, subpath) where subpath is the part after .git/
51pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> {
52 // Remove leading slash
53 let path = path.strip_prefix('/').unwrap_or(path);
54
55 // Split into components
56 let parts: Vec<&str> = path.splitn(3, '/').collect();
57
58 if parts.len() < 3 {
59 return None;
60 }
61
62 let npub = parts[0];
63 let repo_part = parts[1];
64 let subpath = parts[2];
65
66 // Extract identifier (remove .git suffix if present for the middle part)
67 let identifier = if repo_part.ends_with(".git") {
68 &repo_part[..repo_part.len() - 4]
69 } else {
70 repo_part
71 };
72
73 Some((npub, identifier, subpath))
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn test_resolve_repo_path() {
82 let path = resolve_repo_path(
83 "/data/git",
84 "npub1abc123",
85 "my-repo"
86 );
87 assert_eq!(
88 path,
89 PathBuf::from("/data/git/npub1abc123/my-repo.git")
90 );
91 }
92
93 #[test]
94 fn test_resolve_repo_path_with_git_suffix() {
95 let path = resolve_repo_path(
96 "/data/git",
97 "npub1abc123",
98 "my-repo.git"
99 );
100 assert_eq!(
101 path,
102 PathBuf::from("/data/git/npub1abc123/my-repo.git")
103 );
104 }
105
106 #[test]
107 fn test_parse_git_url_info_refs() {
108 let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/info/refs").unwrap();
109 assert_eq!(npub, "npub1abc");
110 assert_eq!(id, "repo");
111 assert_eq!(subpath, "info/refs");
112 }
113
114 #[test]
115 fn test_parse_git_url_upload_pack() {
116 let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/git-upload-pack").unwrap();
117 assert_eq!(npub, "npub1abc");
118 assert_eq!(id, "repo");
119 assert_eq!(subpath, "git-upload-pack");
120 }
121
122 #[test]
123 fn test_parse_git_url_invalid() {
124 assert!(parse_git_url("/npub1abc").is_none());
125 assert!(parse_git_url("/npub1abc/repo").is_none());
126 }
127} \ No newline at end of file
diff --git a/src/git/protocol.rs b/src/git/protocol.rs
new file mode 100644
index 0000000..84da131
--- /dev/null
+++ b/src/git/protocol.rs
@@ -0,0 +1,254 @@
1//! Git Smart HTTP Protocol Implementation
2//!
3//! This module implements the Git pkt-line format and protocol utilities.
4//!
5//! # Pkt-line Format
6//!
7//! A pkt-line is a variable length binary string with a 4-byte length prefix:
8//! - First 4 bytes: hex digits representing total length (including these 4 bytes)
9//! - Remaining bytes: payload data
10//! - Special case "0000": flush packet (end of section)
11//!
12//! # References
13//! - https://git-scm.com/docs/protocol-common#_pkt_line_format
14
15use std::fmt;
16
17/// Represents a Git pkt-line packet
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum PktLine {
20 /// Data packet with payload
21 Data(Vec<u8>),
22 /// Flush packet (0000)
23 Flush,
24}
25
26impl PktLine {
27 /// Create a data packet from bytes
28 pub fn data(data: impl Into<Vec<u8>>) -> Self {
29 Self::Data(data.into())
30 }
31
32 /// Create a flush packet
33 pub fn flush() -> Self {
34 Self::Flush
35 }
36
37 /// Encode this packet to wire format
38 pub fn encode(&self) -> Vec<u8> {
39 match self {
40 PktLine::Flush => b"0000".to_vec(),
41 PktLine::Data(data) => {
42 let len = data.len() + 4;
43 let mut result = Vec::with_capacity(len);
44 result.extend_from_slice(format!("{:04x}", len).as_bytes());
45 result.extend_from_slice(data);
46 result
47 }
48 }
49 }
50
51 /// Parse a single pkt-line from bytes
52 /// Returns (packet, remaining_bytes)
53 pub fn parse(input: &[u8]) -> Result<(Self, &[u8]), ProtocolError> {
54 if input.len() < 4 {
55 return Err(ProtocolError::InsufficientData);
56 }
57
58 let len_str = std::str::from_utf8(&input[0..4])
59 .map_err(|_| ProtocolError::InvalidLength)?;
60
61 let len = u16::from_str_radix(len_str, 16)
62 .map_err(|_| ProtocolError::InvalidLength)? as usize;
63
64 if len == 0 {
65 // Flush packet
66 return Ok((PktLine::Flush, &input[4..]));
67 }
68
69 if len < 4 {
70 return Err(ProtocolError::InvalidLength);
71 }
72
73 if input.len() < len {
74 return Err(ProtocolError::InsufficientData);
75 }
76
77 let data = input[4..len].to_vec();
78 Ok((PktLine::Data(data), &input[len..]))
79 }
80
81 /// Parse all pkt-lines from bytes
82 pub fn parse_all(mut input: &[u8]) -> Result<Vec<Self>, ProtocolError> {
83 let mut packets = Vec::new();
84
85 while !input.is_empty() {
86 let (packet, remaining) = Self::parse(input)?;
87 let is_flush = matches!(packet, PktLine::Flush);
88 packets.push(packet);
89 input = remaining;
90
91 // Stop at flush packet
92 if is_flush {
93 break;
94 }
95 }
96
97 Ok(packets)
98 }
99}
100
101/// Errors that can occur during protocol parsing
102#[derive(Debug)]
103pub enum ProtocolError {
104 /// Not enough data to parse a complete packet
105 InsufficientData,
106 /// Invalid length prefix
107 InvalidLength,
108 /// Invalid UTF-8 in packet data
109 InvalidUtf8,
110 /// IO error
111 Io(std::io::Error),
112}
113
114impl fmt::Display for ProtocolError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Self::InsufficientData => write!(f, "insufficient data for pkt-line"),
118 Self::InvalidLength => write!(f, "invalid pkt-line length"),
119 Self::InvalidUtf8 => write!(f, "invalid UTF-8 in pkt-line"),
120 Self::Io(e) => write!(f, "IO error: {}", e),
121 }
122 }
123}
124
125impl std::error::Error for ProtocolError {}
126
127impl From<std::io::Error> for ProtocolError {
128 fn from(e: std::io::Error) -> Self {
129 Self::Io(e)
130 }
131}
132
133/// Git service type
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum GitService {
136 /// Upload pack (clone/fetch)
137 UploadPack,
138 /// Receive pack (push)
139 ReceivePack,
140}
141
142impl GitService {
143 /// Parse service from query parameter
144 pub fn from_query_param(service: &str) -> Option<Self> {
145 match service {
146 "git-upload-pack" => Some(Self::UploadPack),
147 "git-receive-pack" => Some(Self::ReceivePack),
148 _ => None,
149 }
150 }
151
152 /// Get the service name as used in Git protocol
153 pub fn as_str(&self) -> &'static str {
154 match self {
155 Self::UploadPack => "git-upload-pack",
156 Self::ReceivePack => "git-receive-pack",
157 }
158 }
159
160 /// Get the content type for the service advertisement
161 pub fn advertisement_content_type(&self) -> &'static str {
162 match self {
163 Self::UploadPack => "application/x-git-upload-pack-advertisement",
164 Self::ReceivePack => "application/x-git-receive-pack-advertisement",
165 }
166 }
167
168 /// Get the content type for the service result
169 pub fn result_content_type(&self) -> &'static str {
170 match self {
171 Self::UploadPack => "application/x-git-upload-pack-result",
172 Self::ReceivePack => "application/x-git-receive-pack-result",
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_pktline_encode_flush() {
183 let pkt = PktLine::flush();
184 assert_eq!(pkt.encode(), b"0000");
185 }
186
187 #[test]
188 fn test_pktline_encode_data() {
189 let pkt = PktLine::data(b"hello");
190 assert_eq!(pkt.encode(), b"0009hello");
191 }
192
193 #[test]
194 fn test_pktline_parse_flush() {
195 let (pkt, remaining) = PktLine::parse(b"0000extra").unwrap();
196 assert_eq!(pkt, PktLine::Flush);
197 assert_eq!(remaining, b"extra");
198 }
199
200 #[test]
201 fn test_pktline_parse_data() {
202 let (pkt, remaining) = PktLine::parse(b"0009helloworld").unwrap();
203 assert_eq!(pkt, PktLine::data(b"hello"));
204 assert_eq!(remaining, b"world");
205 }
206
207 #[test]
208 fn test_pktline_parse_insufficient_data() {
209 let result = PktLine::parse(b"000");
210 assert!(matches!(result, Err(ProtocolError::InsufficientData)));
211 }
212
213 #[test]
214 fn test_pktline_parse_invalid_length() {
215 let result = PktLine::parse(b"xxxx");
216 assert!(matches!(result, Err(ProtocolError::InvalidLength)));
217 }
218
219 #[test]
220 fn test_pktline_parse_all() {
221 let input = b"0009hello000aworld\n0000";
222 let packets = PktLine::parse_all(input).unwrap();
223 assert_eq!(packets.len(), 3);
224 assert_eq!(packets[0], PktLine::data(b"hello"));
225 assert_eq!(packets[1], PktLine::data(b"world\n"));
226 assert_eq!(packets[2], PktLine::Flush);
227 }
228
229 #[test]
230 fn test_git_service_from_query() {
231 assert_eq!(
232 GitService::from_query_param("git-upload-pack"),
233 Some(GitService::UploadPack)
234 );
235 assert_eq!(
236 GitService::from_query_param("git-receive-pack"),
237 Some(GitService::ReceivePack)
238 );
239 assert_eq!(GitService::from_query_param("invalid"), None);
240 }
241
242 #[test]
243 fn test_git_service_content_types() {
244 let upload = GitService::UploadPack;
245 assert_eq!(
246 upload.advertisement_content_type(),
247 "application/x-git-upload-pack-advertisement"
248 );
249 assert_eq!(
250 upload.result_content_type(),
251 "application/x-git-upload-pack-result"
252 );
253 }
254} \ No newline at end of file
diff --git a/src/git/subprocess.rs b/src/git/subprocess.rs
new file mode 100644
index 0000000..96eed26
--- /dev/null
+++ b/src/git/subprocess.rs
@@ -0,0 +1,140 @@
1//! Git Subprocess Management
2//!
3//! This module provides utilities for spawning and managing Git subprocesses
4//! for upload-pack and receive-pack operations.
5
6use std::path::Path;
7use std::process::Stdio;
8use tokio::io::{AsyncRead, AsyncWrite};
9use tokio::process::{Child, Command};
10
11use super::protocol::GitService;
12
13/// Git subprocess wrapper
14pub struct GitSubprocess {
15 child: Child,
16}
17
18impl GitSubprocess {
19 /// Spawn a git subprocess for the given service and repository path
20 ///
21 /// # Arguments
22 /// * `service` - The Git service (upload-pack or receive-pack)
23 /// * `repo_path` - Path to the bare Git repository
24 /// * `advertise` - If true, run with --advertise-refs flag
25 pub fn spawn(
26 service: GitService,
27 repo_path: impl AsRef<Path>,
28 advertise: bool,
29 ) -> std::io::Result<Self> {
30 let repo_path = repo_path.as_ref();
31
32 let mut cmd = Command::new("git");
33 cmd.arg(service.as_str());
34
35 if advertise {
36 cmd.arg("--advertise-refs");
37 }
38
39 cmd.arg("--stateless-rpc");
40 cmd.arg(repo_path);
41
42 cmd.stdin(Stdio::piped());
43 cmd.stdout(Stdio::piped());
44 cmd.stderr(Stdio::piped());
45
46 let child = cmd.spawn()?;
47
48 Ok(Self { child })
49 }
50
51 /// Get a mutable reference to stdin
52 pub fn stdin(&mut self) -> Option<&mut (impl AsyncWrite + Unpin)> {
53 self.child.stdin.as_mut()
54 }
55
56 /// Get a mutable reference to stdout
57 pub fn stdout(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> {
58 self.child.stdout.as_mut()
59 }
60
61 /// Get a mutable reference to stderr
62 pub fn stderr(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> {
63 self.child.stderr.as_mut()
64 }
65
66 /// Take ownership of stdin
67 pub fn take_stdin(&mut self) -> Option<impl AsyncWrite> {
68 self.child.stdin.take()
69 }
70
71 /// Take ownership of stdout
72 pub fn take_stdout(&mut self) -> Option<impl AsyncRead> {
73 self.child.stdout.take()
74 }
75
76 /// Take ownership of stderr
77 pub fn take_stderr(&mut self) -> Option<impl AsyncRead> {
78 self.child.stderr.take()
79 }
80
81 /// Wait for the subprocess to complete
82 pub async fn wait(mut self) -> std::io::Result<std::process::ExitStatus> {
83 self.child.wait().await
84 }
85
86 /// Kill the subprocess
87 pub async fn kill(&mut self) -> std::io::Result<()> {
88 self.child.kill().await
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use tempfile::TempDir;
96 use std::process::Command as StdCommand;
97
98 fn create_bare_repo() -> TempDir {
99 let dir = TempDir::new().unwrap();
100 let status = StdCommand::new("git")
101 .args(["init", "--bare"])
102 .arg(dir.path())
103 .status()
104 .expect("Failed to run git init");
105 assert!(status.success());
106 dir
107 }
108
109 #[tokio::test]
110 async fn test_spawn_upload_pack_advertise() {
111 let repo = create_bare_repo();
112 let mut proc = GitSubprocess::spawn(
113 GitService::UploadPack,
114 repo.path(),
115 true,
116 ).expect("Failed to spawn git");
117
118 // Should have spawned successfully
119 assert!(proc.stdout().is_some());
120 assert!(proc.stdin().is_some());
121
122 // Clean up
123 let _ = proc.kill().await;
124 }
125
126 #[tokio::test]
127 async fn test_spawn_receive_pack() {
128 let repo = create_bare_repo();
129 let mut proc = GitSubprocess::spawn(
130 GitService::ReceivePack,
131 repo.path(),
132 false,
133 ).expect("Failed to spawn git");
134
135 assert!(proc.stdout().is_some());
136 assert!(proc.stdin().is_some());
137
138 let _ = proc.kill().await;
139 }
140} \ No newline at end of file