upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/git/mod.rs')
-rw-r--r--src/git/mod.rs210
1 files changed, 157 insertions, 53 deletions
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 076e211..494f8b9 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -40,7 +40,7 @@ use tracing::{debug, info};
40pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { 40pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf {
41 // Remove .git suffix if present 41 // Remove .git suffix if present
42 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); 42 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier);
43 43
44 PathBuf::from(git_data_path) 44 PathBuf::from(git_data_path)
45 .join(npub) 45 .join(npub)
46 .join(format!("{}.git", identifier)) 46 .join(format!("{}.git", identifier))
@@ -89,7 +89,10 @@ pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool {
89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { 89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> {
90 // Validate the ref format 90 // Validate the ref format
91 if !head_ref.starts_with("refs/heads/") { 91 if !head_ref.starts_with("refs/heads/") {
92 return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref)); 92 return Err(format!(
93 "Invalid HEAD ref: {} (must start with refs/heads/)",
94 head_ref
95 ));
93 } 96 }
94 97
95 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); 98 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display());
@@ -130,7 +133,10 @@ pub fn try_set_head_if_available(
130) -> Result<bool, String> { 133) -> Result<bool, String> {
131 // Check if repository exists 134 // Check if repository exists
132 if !repo_path.exists() { 135 if !repo_path.exists() {
133 debug!("Repository not found at {}, cannot set HEAD", repo_path.display()); 136 debug!(
137 "Repository not found at {}, cannot set HEAD",
138 repo_path.display()
139 );
134 return Ok(false); 140 return Ok(false);
135 } 141 }
136 142
@@ -149,6 +155,115 @@ pub fn try_set_head_if_available(
149 Ok(true) 155 Ok(true)
150} 156}
151 157
158/// Get the commit hash that a ref points to
159///
160/// # Arguments
161/// * `repo_path` - Path to the bare git repository
162/// * `ref_name` - The ref name (e.g., "refs/nostr/<event-id>")
163///
164/// # Returns
165/// Some(commit_hash) if the ref exists, None otherwise
166pub fn get_ref_commit(repo_path: &Path, ref_name: &str) -> Option<String> {
167 let output = Command::new("git")
168 .args(["rev-parse", ref_name])
169 .current_dir(repo_path)
170 .output()
171 .ok()?;
172
173 if output.status.success() {
174 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
175 } else {
176 None
177 }
178}
179
180/// Delete a git ref from the repository
181///
182/// # Arguments
183/// * `repo_path` - Path to the bare git repository
184/// * `ref_name` - The ref name to delete (e.g., "refs/nostr/<event-id>")
185///
186/// # Returns
187/// Ok(()) if successful, Err with error message otherwise
188pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> {
189 debug!("Deleting ref {} from {}", ref_name, repo_path.display());
190
191 let output = Command::new("git")
192 .args(["update-ref", "-d", ref_name])
193 .current_dir(repo_path)
194 .output()
195 .map_err(|e| format!("Failed to execute git update-ref: {}", e))?;
196
197 if !output.status.success() {
198 let stderr = String::from_utf8_lossy(&output.stderr);
199 return Err(format!("git update-ref -d failed: {}", stderr));
200 }
201
202 info!("Deleted ref {} from {}", ref_name, repo_path.display());
203 Ok(())
204}
205
206/// Validate refs/nostr/<event-id> ref against expected commit
207///
208/// If the ref exists but points to a different commit than expected,
209/// the ref is deleted. This is called when a PR event is received to
210/// ensure refs/nostr refs are consistent with their corresponding events.
211///
212/// # Arguments
213/// * `repo_path` - Path to the bare git repository
214/// * `event_id` - The event ID (hex string)
215/// * `expected_commit` - The commit hash from the event's `c` tag
216///
217/// # Returns
218/// Ok(true) if ref was deleted (mismatch), Ok(false) if no action taken, Err on failure
219pub fn validate_nostr_ref(
220 repo_path: &Path,
221 event_id: &str,
222 expected_commit: &str,
223) -> Result<bool, String> {
224 let ref_name = format!("refs/nostr/{}", event_id);
225
226 // Check if repository exists
227 if !repo_path.exists() {
228 debug!(
229 "Repository not found at {}, skipping ref validation",
230 repo_path.display()
231 );
232 return Ok(false);
233 }
234
235 // Check if the ref exists
236 let current_commit = match get_ref_commit(repo_path, &ref_name) {
237 Some(commit) => commit,
238 None => {
239 debug!("Ref {} does not exist in {}", ref_name, repo_path.display());
240 return Ok(false);
241 }
242 };
243
244 // Compare commits
245 if current_commit == expected_commit {
246 debug!(
247 "Ref {} points to correct commit {} in {}",
248 ref_name,
249 expected_commit,
250 repo_path.display()
251 );
252 return Ok(false);
253 }
254
255 // Commit mismatch - delete the ref
256 info!(
257 "Deleting mismatched ref {} in {}: expected {}, found {}",
258 ref_name,
259 repo_path.display(),
260 expected_commit,
261 current_commit
262 );
263 delete_ref(repo_path, &ref_name)?;
264 Ok(true)
265}
266
152/// Get the current HEAD ref from a repository 267/// Get the current HEAD ref from a repository
153/// 268///
154/// # Arguments 269/// # Arguments
@@ -178,25 +293,25 @@ pub fn get_repository_head(repo_path: &Path) -> Option<String> {
178pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { 293pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> {
179 // Remove leading slash 294 // Remove leading slash
180 let path = path.strip_prefix('/').unwrap_or(path); 295 let path = path.strip_prefix('/').unwrap_or(path);
181 296
182 // Split into components 297 // Split into components
183 let parts: Vec<&str> = path.splitn(3, '/').collect(); 298 let parts: Vec<&str> = path.splitn(3, '/').collect();
184 299
185 if parts.len() < 3 { 300 if parts.len() < 3 {
186 return None; 301 return None;
187 } 302 }
188 303
189 let npub = parts[0]; 304 let npub = parts[0];
190 let repo_part = parts[1]; 305 let repo_part = parts[1];
191 let subpath = parts[2]; 306 let subpath = parts[2];
192 307
193 // Extract identifier (remove .git suffix if present for the middle part) 308 // Extract identifier (remove .git suffix if present for the middle part)
194 let identifier = if repo_part.ends_with(".git") { 309 let identifier = if repo_part.ends_with(".git") {
195 &repo_part[..repo_part.len() - 4] 310 &repo_part[..repo_part.len() - 4]
196 } else { 311 } else {
197 repo_part 312 repo_part
198 }; 313 };
199 314
200 Some((npub, identifier, subpath)) 315 Some((npub, identifier, subpath))
201} 316}
202 317
@@ -210,13 +325,13 @@ mod tests {
210 fn create_test_repo() -> (TempDir, PathBuf) { 325 fn create_test_repo() -> (TempDir, PathBuf) {
211 let temp_dir = TempDir::new().unwrap(); 326 let temp_dir = TempDir::new().unwrap();
212 let repo_path = temp_dir.path().join("test.git"); 327 let repo_path = temp_dir.path().join("test.git");
213 328
214 // Initialize bare repository 329 // Initialize bare repository
215 Command::new("git") 330 Command::new("git")
216 .args(["init", "--bare", repo_path.to_str().unwrap()]) 331 .args(["init", "--bare", repo_path.to_str().unwrap()])
217 .output() 332 .output()
218 .unwrap(); 333 .unwrap();
219 334
220 (temp_dir, repo_path) 335 (temp_dir, repo_path)
221 } 336 }
222 337
@@ -225,19 +340,23 @@ mod tests {
225 let temp_dir = TempDir::new().unwrap(); 340 let temp_dir = TempDir::new().unwrap();
226 let work_dir = temp_dir.path().join("work"); 341 let work_dir = temp_dir.path().join("work");
227 let bare_repo = temp_dir.path().join("test.git"); 342 let bare_repo = temp_dir.path().join("test.git");
228 343
229 // Initialize bare repository 344 // Initialize bare repository
230 Command::new("git") 345 Command::new("git")
231 .args(["init", "--bare", bare_repo.to_str().unwrap()]) 346 .args(["init", "--bare", "--initial-branch=main", bare_repo.to_str().unwrap()])
232 .output() 347 .output()
233 .unwrap(); 348 .unwrap();
234 349
235 // Clone to working directory 350 // Clone to working directory
236 Command::new("git") 351 Command::new("git")
237 .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()]) 352 .args([
353 "clone",
354 bare_repo.to_str().unwrap(),
355 work_dir.to_str().unwrap(),
356 ])
238 .output() 357 .output()
239 .unwrap(); 358 .unwrap();
240 359
241 // Configure git for commits 360 // Configure git for commits
242 Command::new("git") 361 Command::new("git")
243 .args(["config", "user.email", "test@test.com"]) 362 .args(["config", "user.email", "test@test.com"])
@@ -249,7 +368,7 @@ mod tests {
249 .current_dir(&work_dir) 368 .current_dir(&work_dir)
250 .output() 369 .output()
251 .unwrap(); 370 .unwrap();
252 371
253 // Create a file and commit 372 // Create a file and commit
254 fs::write(work_dir.join("README.md"), "# Test").unwrap(); 373 fs::write(work_dir.join("README.md"), "# Test").unwrap();
255 Command::new("git") 374 Command::new("git")
@@ -262,7 +381,7 @@ mod tests {
262 .current_dir(&work_dir) 381 .current_dir(&work_dir)
263 .output() 382 .output()
264 .unwrap(); 383 .unwrap();
265 384
266 // Get commit hash 385 // Get commit hash
267 let output = Command::new("git") 386 let output = Command::new("git")
268 .args(["rev-parse", "HEAD"]) 387 .args(["rev-parse", "HEAD"])
@@ -270,41 +389,27 @@ mod tests {
270 .output() 389 .output()
271 .unwrap(); 390 .unwrap();
272 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); 391 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
273 392
274 // Push to bare repo 393 // Push to bare repo
275 Command::new("git") 394 Command::new("git")
276 .args(["push", "origin", "master"]) 395 .args(["push", "origin", "main"])
277 .current_dir(&work_dir) 396 .current_dir(&work_dir)
278 .output() 397 .output()
279 .unwrap(); 398 .unwrap();
280 399
281 (temp_dir, bare_repo, commit_hash) 400 (temp_dir, bare_repo, commit_hash)
282 } 401 }
283 402
284 #[test] 403 #[test]
285 fn test_resolve_repo_path() { 404 fn test_resolve_repo_path() {
286 let path = resolve_repo_path( 405 let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo");
287 "/data/git", 406 assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git"));
288 "npub1abc123",
289 "my-repo"
290 );
291 assert_eq!(
292 path,
293 PathBuf::from("/data/git/npub1abc123/my-repo.git")
294 );
295 } 407 }
296 408
297 #[test] 409 #[test]
298 fn test_resolve_repo_path_with_git_suffix() { 410 fn test_resolve_repo_path_with_git_suffix() {
299 let path = resolve_repo_path( 411 let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git");
300 "/data/git", 412 assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git"));
301 "npub1abc123",
302 "my-repo.git"
303 );
304 assert_eq!(
305 path,
306 PathBuf::from("/data/git/npub1abc123/my-repo.git")
307 );
308 } 413 }
309 414
310 #[test] 415 #[test]
@@ -332,7 +437,10 @@ mod tests {
332 #[test] 437 #[test]
333 fn test_commit_exists_nonexistent() { 438 fn test_commit_exists_nonexistent() {
334 let (_temp_dir, repo_path) = create_test_repo(); 439 let (_temp_dir, repo_path) = create_test_repo();
335 assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef")); 440 assert!(!commit_exists(
441 &repo_path,
442 "deadbeef1234567890abcdef1234567890abcdef"
443 ));
336 } 444 }
337 445
338 #[test] 446 #[test]
@@ -344,11 +452,11 @@ mod tests {
344 #[test] 452 #[test]
345 fn test_set_repository_head() { 453 fn test_set_repository_head() {
346 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); 454 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit();
347 455
348 // Default HEAD might be refs/heads/master 456 // Default HEAD might be refs/heads/master
349 let result = set_repository_head(&repo_path, "refs/heads/main"); 457 let result = set_repository_head(&repo_path, "refs/heads/main");
350 assert!(result.is_ok()); 458 assert!(result.is_ok());
351 459
352 let head = get_repository_head(&repo_path); 460 let head = get_repository_head(&repo_path);
353 assert_eq!(head, Some("refs/heads/main".to_string())); 461 assert_eq!(head, Some("refs/heads/main".to_string()));
354 } 462 }
@@ -356,7 +464,7 @@ mod tests {
356 #[test] 464 #[test]
357 fn test_set_repository_head_invalid_ref() { 465 fn test_set_repository_head_invalid_ref() {
358 let (_temp_dir, repo_path) = create_test_repo(); 466 let (_temp_dir, repo_path) = create_test_repo();
359 467
360 // Invalid ref format should fail 468 // Invalid ref format should fail
361 let result = set_repository_head(&repo_path, "main"); 469 let result = set_repository_head(&repo_path, "main");
362 assert!(result.is_err()); 470 assert!(result.is_err());
@@ -366,13 +474,13 @@ mod tests {
366 #[test] 474 #[test]
367 fn test_try_set_head_if_available_commit_missing() { 475 fn test_try_set_head_if_available_commit_missing() {
368 let (_temp_dir, repo_path) = create_test_repo(); 476 let (_temp_dir, repo_path) = create_test_repo();
369 477
370 let result = try_set_head_if_available( 478 let result = try_set_head_if_available(
371 &repo_path, 479 &repo_path,
372 "refs/heads/main", 480 "refs/heads/main",
373 "deadbeef1234567890abcdef1234567890abcdef", 481 "deadbeef1234567890abcdef1234567890abcdef",
374 ); 482 );
375 483
376 // Should return Ok(false) - commit not found 484 // Should return Ok(false) - commit not found
377 assert!(result.is_ok()); 485 assert!(result.is_ok());
378 assert!(!result.unwrap()); 486 assert!(!result.unwrap());
@@ -381,19 +489,15 @@ mod tests {
381 #[test] 489 #[test]
382 fn test_try_set_head_if_available_success() { 490 fn test_try_set_head_if_available_success() {
383 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); 491 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit();
384 492
385 let result = try_set_head_if_available( 493 let result = try_set_head_if_available(&repo_path, "refs/heads/main", &commit_hash);
386 &repo_path, 494
387 "refs/heads/main",
388 &commit_hash,
389 );
390
391 // Should return Ok(true) - HEAD was set 495 // Should return Ok(true) - HEAD was set
392 assert!(result.is_ok()); 496 assert!(result.is_ok());
393 assert!(result.unwrap()); 497 assert!(result.unwrap());
394 498
395 // Verify HEAD was set 499 // Verify HEAD was set
396 let head = get_repository_head(&repo_path); 500 let head = get_repository_head(&repo_path);
397 assert_eq!(head, Some("refs/heads/main".to_string())); 501 assert_eq!(head, Some("refs/heads/main".to_string()));
398 } 502 }
399} \ No newline at end of file 503}