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-28 09:19:06 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-28 09:19:06 +0000
commit4da51a8adb94f2979c0a911157f26596c1ee2cb5 (patch)
tree72c7e2d7347ae39fb6e7db06771a01beeddc37d3 /src/git
parente41126732fb75ec66a979c09544076ba92373680 (diff)
sync HEAD on state event and git data push
Diffstat (limited to 'src/git')
-rw-r--r--src/git/handlers.rs47
-rw-r--r--src/git/mod.rs273
2 files changed, 317 insertions, 3 deletions
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
index 27bec76..73f72f3 100644
--- a/src/git/handlers.rs
+++ b/src/git/handlers.rs
@@ -13,6 +13,9 @@ use super::authorization::{
13}; 13};
14use super::protocol::{GitService, PktLine}; 14use super::protocol::{GitService, PktLine};
15use super::subprocess::GitSubprocess; 15use super::subprocess::GitSubprocess;
16use super::{try_set_head_if_available};
17
18use crate::nostr::events::RepositoryState;
16 19
17/// Handle GET /info/refs?service=git-{upload,receive}-pack 20/// Handle GET /info/refs?service=git-{upload,receive}-pack
18/// 21///
@@ -163,6 +166,9 @@ pub struct PushAuthParams {
163/// This includes GRASP authorization validation according to GRASP-01: 166/// This includes GRASP authorization validation according to GRASP-01:
164/// "MUST accept pushes via this service that match the latest repo state announcement 167/// "MUST accept pushes via this service that match the latest repo state announcement
165/// on the relay, respecting the recursive maintainer set." 168/// on the relay, respecting the recursive maintainer set."
169///
170/// Also per GRASP-01: "MUST set repository HEAD per repository state announcement
171/// as soon as the git data related to that branch has been received."
166pub async fn handle_receive_pack( 172pub async fn handle_receive_pack(
167 repo_path: PathBuf, 173 repo_path: PathBuf,
168 request_body: Bytes, 174 request_body: Bytes,
@@ -174,14 +180,17 @@ pub async fn handle_receive_pack(
174 return Err(GitError::RepositoryNotFound); 180 return Err(GitError::RepositoryNotFound);
175 } 181 }
176 182
183 // Keep track of state for HEAD setting after push
184 let mut authorized_state: Option<RepositoryState> = None;
185
177 // GRASP Authorization Check 186 // GRASP Authorization Check
178 if let Some(params) = auth_params { 187 if let Some(ref params) = auth_params {
179 info!( 188 info!(
180 "Authorizing push for {}/{} via {}", 189 "Authorizing push for {}/{} via {}",
181 params.owner_npub, params.identifier, params.relay_url 190 params.owner_npub, params.identifier, params.relay_url
182 ); 191 );
183 192
184 match authorize_push(&params, &request_body).await { 193 match authorize_push(params, &request_body).await {
185 Ok(auth_result) => { 194 Ok(auth_result) => {
186 if !auth_result.authorized { 195 if !auth_result.authorized {
187 warn!( 196 warn!(
@@ -196,6 +205,8 @@ pub async fn handle_receive_pack(
196 params.identifier, 205 params.identifier,
197 auth_result.maintainers.len() 206 auth_result.maintainers.len()
198 ); 207 );
208 // Save the state for HEAD setting after push
209 authorized_state = auth_result.state;
199 } 210 }
200 Err(e) => { 211 Err(e) => {
201 warn!( 212 warn!(
@@ -246,6 +257,38 @@ pub async fn handle_receive_pack(
246 return Err(GitError::GitFailed(status.code())); 257 return Err(GitError::GitFailed(status.code()));
247 } 258 }
248 259
260 // GRASP-01: Set HEAD after git data is received
261 // "MUST set repository HEAD per repository state announcement
262 // as soon as the git data related to that branch has been received."
263 if let Some(state) = authorized_state {
264 if let Some(head_ref) = &state.head {
265 if let Some(branch_name) = state.get_head_branch() {
266 if let Some(commit) = state.get_branch_commit(branch_name) {
267 match try_set_head_if_available(&repo_path, head_ref, commit) {
268 Ok(true) => {
269 info!(
270 "Set HEAD to {} after push to {:?}",
271 head_ref, repo_path
272 );
273 }
274 Ok(false) => {
275 debug!(
276 "HEAD commit {} not found after push, HEAD not updated",
277 commit
278 );
279 }
280 Err(e) => {
281 warn!(
282 "Failed to set HEAD after push: {}",
283 e
284 );
285 }
286 }
287 }
288 }
289 }
290 }
291
249 Ok(Response::builder() 292 Ok(Response::builder()
250 .status(StatusCode::OK) 293 .status(StatusCode::OK)
251 .header("content-type", GitService::ReceivePack.result_content_type()) 294 .header("content-type", GitService::ReceivePack.result_content_type())
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 81ff277..076e211 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -22,7 +22,9 @@ pub mod handlers;
22pub mod protocol; 22pub mod protocol;
23pub mod subprocess; 23pub mod subprocess;
24 24
25use std::path::PathBuf; 25use std::path::{Path, PathBuf};
26use std::process::Command;
27use tracing::{debug, info};
26 28
27/// Parse a Git repository path from URL components 29/// Parse a Git repository path from URL components
28/// 30///
@@ -44,6 +46,130 @@ pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> P
44 .join(format!("{}.git", identifier)) 46 .join(format!("{}.git", identifier))
45} 47}
46 48
49/// Check if a commit exists in the repository
50///
51/// # Arguments
52/// * `repo_path` - Path to the bare git repository
53/// * `commit_hash` - The commit hash to check
54///
55/// # Returns
56/// True if the commit exists in the repository, false otherwise
57pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool {
58 let output = Command::new("git")
59 .args(["cat-file", "-t", commit_hash])
60 .current_dir(repo_path)
61 .output();
62
63 match output {
64 Ok(result) => {
65 if result.status.success() {
66 let obj_type = String::from_utf8_lossy(&result.stdout);
67 // Object exists and is a commit
68 obj_type.trim() == "commit"
69 } else {
70 false
71 }
72 }
73 Err(_) => false,
74 }
75}
76
77/// Set the repository HEAD to point to a branch
78///
79/// This updates the HEAD symbolic ref to point to the specified branch.
80/// Per GRASP-01: "MUST set repository HEAD per repository state announcement
81/// as soon as the git data related to that branch has been received."
82///
83/// # Arguments
84/// * `repo_path` - Path to the bare git repository
85/// * `head_ref` - The ref to set HEAD to (e.g., "refs/heads/main")
86///
87/// # Returns
88/// Ok(()) if successful, Err with error message otherwise
89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> {
90 // Validate the ref format
91 if !head_ref.starts_with("refs/heads/") {
92 return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref));
93 }
94
95 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display());
96
97 let output = Command::new("git")
98 .args(["symbolic-ref", "HEAD", head_ref])
99 .current_dir(repo_path)
100 .output()
101 .map_err(|e| format!("Failed to execute git symbolic-ref: {}", e))?;
102
103 if !output.status.success() {
104 let stderr = String::from_utf8_lossy(&output.stderr);
105 return Err(format!("git symbolic-ref failed: {}", stderr));
106 }
107
108 info!("Updated HEAD to {} in {}", head_ref, repo_path.display());
109 Ok(())
110}
111
112/// Try to set repository HEAD from a repository state event
113///
114/// This function checks if the HEAD branch's commit is available in the repository
115/// and sets HEAD if it is. This should be called:
116/// 1. When a repository state event is received (in case git data already exists)
117/// 2. After git data is received (in case a state event was already received)
118///
119/// # Arguments
120/// * `repo_path` - Path to the bare git repository
121/// * `head_ref` - The ref to set HEAD to (e.g., "refs/heads/main")
122/// * `head_commit` - The commit hash that the HEAD branch should point to
123///
124/// # Returns
125/// Ok(true) if HEAD was set, Ok(false) if commit not yet available, Err on failure
126pub fn try_set_head_if_available(
127 repo_path: &Path,
128 head_ref: &str,
129 head_commit: &str,
130) -> Result<bool, String> {
131 // Check if repository exists
132 if !repo_path.exists() {
133 debug!("Repository not found at {}, cannot set HEAD", repo_path.display());
134 return Ok(false);
135 }
136
137 // Check if the commit exists in the repository
138 if !commit_exists(repo_path, head_commit) {
139 debug!(
140 "Commit {} not found in {}, HEAD not set yet",
141 head_commit,
142 repo_path.display()
143 );
144 return Ok(false);
145 }
146
147 // Commit exists, set HEAD
148 set_repository_head(repo_path, head_ref)?;
149 Ok(true)
150}
151
152/// Get the current HEAD ref from a repository
153///
154/// # Arguments
155/// * `repo_path` - Path to the bare git repository
156///
157/// # Returns
158/// The current HEAD ref (e.g., "refs/heads/main") or None if not set
159pub fn get_repository_head(repo_path: &Path) -> Option<String> {
160 let output = Command::new("git")
161 .args(["symbolic-ref", "HEAD"])
162 .current_dir(repo_path)
163 .output()
164 .ok()?;
165
166 if output.status.success() {
167 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
168 } else {
169 None
170 }
171}
172
47/// Extract npub and identifier from a Git URL path 173/// Extract npub and identifier from a Git URL path
48/// 174///
49/// Parses paths like `/<npub>/<identifier>.git/info/refs` 175/// Parses paths like `/<npub>/<identifier>.git/info/refs`
@@ -77,6 +203,83 @@ pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> {
77#[cfg(test)] 203#[cfg(test)]
78mod tests { 204mod tests {
79 use super::*; 205 use super::*;
206 use std::fs;
207 use tempfile::TempDir;
208
209 /// Create a test bare repository with optional commits
210 fn create_test_repo() -> (TempDir, PathBuf) {
211 let temp_dir = TempDir::new().unwrap();
212 let repo_path = temp_dir.path().join("test.git");
213
214 // Initialize bare repository
215 Command::new("git")
216 .args(["init", "--bare", repo_path.to_str().unwrap()])
217 .output()
218 .unwrap();
219
220 (temp_dir, repo_path)
221 }
222
223 /// Create a test repository with a commit on a branch
224 fn create_test_repo_with_commit() -> (TempDir, PathBuf, String) {
225 let temp_dir = TempDir::new().unwrap();
226 let work_dir = temp_dir.path().join("work");
227 let bare_repo = temp_dir.path().join("test.git");
228
229 // Initialize bare repository
230 Command::new("git")
231 .args(["init", "--bare", bare_repo.to_str().unwrap()])
232 .output()
233 .unwrap();
234
235 // Clone to working directory
236 Command::new("git")
237 .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()])
238 .output()
239 .unwrap();
240
241 // Configure git for commits
242 Command::new("git")
243 .args(["config", "user.email", "test@test.com"])
244 .current_dir(&work_dir)
245 .output()
246 .unwrap();
247 Command::new("git")
248 .args(["config", "user.name", "Test"])
249 .current_dir(&work_dir)
250 .output()
251 .unwrap();
252
253 // Create a file and commit
254 fs::write(work_dir.join("README.md"), "# Test").unwrap();
255 Command::new("git")
256 .args(["add", "README.md"])
257 .current_dir(&work_dir)
258 .output()
259 .unwrap();
260 Command::new("git")
261 .args(["commit", "-m", "Initial commit"])
262 .current_dir(&work_dir)
263 .output()
264 .unwrap();
265
266 // Get commit hash
267 let output = Command::new("git")
268 .args(["rev-parse", "HEAD"])
269 .current_dir(&work_dir)
270 .output()
271 .unwrap();
272 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
273
274 // Push to bare repo
275 Command::new("git")
276 .args(["push", "origin", "master"])
277 .current_dir(&work_dir)
278 .output()
279 .unwrap();
280
281 (temp_dir, bare_repo, commit_hash)
282 }
80 283
81 #[test] 284 #[test]
82 fn test_resolve_repo_path() { 285 fn test_resolve_repo_path() {
@@ -125,4 +328,72 @@ mod tests {
125 assert!(parse_git_url("/npub1abc").is_none()); 328 assert!(parse_git_url("/npub1abc").is_none());
126 assert!(parse_git_url("/npub1abc/repo").is_none()); 329 assert!(parse_git_url("/npub1abc/repo").is_none());
127 } 330 }
331
332 #[test]
333 fn test_commit_exists_nonexistent() {
334 let (_temp_dir, repo_path) = create_test_repo();
335 assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef"));
336 }
337
338 #[test]
339 fn test_commit_exists_with_commit() {
340 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit();
341 assert!(commit_exists(&repo_path, &commit_hash));
342 }
343
344 #[test]
345 fn test_set_repository_head() {
346 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit();
347
348 // Default HEAD might be refs/heads/master
349 let result = set_repository_head(&repo_path, "refs/heads/main");
350 assert!(result.is_ok());
351
352 let head = get_repository_head(&repo_path);
353 assert_eq!(head, Some("refs/heads/main".to_string()));
354 }
355
356 #[test]
357 fn test_set_repository_head_invalid_ref() {
358 let (_temp_dir, repo_path) = create_test_repo();
359
360 // Invalid ref format should fail
361 let result = set_repository_head(&repo_path, "main");
362 assert!(result.is_err());
363 assert!(result.unwrap_err().contains("must start with refs/heads/"));
364 }
365
366 #[test]
367 fn test_try_set_head_if_available_commit_missing() {
368 let (_temp_dir, repo_path) = create_test_repo();
369
370 let result = try_set_head_if_available(
371 &repo_path,
372 "refs/heads/main",
373 "deadbeef1234567890abcdef1234567890abcdef",
374 );
375
376 // Should return Ok(false) - commit not found
377 assert!(result.is_ok());
378 assert!(!result.unwrap());
379 }
380
381 #[test]
382 fn test_try_set_head_if_available_success() {
383 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit();
384
385 let result = try_set_head_if_available(
386 &repo_path,
387 "refs/heads/main",
388 &commit_hash,
389 );
390
391 // Should return Ok(true) - HEAD was set
392 assert!(result.is_ok());
393 assert!(result.unwrap());
394
395 // Verify HEAD was set
396 let head = get_repository_head(&repo_path);
397 assert_eq!(head, Some("refs/heads/main".to_string()));
398 }
128} \ No newline at end of file 399} \ No newline at end of file