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-08 00:50:54 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-08 00:50:54 +0000
commitf75e1c59aacf5ce668fd327e4e3d827511661c2a (patch)
tree867926c7503e7c587e86c67896a9e7347600447b
parent3f14f998d64b5fa15bdddd7570b4f72874eb9f29 (diff)
chore: cargo fmt
-rw-r--r--src/git/authorization.rs4
-rw-r--r--src/git/process.rs66
-rw-r--r--src/git/sync.rs40
-rw-r--r--src/main.rs4
-rw-r--r--src/nostr/builder.rs9
-rw-r--r--src/nostr/policy/pr_event.rs1
-rw-r--r--src/nostr/policy/state.rs10
-rw-r--r--src/purgatory/helpers.rs22
-rw-r--r--src/purgatory/mod.rs3
-rw-r--r--src/purgatory/sync/context.rs19
-rw-r--r--src/purgatory/sync/functions.rs69
-rw-r--r--src/purgatory/sync/loop.rs5
-rw-r--r--src/purgatory/sync/throttle.rs31
-rw-r--r--src/sync/mod.rs4
-rw-r--r--tests/common/git_server.rs98
-rw-r--r--tests/common/mock_relay.rs23
-rw-r--r--tests/common/purgatory_helpers.rs29
-rw-r--r--tests/common/relay.rs5
18 files changed, 249 insertions, 193 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index fbddb98..7502a52 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -134,11 +134,11 @@ pub async fn authorize_push(
134 e 134 e
135 ))); 135 )));
136 } 136 }
137 137
138 // Create placeholder for git-data-first scenario 138 // Create placeholder for git-data-first scenario
139 // This allows cleanup if the PR event never arrives 139 // This allows cleanup if the PR event never arrives
140 purgatory.add_pr_placeholder(event_id_hex.to_string(), new_oid.clone()); 140 purgatory.add_pr_placeholder(event_id_hex.to_string(), new_oid.clone());
141 141
142 debug!( 142 debug!(
143 "Created placeholder for {} - awaiting PR event (will expire in 30min if event doesn't arrive)", 143 "Created placeholder for {} - awaiting PR event (will expire in 30min if event doesn't arrive)",
144 event_id_hex 144 event_id_hex
diff --git a/src/git/process.rs b/src/git/process.rs
index d052c04..215b423 100644
--- a/src/git/process.rs
+++ b/src/git/process.rs
@@ -6,12 +6,15 @@
6//! - When events are released from purgatory (purgatory sync) 6//! - When events are released from purgatory (purgatory sync)
7//! - When git pushes trigger purgatory releases (receive-pack handler) 7//! - When git pushes trigger purgatory releases (receive-pack handler)
8 8
9use std::path::Path;
10use nostr_sdk::Event;
11use crate::git::authorization::{collect_authorized_maintainers, RepositoryData};
12use crate::git::sync::{align_repository_with_state, sync_pr_refs_to_tagged_owner_repos, copy_missing_oids_between_repos};
13use crate::git; 9use crate::git;
10use crate::git::authorization::{collect_authorized_maintainers, RepositoryData};
11use crate::git::sync::{
12 align_repository_with_state, copy_missing_oids_between_repos,
13 sync_pr_refs_to_tagged_owner_repos,
14};
14use crate::nostr::events::RepositoryState; 15use crate::nostr::events::RepositoryState;
16use nostr_sdk::Event;
17use std::path::Path;
15 18
16/// Result of processing a state event with git data 19/// Result of processing a state event with git data
17#[derive(Debug, Default, Clone)] 20#[derive(Debug, Default, Clone)]
@@ -68,19 +71,19 @@ pub fn process_state_with_git_data(
68 git_data_path: &Path, 71 git_data_path: &Path,
69) -> ProcessStateResult { 72) -> ProcessStateResult {
70 let mut result = ProcessStateResult::default(); 73 let mut result = ProcessStateResult::default();
71 74
72 let state_author = state.event.pubkey.to_hex(); 75 let state_author = state.event.pubkey.to_hex();
73 76
74 // Collect authorized maintainers per owner 77 // Collect authorized maintainers per owner
75 let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); 78 let by_owner = collect_authorized_maintainers(&db_repo_data.announcements);
76 79
77 // Step 1: Identify owner repos that the state event author is maintainer for 80 // Step 1: Identify owner repos that the state event author is maintainer for
78 let authorized_owners: Vec<&String> = by_owner 81 let authorized_owners: Vec<&String> = by_owner
79 .iter() 82 .iter()
80 .filter(|(_, maintainers)| maintainers.contains(&state_author)) 83 .filter(|(_, maintainers)| maintainers.contains(&state_author))
81 .map(|(owner, _)| owner) 84 .map(|(owner, _)| owner)
82 .collect(); 85 .collect();
83 86
84 if authorized_owners.is_empty() { 87 if authorized_owners.is_empty() {
85 tracing::debug!( 88 tracing::debug!(
86 identifier = %state.identifier, 89 identifier = %state.identifier,
@@ -89,18 +92,18 @@ pub fn process_state_with_git_data(
89 ); 92 );
90 return result; 93 return result;
91 } 94 }
92 95
93 // Process each owner repo that authorizes this state event author 96 // Process each owner repo that authorizes this state event author
94 for owner in &authorized_owners { 97 for owner in &authorized_owners {
95 let maintainers = by_owner.get(*owner).unwrap(); 98 let maintainers = by_owner.get(*owner).unwrap();
96 99
97 // Step 2: Check if this state event is the latest authorized for this owner 100 // Step 2: Check if this state event is the latest authorized for this owner
98 let is_latest = crate::git::sync::is_latest_authorized_state_public( 101 let is_latest = crate::git::sync::is_latest_authorized_state_public(
99 state, 102 state,
100 maintainers, 103 maintainers,
101 &db_repo_data.states, 104 &db_repo_data.states,
102 ); 105 );
103 106
104 if !is_latest { 107 if !is_latest {
105 tracing::debug!( 108 tracing::debug!(
106 identifier = %state.identifier, 109 identifier = %state.identifier,
@@ -109,7 +112,7 @@ pub fn process_state_with_git_data(
109 ); 112 );
110 continue; 113 continue;
111 } 114 }
112 115
113 // Find the announcement for this owner 116 // Find the announcement for this owner
114 let Some(announcement) = db_repo_data 117 let Some(announcement) = db_repo_data
115 .announcements 118 .announcements
@@ -118,9 +121,9 @@ pub fn process_state_with_git_data(
118 else { 121 else {
119 continue; 122 continue;
120 }; 123 };
121 124
122 let target_repo_path = git_data_path.join(announcement.repo_path()); 125 let target_repo_path = git_data_path.join(announcement.repo_path());
123 126
124 // Step 3: Check git repo exists for that owner 127 // Step 3: Check git repo exists for that owner
125 if !target_repo_path.exists() { 128 if !target_repo_path.exists() {
126 tracing::debug!( 129 tracing::debug!(
@@ -131,14 +134,12 @@ pub fn process_state_with_git_data(
131 ); 134 );
132 continue; 135 continue;
133 } 136 }
134 137
135 // Step 4: Copy all required OIDs to that repo (unless it's source_repo_path) 138 // Step 4: Copy all required OIDs to that repo (unless it's source_repo_path)
136 if target_repo_path != source_repo_path { 139 if target_repo_path != source_repo_path {
137 if let Err(e) = copy_missing_oids_between_repos( 140 if let Err(e) =
138 source_repo_path, 141 copy_missing_oids_between_repos(source_repo_path, &target_repo_path, state)
139 &target_repo_path, 142 {
140 state,
141 ) {
142 tracing::warn!( 143 tracing::warn!(
143 identifier = %state.identifier, 144 identifier = %state.identifier,
144 source = %source_repo_path.display(), 145 source = %source_repo_path.display(),
@@ -150,14 +151,14 @@ pub fn process_state_with_git_data(
150 continue; // Skip this owner repo 151 continue; // Skip this owner repo
151 } 152 }
152 } 153 }
153 154
154 // Step 5: Reset the git state in that repo to match the state event 155 // Step 5: Reset the git state in that repo to match the state event
155 let align_result = align_repository_with_state(&target_repo_path, state); 156 let align_result = align_repository_with_state(&target_repo_path, state);
156 result.repos_synced += 1; 157 result.repos_synced += 1;
157 result.refs_created += align_result.refs_created; 158 result.refs_created += align_result.refs_created;
158 result.refs_updated += align_result.refs_updated; 159 result.refs_updated += align_result.refs_updated;
159 result.refs_deleted += align_result.refs_deleted; 160 result.refs_deleted += align_result.refs_deleted;
160 161
161 tracing::info!( 162 tracing::info!(
162 identifier = %state.identifier, 163 identifier = %state.identifier,
163 owner = %owner, 164 owner = %owner,
@@ -169,7 +170,7 @@ pub fn process_state_with_git_data(
169 "Aligned repository with state" 170 "Aligned repository with state"
170 ); 171 );
171 } 172 }
172 173
173 result 174 result
174} 175}
175 176
@@ -205,13 +206,13 @@ pub fn process_pr_with_git_data(
205 source_owner_pubkey: &str, 206 source_owner_pubkey: &str,
206) -> ProcessPrResult { 207) -> ProcessPrResult {
207 let mut result = ProcessPrResult::default(); 208 let mut result = ProcessPrResult::default();
208 209
209 let event_id = event.id.to_hex(); 210 let event_id = event.id.to_hex();
210 211
211 // Sync PR ref to owner repos using tagged maintainer logic 212 // Sync PR ref to owner repos using tagged maintainer logic
212 let pr_refs = vec![(event_id.clone(), commit.to_string())]; 213 let pr_refs = vec![(event_id.clone(), commit.to_string())];
213 let pr_events = vec![event.clone()]; 214 let pr_events = vec![event.clone()];
214 215
215 let sync_result = sync_pr_refs_to_tagged_owner_repos( 216 let sync_result = sync_pr_refs_to_tagged_owner_repos(
216 source_repo_path, 217 source_repo_path,
217 &pr_refs, 218 &pr_refs,
@@ -222,13 +223,10 @@ pub fn process_pr_with_git_data(
222 ); 223 );
223 result.repos_synced += sync_result.repos_synced; 224 result.repos_synced += sync_result.repos_synced;
224 result.refs_created += sync_result.refs_created; 225 result.refs_created += sync_result.refs_created;
225 result.errors.extend( 226 result
226 sync_result 227 .errors
227 .errors 228 .extend(sync_result.errors.into_iter().map(|(_, e)| e));
228 .into_iter() 229
229 .map(|(_, e)| e),
230 );
231
232 // Create the ref in the source repo if it doesn't exist 230 // Create the ref in the source repo if it doesn't exist
233 let ref_name = format!("refs/nostr/{}", event_id); 231 let ref_name = format!("refs/nostr/{}", event_id);
234 if git::get_ref_commit(source_repo_path, &ref_name).is_none() { 232 if git::get_ref_commit(source_repo_path, &ref_name).is_none() {
@@ -250,6 +248,6 @@ pub fn process_pr_with_git_data(
250 ); 248 );
251 } 249 }
252 } 250 }
253 251
254 result 252 result
255} 253}
diff --git a/src/git/sync.rs b/src/git/sync.rs
index 5e2d3f2..06013a5 100644
--- a/src/git/sync.rs
+++ b/src/git/sync.rs
@@ -837,13 +837,27 @@ pub async fn process_newly_available_git_data(
837 ); 837 );
838 838
839 // Process state events from purgatory 839 // Process state events from purgatory
840 let state_result = 840 let state_result = process_purgatory_state_events(
841 process_purgatory_state_events(&identifier, source_repo_path, database, local_relay, purgatory, git_data_path).await; 841 &identifier,
842 source_repo_path,
843 database,
844 local_relay,
845 purgatory,
846 git_data_path,
847 )
848 .await;
842 result.merge(state_result); 849 result.merge(state_result);
843 850
844 // Process PR events from purgatory 851 // Process PR events from purgatory
845 let pr_result = 852 let pr_result = process_purgatory_pr_events(
846 process_purgatory_pr_events(&identifier, source_repo_path, database, local_relay, purgatory, git_data_path).await; 853 &identifier,
854 source_repo_path,
855 database,
856 local_relay,
857 purgatory,
858 git_data_path,
859 )
860 .await;
847 result.merge(pr_result); 861 result.merge(pr_result);
848 862
849 if result.released_any() { 863 if result.released_any() {
@@ -1113,7 +1127,9 @@ async fn process_purgatory_pr_events(
1113 error = %e, 1127 error = %e,
1114 "Failed to fetch repository data for PR events" 1128 "Failed to fetch repository data for PR events"
1115 ); 1129 );
1116 result.errors.push(format!("Failed to fetch repo data: {}", e)); 1130 result
1131 .errors
1132 .push(format!("Failed to fetch repo data: {}", e));
1117 return result; 1133 return result;
1118 } 1134 }
1119 }; 1135 };
@@ -1137,8 +1153,8 @@ async fn process_purgatory_pr_events(
1137 } 1153 }
1138 1154
1139 // Extract owner pubkey 1155 // Extract owner pubkey
1140 let owner_pubkey = extract_owner_from_repo_path(source_repo_path, git_data_path) 1156 let owner_pubkey =
1141 .unwrap_or_default(); 1157 extract_owner_from_repo_path(source_repo_path, git_data_path).unwrap_or_default();
1142 1158
1143 // Use unified processing function 1159 // Use unified processing function
1144 let process_result = crate::git::process::process_pr_with_git_data( 1160 let process_result = crate::git::process::process_pr_with_git_data(
@@ -1192,7 +1208,9 @@ async fn process_purgatory_pr_events(
1192 error = %e, 1208 error = %e,
1193 "Failed to save PR event to database" 1209 "Failed to save PR event to database"
1194 ); 1210 );
1195 result.errors.push(format!("Failed to save PR event: {}", e)); 1211 result
1212 .errors
1213 .push(format!("Failed to save PR event: {}", e));
1196 } 1214 }
1197 } 1215 }
1198 } 1216 }
@@ -1527,11 +1545,7 @@ mod tests {
1527 } 1545 }
1528 1546
1529 // Helper function to create a test state event with specific timestamp 1547 // Helper function to create a test state event with specific timestamp
1530 fn create_test_state_event( 1548 fn create_test_state_event(keys: &Keys, identifier: &str, created_at: u64) -> RepositoryState {
1531 keys: &Keys,
1532 identifier: &str,
1533 created_at: u64,
1534 ) -> RepositoryState {
1535 create_test_state_event_with_nonce(keys, identifier, created_at, "") 1549 create_test_state_event_with_nonce(keys, identifier, created_at, "")
1536 } 1550 }
1537 1551
diff --git a/src/main.rs b/src/main.rs
index 8b870dc..5e9e2d0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -146,7 +146,9 @@ async fn main() -> Result<()> {
146 throttle_manager.set_context(sync_ctx.clone()); 146 throttle_manager.set_context(sync_ctx.clone());
147 147
148 // Start the sync loop 148 // Start the sync loop
149 let _sync_loop_handle = purgatory.clone().start_sync_loop(sync_ctx, throttle_manager); 149 let _sync_loop_handle = purgatory
150 .clone()
151 .start_sync_loop(sync_ctx, throttle_manager);
150 info!("Purgatory sync loop started (1s interval)"); 152 info!("Purgatory sync loop started (1s interval)");
151 153
152 // Setup shutdown handler for purgatory cleanup 154 // Setup shutdown handler for purgatory cleanup
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 0e5c18a..81f7fbb 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -162,7 +162,11 @@ impl Nip34WritePolicy {
162 match self.state_policy.validate(event) { 162 match self.state_policy.validate(event) {
163 StateResult::Accept => { 163 StateResult::Accept => {
164 // Process state alignment asynchronously 164 // Process state alignment asynchronously
165 match self.state_policy.process_state_event(event, is_synced).await { 165 match self
166 .state_policy
167 .process_state_event(event, is_synced)
168 .await
169 {
166 Ok(poilicy_result) => poilicy_result, 170 Ok(poilicy_result) => poilicy_result,
167 Err(e) => { 171 Err(e) => {
168 tracing::warn!("Failed to process state event {}: {}", event_id_str, e); 172 tracing::warn!("Failed to process state event {}: {}", event_id_str, e);
@@ -247,7 +251,8 @@ impl Nip34WritePolicy {
247 ); 251 );
248 return WritePolicyResult::Reject { 252 return WritePolicyResult::Reject {
249 status: false, 253 status: false,
250 message: "invalid: previously expired from purgatory without git data".into(), 254 message: "invalid: previously expired from purgatory without git data"
255 .into(),
251 }; 256 };
252 } 257 }
253 258
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
index 9942a6a..00e09c3 100644
--- a/src/nostr/policy/pr_event.rs
+++ b/src/nostr/policy/pr_event.rs
@@ -237,5 +237,4 @@ impl PrEventPolicy {
237 237
238 Ok(repo_paths) 238 Ok(repo_paths)
239 } 239 }
240
241} 240}
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
index 7bbb379..acb76a3 100644
--- a/src/nostr/policy/state.rs
+++ b/src/nostr/policy/state.rs
@@ -9,8 +9,8 @@ use nostr_relay_builder::builder::WritePolicyResult;
9use nostr_relay_builder::prelude::Event; 9use nostr_relay_builder::prelude::Event;
10 10
11use super::PolicyContext; 11use super::PolicyContext;
12use crate::git::authorization::fetch_repository_data;
13use crate::git; 12use crate::git;
13use crate::git::authorization::fetch_repository_data;
14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; 14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState};
15 15
16/// Result of state policy evaluation 16/// Result of state policy evaluation
@@ -48,7 +48,11 @@ impl StatePolicy {
48 /// * `is_synced` - True if this event came from proactive sync (vs user-submitted) 48 /// * `is_synced` - True if this event came from proactive sync (vs user-submitted)
49 /// 49 ///
50 /// Returns the true if git data already availale or false if added to purgatory 50 /// Returns the true if git data already availale or false if added to purgatory
51 pub async fn process_state_event(&self, event: &Event, is_synced: bool) -> Result<WritePolicyResult> { 51 pub async fn process_state_event(
52 &self,
53 event: &Event,
54 is_synced: bool,
55 ) -> Result<WritePolicyResult> {
52 // Parse state to get HEAD and branch info 56 // Parse state to get HEAD and branch info
53 let state = 57 let state =
54 RepositoryState::from_event(event.clone()).context("Failed to parse state event")?; 58 RepositoryState::from_event(event.clone()).context("Failed to parse state event")?;
@@ -155,8 +159,6 @@ impl StatePolicy {
155 }) 159 })
156 } 160 }
157 } 161 }
158
159
160} 162}
161 163
162fn find_repo_with_git_data( 164fn find_repo_with_git_data(
diff --git a/src/purgatory/helpers.rs b/src/purgatory/helpers.rs
index 93dc378..193ef99 100644
--- a/src/purgatory/helpers.rs
+++ b/src/purgatory/helpers.rs
@@ -515,7 +515,7 @@ mod tests {
515 515
516 // Create a working repo to generate a commit 516 // Create a working repo to generate a commit
517 let work_dir = tempfile::tempdir().unwrap(); 517 let work_dir = tempfile::tempdir().unwrap();
518 518
519 Command::new("git") 519 Command::new("git")
520 .args(["init"]) 520 .args(["init"])
521 .current_dir(work_dir.path()) 521 .current_dir(work_dir.path())
@@ -585,7 +585,7 @@ mod tests {
585 use std::process::Command; 585 use std::process::Command;
586 586
587 let temp_dir = tempfile::tempdir().unwrap(); 587 let temp_dir = tempfile::tempdir().unwrap();
588 588
589 Command::new("git") 589 Command::new("git")
590 .args(["init", "--bare"]) 590 .args(["init", "--bare"])
591 .current_dir(temp_dir.path()) 591 .current_dir(temp_dir.path())
@@ -603,10 +603,7 @@ mod tests {
603 let commit_hash = commit_hash.expect("Should have a commit"); 603 let commit_hash = commit_hash.expect("Should have a commit");
604 604
605 // Create a state event referencing that commit 605 // Create a state event referencing that commit
606 let event = create_test_state_event( 606 let event = create_test_state_event("test-repo", vec![("refs/heads/main", &commit_hash)]);
607 "test-repo",
608 vec![("refs/heads/main", &commit_hash)],
609 );
610 607
611 // Should return true since the OID exists 608 // Should return true since the OID exists
612 assert!(can_apply_state(&event, repo_path)); 609 assert!(can_apply_state(&event, repo_path));
@@ -621,7 +618,10 @@ mod tests {
621 // Create a state event referencing a non-existent commit 618 // Create a state event referencing a non-existent commit
622 let event = create_test_state_event( 619 let event = create_test_state_event(
623 "test-repo", 620 "test-repo",
624 vec![("refs/heads/main", "0000000000000000000000000000000000000000")], 621 vec![(
622 "refs/heads/main",
623 "0000000000000000000000000000000000000000",
624 )],
625 ); 625 );
626 626
627 // Should return false since the OID doesn't exist 627 // Should return false since the OID doesn't exist
@@ -655,8 +655,8 @@ mod tests {
655 let event = create_test_state_event( 655 let event = create_test_state_event(
656 "test-repo", 656 "test-repo",
657 vec![ 657 vec![
658 ("refs/heads/main", &commit_hash), // exists 658 ("refs/heads/main", &commit_hash), // exists
659 ("refs/heads/dev", "0000000000000000000000000000000000000000"), // doesn't exist 659 ("refs/heads/dev", "0000000000000000000000000000000000000000"), // doesn't exist
660 ], 660 ],
661 ); 661 );
662 662
@@ -687,8 +687,8 @@ mod tests {
687 let event = create_test_state_event( 687 let event = create_test_state_event(
688 "test-repo", 688 "test-repo",
689 vec![ 689 vec![
690 ("refs/heads/main", &commit_hash), // real OID that exists 690 ("refs/heads/main", &commit_hash), // real OID that exists
691 ("refs/heads/alias", "ref: refs/heads/main"), // symbolic ref 691 ("refs/heads/alias", "ref: refs/heads/main"), // symbolic ref
692 ], 692 ],
693 ); 693 );
694 694
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index fe0a439..20df19b 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -1134,7 +1134,8 @@ fn test_cleanup_expired_events() {
1134 1134
1135 // Manually set event1's expiry time to be old 1135 // Manually set event1's expiry time to be old
1136 if let Some(mut entry) = purgatory.expired_events.get_mut(&event1_id) { 1136 if let Some(mut entry) = purgatory.expired_events.get_mut(&event1_id) {
1137 *entry.value_mut() = Instant::now() - Duration::from_secs(8 * 24 * 3600); // 8 days ago 1137 *entry.value_mut() = Instant::now() - Duration::from_secs(8 * 24 * 3600);
1138 // 8 days ago
1138 } 1139 }
1139 1140
1140 // Clean up expired events older than 7 days 1141 // Clean up expired events older than 7 days
diff --git a/src/purgatory/sync/context.rs b/src/purgatory/sync/context.rs
index 2922f10..9e195c7 100644
--- a/src/purgatory/sync/context.rs
+++ b/src/purgatory/sync/context.rs
@@ -119,12 +119,8 @@ pub trait SyncContext: Send + Sync {
119 /// 119 ///
120 /// # Returns 120 /// # Returns
121 /// List of OIDs that were successfully fetched 121 /// List of OIDs that were successfully fetched
122 async fn fetch_oids( 122 async fn fetch_oids(&self, repo_path: &Path, url: &str, oids: &[String])
123 &self, 123 -> Result<Vec<String>>;
124 repo_path: &Path,
125 url: &str,
126 oids: &[String],
127 ) -> Result<Vec<String>>;
128 124
129 /// Process newly available git data. 125 /// Process newly available git data.
130 /// 126 ///
@@ -368,10 +364,7 @@ impl SyncContext for RealSyncContext {
368 .cloned() 364 .cloned()
369 .collect(); 365 .collect();
370 366
371 debug!( 367 debug!(fetched_count = fetched.len(), "Successfully fetched OIDs");
372 fetched_count = fetched.len(),
373 "Successfully fetched OIDs"
374 );
375 368
376 fetched 369 fetched
377 } 370 }
@@ -702,11 +695,7 @@ pub mod mock {
702 } 695 }
703 696
704 // Get OIDs this URL can provide 697 // Get OIDs this URL can provide
705 let provides = self 698 let provides = self.url_provides_oids.get(url).cloned().unwrap_or_default();
706 .url_provides_oids
707 .get(url)
708 .cloned()
709 .unwrap_or_default();
710 699
711 // Find which requested OIDs this URL can provide 700 // Find which requested OIDs this URL can provide
712 let fetched: Vec<String> = oids 701 let fetched: Vec<String> = oids
diff --git a/src/purgatory/sync/functions.rs b/src/purgatory/sync/functions.rs
index bb7c0b9..370990e 100644
--- a/src/purgatory/sync/functions.rs
+++ b/src/purgatory/sync/functions.rs
@@ -32,15 +32,17 @@ use super::throttle::ThrottleManager;
32fn extract_domain(url: &str) -> Option<String> { 32fn extract_domain(url: &str) -> Option<String> {
33 // Simple URL parsing for HTTP(S) URLs 33 // Simple URL parsing for HTTP(S) URLs
34 // Format: scheme://[user@]host[:port]/path 34 // Format: scheme://[user@]host[:port]/path
35 let url = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?; 35 let url = url
36 36 .strip_prefix("https://")
37 .or_else(|| url.strip_prefix("http://"))?;
38
37 // Remove user info if present (e.g., "user@host" -> "host") 39 // Remove user info if present (e.g., "user@host" -> "host")
38 let url = url.split('@').next_back()?; 40 let url = url.split('@').next_back()?;
39 41
40 // Extract host (before first '/' or ':') 42 // Extract host (before first '/' or ':')
41 let host = url.split('/').next()?; 43 let host = url.split('/').next()?;
42 let host = host.split(':').next()?; 44 let host = host.split(':').next()?;
43 45
44 if host.is_empty() { 46 if host.is_empty() {
45 None 47 None
46 } else { 48 } else {
@@ -112,17 +114,17 @@ pub async fn sync_identifier_next_url<C: SyncContext + ?Sized>(
112 114
113 // 4. Collect clone URLs from announcements AND PR events in purgatory 115 // 4. Collect clone URLs from announcements AND PR events in purgatory
114 let our_domain = ctx.our_domain(); 116 let our_domain = ctx.our_domain();
115 117
116 // Get clone URLs from repository announcements 118 // Get clone URLs from repository announcements
117 let announcement_urls: HashSet<String> = repo_data 119 let announcement_urls: HashSet<String> = repo_data
118 .announcements 120 .announcements
119 .iter() 121 .iter()
120 .flat_map(|a| a.clone_urls.iter().cloned()) 122 .flat_map(|a| a.clone_urls.iter().cloned())
121 .collect(); 123 .collect();
122 124
123 // Get clone URLs from PR events in purgatory 125 // Get clone URLs from PR events in purgatory
124 let pr_urls = ctx.collect_pr_clone_urls(identifier); 126 let pr_urls = ctx.collect_pr_clone_urls(identifier);
125 127
126 // Merge and filter out our domain 128 // Merge and filter out our domain
127 let all_urls: HashSet<String> = announcement_urls 129 let all_urls: HashSet<String> = announcement_urls
128 .union(&pr_urls) 130 .union(&pr_urls)
@@ -151,11 +153,9 @@ pub async fn sync_identifier_next_url<C: SyncContext + ?Sized>(
151 match domain { 153 match domain {
152 Some(specific_domain) => { 154 Some(specific_domain) => {
153 // Only look at URLs from this specific domain 155 // Only look at URLs from this specific domain
154 urls_by_domain.get(specific_domain).and_then(|urls| { 156 urls_by_domain
155 urls.iter() 157 .get(specific_domain)
156 .find(|url| !tried_urls.contains(*url)) 158 .and_then(|urls| urls.iter().find(|url| !tried_urls.contains(*url)).cloned())
157 .cloned()
158 })
159 } 159 }
160 None => { 160 None => {
161 // Try any non-throttled domain 161 // Try any non-throttled domain
@@ -217,17 +217,17 @@ pub async fn get_throttled_domains_with_untried_urls<C: SyncContext + ?Sized>(
217 }; 217 };
218 218
219 let our_domain = ctx.our_domain(); 219 let our_domain = ctx.our_domain();
220 220
221 // Get clone URLs from repository announcements 221 // Get clone URLs from repository announcements
222 let announcement_urls: HashSet<String> = repo_data 222 let announcement_urls: HashSet<String> = repo_data
223 .announcements 223 .announcements
224 .iter() 224 .iter()
225 .flat_map(|a| a.clone_urls.iter().cloned()) 225 .flat_map(|a| a.clone_urls.iter().cloned())
226 .collect(); 226 .collect();
227 227
228 // Get clone URLs from PR events in purgatory 228 // Get clone URLs from PR events in purgatory
229 let pr_urls = ctx.collect_pr_clone_urls(identifier); 229 let pr_urls = ctx.collect_pr_clone_urls(identifier);
230 230
231 // Merge and filter out our domain 231 // Merge and filter out our domain
232 let all_urls: HashSet<String> = announcement_urls 232 let all_urls: HashSet<String> = announcement_urls
233 .union(&pr_urls) 233 .union(&pr_urls)
@@ -766,9 +766,13 @@ mod tests {
766 let mut tried_urls = HashSet::new(); 766 let mut tried_urls = HashSet::new();
767 tried_urls.insert("https://github.com/foo/bar.git".to_string()); 767 tried_urls.insert("https://github.com/foo/bar.git".to_string());
768 768
769 let throttled = 769 let throttled = get_throttled_domains_with_untried_urls(
770 get_throttled_domains_with_untried_urls(&mock, "test-repo", &tried_urls, &throttle_manager) 770 &mock,
771 .await; 771 "test-repo",
772 &tried_urls,
773 &throttle_manager,
774 )
775 .await;
772 776
773 // Should only include gitlab.com (throttled with untried URLs) 777 // Should only include gitlab.com (throttled with untried URLs)
774 // github.com is throttled but URL was tried 778 // github.com is throttled but URL was tried
@@ -885,11 +889,10 @@ mod tests {
885 #[tokio::test] 889 #[tokio::test]
886 async fn test_collect_pr_clone_urls_returns_configured_urls() { 890 async fn test_collect_pr_clone_urls_returns_configured_urls() {
887 // Test that MockSyncContext returns configured PR clone URLs 891 // Test that MockSyncContext returns configured PR clone URLs
888 let mock = MockSyncContext::new() 892 let mock = MockSyncContext::new().with_pr_clone_urls(&[
889 .with_pr_clone_urls(&[ 893 "https://pr-server.com/fork.git",
890 "https://pr-server.com/fork.git", 894 "https://another-server.com/fork.git",
891 "https://another-server.com/fork.git", 895 ]);
892 ]);
893 896
894 let pr_urls = mock.collect_pr_clone_urls("test-repo"); 897 let pr_urls = mock.collect_pr_clone_urls("test-repo");
895 898
@@ -945,7 +948,7 @@ mod tests {
945 .with_urls(&["https://github.com/owner/repo.git"]) 948 .with_urls(&["https://github.com/owner/repo.git"])
946 .with_pr_clone_urls(&[ 949 .with_pr_clone_urls(&[
947 "https://our-relay.com/fork.git", // Should be filtered 950 "https://our-relay.com/fork.git", // Should be filtered
948 "https://external.com/fork.git", // Should be included 951 "https://external.com/fork.git", // Should be included
949 ]) 952 ])
950 .with_our_domain("our-relay.com") 953 .with_our_domain("our-relay.com")
951 .with_needed_oids(&["abc123"]) 954 .with_needed_oids(&["abc123"])
@@ -957,8 +960,7 @@ mod tests {
957 // Collect all available URLs 960 // Collect all available URLs
958 let mut available_urls = Vec::new(); 961 let mut available_urls = Vec::new();
959 while let Some(url) = 962 while let Some(url) =
960 sync_identifier_next_url(&mock, "test-repo", None, &tried_urls, &throttle_manager) 963 sync_identifier_next_url(&mock, "test-repo", None, &tried_urls, &throttle_manager).await
961 .await
962 { 964 {
963 available_urls.push(url.clone()); 965 available_urls.push(url.clone());
964 tried_urls.insert(url); 966 tried_urls.insert(url);
@@ -1006,16 +1008,17 @@ mod tests {
1006 1008
1007 let tried_urls = HashSet::new(); 1009 let tried_urls = HashSet::new();
1008 1010
1009 let throttled = 1011 let throttled = get_throttled_domains_with_untried_urls(
1010 get_throttled_domains_with_untried_urls(&mock, "test-repo", &tried_urls, &throttle_manager) 1012 &mock,
1011 .await; 1013 "test-repo",
1014 &tried_urls,
1015 &throttle_manager,
1016 )
1017 .await;
1012 1018
1013 // Should include both throttled domains 1019 // Should include both throttled domains
1014 let domains: Vec<&str> = throttled.iter().map(|t| t.domain.as_str()).collect(); 1020 let domains: Vec<&str> = throttled.iter().map(|t| t.domain.as_str()).collect();
1015 assert!( 1021 assert!(domains.contains(&"github.com"), "Should include github.com");
1016 domains.contains(&"github.com"),
1017 "Should include github.com"
1018 );
1019 assert!( 1022 assert!(
1020 domains.contains(&"pr-server.com"), 1023 domains.contains(&"pr-server.com"),
1021 "Should include pr-server.com from PR clone URLs" 1024 "Should include pr-server.com from PR clone URLs"
diff --git a/src/purgatory/sync/loop.rs b/src/purgatory/sync/loop.rs
index ebca766..92e0594 100644
--- a/src/purgatory/sync/loop.rs
+++ b/src/purgatory/sync/loop.rs
@@ -62,7 +62,10 @@ impl Purgatory {
62 ctx: Arc<dyn SyncContext>, 62 ctx: Arc<dyn SyncContext>,
63 throttle_manager: Arc<ThrottleManager>, 63 throttle_manager: Arc<ThrottleManager>,
64 ) -> JoinHandle<()> { 64 ) -> JoinHandle<()> {
65 info!("Starting purgatory sync loop (interval: {:?})", SYNC_LOOP_INTERVAL); 65 info!(
66 "Starting purgatory sync loop (interval: {:?})",
67 SYNC_LOOP_INTERVAL
68 );
66 69
67 tokio::spawn(async move { 70 tokio::spawn(async move {
68 let mut interval = tokio::time::interval(SYNC_LOOP_INTERVAL); 71 let mut interval = tokio::time::interval(SYNC_LOOP_INTERVAL);
diff --git a/src/purgatory/sync/throttle.rs b/src/purgatory/sync/throttle.rs
index e6efe1f..ad6e8ea 100644
--- a/src/purgatory/sync/throttle.rs
+++ b/src/purgatory/sync/throttle.rs
@@ -316,15 +316,13 @@ impl ThrottleManager {
316 } 316 }
317 317
318 // Create new throttle 318 // Create new throttle
319 self.throttles 319 self.throttles.entry(domain.to_string()).or_insert_with(|| {
320 .entry(domain.to_string()) 320 Mutex::new(DomainThrottle::new(
321 .or_insert_with(|| { 321 domain.to_string(),
322 Mutex::new(DomainThrottle::new( 322 self.max_concurrent_per_domain,
323 domain.to_string(), 323 self.max_per_minute_per_domain,
324 self.max_concurrent_per_domain, 324 ))
325 self.max_per_minute_per_domain, 325 });
326 ))
327 });
328 326
329 // Return the entry (we know it exists now) 327 // Return the entry (we know it exists now)
330 self.throttles.get(domain).unwrap() 328 self.throttles.get(domain).unwrap()
@@ -438,7 +436,9 @@ impl ThrottleManager {
438 let domain = domain.to_string(); 436 let domain = domain.to_string();
439 437
440 tokio::spawn(async move { 438 tokio::spawn(async move {
441 manager.process_queued_identifier(&domain, &identifier).await; 439 manager
440 .process_queued_identifier(&domain, &identifier)
441 .await;
442 }); 442 });
443 } 443 }
444 } 444 }
@@ -480,14 +480,9 @@ impl ThrottleManager {
480 }; 480 };
481 481
482 // Get next URL for this identifier on this specific domain 482 // Get next URL for this identifier on this specific domain
483 let url = sync_identifier_next_url( 483 let url =
484 ctx.as_ref(), 484 sync_identifier_next_url(ctx.as_ref(), identifier, Some(domain), &tried_urls, self)
485 identifier, 485 .await;
486 Some(domain),
487 &tried_urls,
488 self,
489 )
490 .await;
491 486
492 match url { 487 match url {
493 Some(url) => { 488 Some(url) => {
diff --git a/src/sync/mod.rs b/src/sync/mod.rs
index 7d60ea4..fa44ab1 100644
--- a/src/sync/mod.rs
+++ b/src/sync/mod.rs
@@ -1069,7 +1069,9 @@ impl SyncManager {
1069 } 1069 }
1070 // PR events (kind 1617/1618) - extract identifier from 'a' tag 1070 // PR events (kind 1617/1618) - extract identifier from 'a' tag
1071 else if event.kind.as_u16() == 1617 || event.kind.as_u16() == 1618 { 1071 else if event.kind.as_u16() == 1617 || event.kind.as_u16() == 1618 {
1072 if let Some(identifier) = crate::git::sync::extract_identifier_from_pr_event(&event) { 1072 if let Some(identifier) =
1073 crate::git::sync::extract_identifier_from_pr_event(&event)
1074 {
1073 tracing::debug!( 1075 tracing::debug!(
1074 event_id = %event.id, 1076 event_id = %event.id,
1075 identifier = %identifier, 1077 identifier = %identifier,
diff --git a/tests/common/git_server.rs b/tests/common/git_server.rs
index adf66b5..d0d727e 100644
--- a/tests/common/git_server.rs
+++ b/tests/common/git_server.rs
@@ -301,7 +301,10 @@ fn find_free_port() -> u16 {
301 use std::net::TcpListener; 301 use std::net::TcpListener;
302 302
303 let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); 303 let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
304 let port = listener.local_addr().expect("Failed to get local addr").port(); 304 let port = listener
305 .local_addr()
306 .expect("Failed to get local addr")
307 .port();
305 drop(listener); 308 drop(listener);
306 port 309 port
307} 310}
@@ -320,7 +323,10 @@ async fn wait_for_server_ready(port: u16) {
320 } 323 }
321 Err(_) => { 324 Err(_) => {
322 if attempt == max_attempts - 1 { 325 if attempt == max_attempts - 1 {
323 panic!("SimpleGitServer failed to start after {} attempts", max_attempts); 326 panic!(
327 "SimpleGitServer failed to start after {} attempts",
328 max_attempts
329 );
324 } 330 }
325 tokio::time::sleep(delay).await; 331 tokio::time::sleep(delay).await;
326 } 332 }
@@ -366,10 +372,13 @@ mod tests {
366 .await 372 .await
367 .expect("Failed to fetch info/refs"); 373 .expect("Failed to fetch info/refs");
368 374
369 assert!(response.status().is_success(), "info/refs should be accessible"); 375 assert!(
376 response.status().is_success(),
377 "info/refs should be accessible"
378 );
370 379
371 let body = response.text().await.expect("Failed to read response body"); 380 let body = response.text().await.expect("Failed to read response body");
372 381
373 // Should contain at least one ref (HEAD or refs/heads/main) 382 // Should contain at least one ref (HEAD or refs/heads/main)
374 assert!( 383 assert!(
375 body.contains("refs/heads/main") || body.contains("HEAD"), 384 body.contains("refs/heads/main") || body.contains("HEAD"),
@@ -404,7 +413,7 @@ mod tests {
404 ); 413 );
405 414
406 let stdout = String::from_utf8_lossy(&output.stdout); 415 let stdout = String::from_utf8_lossy(&output.stdout);
407 416
408 // Should list the main branch with the correct commit 417 // Should list the main branch with the correct commit
409 assert!( 418 assert!(
410 stdout.contains(&commit_hash), 419 stdout.contains(&commit_hash),
@@ -433,7 +442,7 @@ mod tests {
433 442
434 // Create a destination repo to fetch into 443 // Create a destination repo to fetch into
435 let dest_dir = tempfile::tempdir().expect("Failed to create dest dir"); 444 let dest_dir = tempfile::tempdir().expect("Failed to create dest dir");
436 445
437 // Initialize empty repo (using tokio::process::Command) 446 // Initialize empty repo (using tokio::process::Command)
438 let output = tokio::process::Command::new("git") 447 let output = tokio::process::Command::new("git")
439 .args(["init"]) 448 .args(["init"])
@@ -487,14 +496,23 @@ mod tests {
487 #[test] 496 #[test]
488 fn test_is_safe_path_blocks_traversal() { 497 fn test_is_safe_path_blocks_traversal() {
489 let repo_path = Path::new("/tmp/repo"); 498 let repo_path = Path::new("/tmp/repo");
490 499
491 // Safe paths 500 // Safe paths
492 assert!(is_safe_path(Path::new("/tmp/repo/info/refs"), repo_path)); 501 assert!(is_safe_path(Path::new("/tmp/repo/info/refs"), repo_path));
493 assert!(is_safe_path(Path::new("/tmp/repo/objects/pack/file.pack"), repo_path)); 502 assert!(is_safe_path(
494 503 Path::new("/tmp/repo/objects/pack/file.pack"),
504 repo_path
505 ));
506
495 // Unsafe paths (path traversal) 507 // Unsafe paths (path traversal)
496 assert!(!is_safe_path(Path::new("/tmp/repo/../etc/passwd"), repo_path)); 508 assert!(!is_safe_path(
497 assert!(!is_safe_path(Path::new("/tmp/repo/../../etc/passwd"), repo_path)); 509 Path::new("/tmp/repo/../etc/passwd"),
510 repo_path
511 ));
512 assert!(!is_safe_path(
513 Path::new("/tmp/repo/../../etc/passwd"),
514 repo_path
515 ));
498 } 516 }
499} 517}
500 518
@@ -563,17 +581,19 @@ impl SmartGitServer {
563 } 581 }
564 582
565 // 3. Create and bind listener (eliminates port race condition) 583 // 3. Create and bind listener (eliminates port race condition)
566 let std_listener = std::net::TcpListener::bind("127.0.0.1:0") 584 let std_listener =
567 .expect("Failed to bind to random port"); 585 std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
568 let port = std_listener.local_addr() 586 let port = std_listener
587 .local_addr()
569 .expect("Failed to get local addr") 588 .expect("Failed to get local addr")
570 .port(); 589 .port();
571 590
572 // Convert to tokio listener (keeps port bound) 591 // Convert to tokio listener (keeps port bound)
573 std_listener.set_nonblocking(true) 592 std_listener
593 .set_nonblocking(true)
574 .expect("Failed to set non-blocking"); 594 .expect("Failed to set non-blocking");
575 let listener = TcpListener::from_std(std_listener) 595 let listener =
576 .expect("Failed to convert to tokio listener"); 596 TcpListener::from_std(std_listener).expect("Failed to convert to tokio listener");
577 597
578 // 4. Create shutdown channel 598 // 4. Create shutdown channel
579 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); 599 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
@@ -690,15 +710,13 @@ async fn handle_smart_request(
690 // Route: GET /info/refs?service=git-upload-pack 710 // Route: GET /info/refs?service=git-upload-pack
691 if method == hyper::Method::GET && path.ends_with("/info/refs") { 711 if method == hyper::Method::GET && path.ends_with("/info/refs") {
692 // Parse service from query string 712 // Parse service from query string
693 let service = query 713 let service = query.split('&').find_map(|param| {
694 .split('&') 714 let mut parts = param.splitn(2, '=');
695 .find_map(|param| { 715 match (parts.next(), parts.next()) {
696 let mut parts = param.splitn(2, '='); 716 (Some("service"), Some(svc)) => Some(svc),
697 match (parts.next(), parts.next()) { 717 _ => None,
698 (Some("service"), Some(svc)) => Some(svc), 718 }
699 _ => None, 719 });
700 }
701 });
702 720
703 match service { 721 match service {
704 Some("git-upload-pack") => { 722 Some("git-upload-pack") => {
@@ -714,7 +732,9 @@ async fn handle_smart_request(
714 _ => { 732 _ => {
715 return Ok(Response::builder() 733 return Ok(Response::builder()
716 .status(StatusCode::BAD_REQUEST) 734 .status(StatusCode::BAD_REQUEST)
717 .body(Full::new(Bytes::from("Missing or invalid service parameter"))) 735 .body(Full::new(Bytes::from(
736 "Missing or invalid service parameter",
737 )))
718 .unwrap()); 738 .unwrap());
719 } 739 }
720 } 740 }
@@ -740,8 +760,8 @@ async fn handle_info_refs_upload_pack(
740 git_protocol_version: Option<&str>, 760 git_protocol_version: Option<&str>,
741) -> Result<Response<Full<Bytes>>, hyper::Error> { 761) -> Result<Response<Full<Bytes>>, hyper::Error> {
742 use std::process::Stdio; 762 use std::process::Stdio;
743 use tokio::process::Command as TokioCommand;
744 use tokio::io::AsyncReadExt; 763 use tokio::io::AsyncReadExt;
764 use tokio::process::Command as TokioCommand;
745 765
746 // Spawn git upload-pack --advertise-refs 766 // Spawn git upload-pack --advertise-refs
747 let mut cmd = TokioCommand::new("git"); 767 let mut cmd = TokioCommand::new("git");
@@ -763,8 +783,7 @@ async fn handle_info_refs_upload_pack(
763 .stdout(Stdio::piped()) 783 .stdout(Stdio::piped())
764 .stderr(Stdio::piped()); 784 .stderr(Stdio::piped());
765 785
766 let mut child = match cmd.spawn() 786 let mut child = match cmd.spawn() {
767 {
768 Ok(child) => child, 787 Ok(child) => child,
769 Err(e) => { 788 Err(e) => {
770 eprintln!("Failed to spawn git upload-pack: {}", e); 789 eprintln!("Failed to spawn git upload-pack: {}", e);
@@ -800,7 +819,7 @@ async fn handle_info_refs_upload_pack(
800 let len = service_line.len() + 4; 819 let len = service_line.len() + 4;
801 response_body.extend_from_slice(format!("{:04x}", len).as_bytes()); 820 response_body.extend_from_slice(format!("{:04x}", len).as_bytes());
802 response_body.extend_from_slice(service_line.as_bytes()); 821 response_body.extend_from_slice(service_line.as_bytes());
803 822
804 // Flush packet 823 // Flush packet
805 response_body.extend_from_slice(b"0000"); 824 response_body.extend_from_slice(b"0000");
806 825
@@ -809,7 +828,10 @@ async fn handle_info_refs_upload_pack(
809 828
810 Ok(Response::builder() 829 Ok(Response::builder()
811 .status(StatusCode::OK) 830 .status(StatusCode::OK)
812 .header("Content-Type", "application/x-git-upload-pack-advertisement") 831 .header(
832 "Content-Type",
833 "application/x-git-upload-pack-advertisement",
834 )
813 .header("Cache-Control", "no-cache") 835 .header("Cache-Control", "no-cache")
814 .body(Full::new(Bytes::from(response_body))) 836 .body(Full::new(Bytes::from(response_body)))
815 .unwrap()) 837 .unwrap())
@@ -850,8 +872,7 @@ async fn handle_upload_pack(
850 .stdout(Stdio::piped()) 872 .stdout(Stdio::piped())
851 .stderr(Stdio::piped()); 873 .stderr(Stdio::piped());
852 874
853 let mut child = match cmd.spawn() 875 let mut child = match cmd.spawn() {
854 {
855 Ok(child) => child, 876 Ok(child) => child,
856 Err(e) => { 877 Err(e) => {
857 eprintln!("Failed to spawn git upload-pack: {}", e); 878 eprintln!("Failed to spawn git upload-pack: {}", e);
@@ -957,7 +978,10 @@ mod smart_git_server_tests {
957 content_type 978 content_type
958 ); 979 );
959 980
960 let body = response.bytes().await.expect("Failed to read response body"); 981 let body = response
982 .bytes()
983 .await
984 .expect("Failed to read response body");
961 985
962 // Should start with service advertisement pkt-line 986 // Should start with service advertisement pkt-line
963 let body_str = String::from_utf8_lossy(&body); 987 let body_str = String::from_utf8_lossy(&body);
@@ -1077,7 +1101,7 @@ mod smart_git_server_tests {
1077 #[tokio::test] 1101 #[tokio::test]
1078 async fn test_smart_git_server_shallow_fetch() { 1102 async fn test_smart_git_server_shallow_fetch() {
1079 // This is the KEY test - shallow fetch requires smart HTTP protocol 1103 // This is the KEY test - shallow fetch requires smart HTTP protocol
1080 1104
1081 // Create a source repo with a commit 1105 // Create a source repo with a commit
1082 let source_dir = tempfile::tempdir().expect("Failed to create source dir"); 1106 let source_dir = tempfile::tempdir().expect("Failed to create source dir");
1083 let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest) 1107 let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest)
diff --git a/tests/common/mock_relay.rs b/tests/common/mock_relay.rs
index b6376a7..e81c453 100644
--- a/tests/common/mock_relay.rs
+++ b/tests/common/mock_relay.rs
@@ -73,8 +73,8 @@ impl MockRelay {
73 /// in an in-memory database. 73 /// in an in-memory database.
74 pub async fn start() -> Self { 74 pub async fn start() -> Self {
75 // Create and bind listener (eliminates port race condition) 75 // Create and bind listener (eliminates port race condition)
76 let std_listener = std::net::TcpListener::bind("127.0.0.1:0") 76 let std_listener =
77 .expect("Failed to bind to random port"); 77 std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
78 let port = std_listener 78 let port = std_listener
79 .local_addr() 79 .local_addr()
80 .expect("Failed to get local addr") 80 .expect("Failed to get local addr")
@@ -84,8 +84,8 @@ impl MockRelay {
84 std_listener 84 std_listener
85 .set_nonblocking(true) 85 .set_nonblocking(true)
86 .expect("Failed to set non-blocking"); 86 .expect("Failed to set non-blocking");
87 let listener = TcpListener::from_std(std_listener) 87 let listener =
88 .expect("Failed to convert to tokio listener"); 88 TcpListener::from_std(std_listener).expect("Failed to convert to tokio listener");
89 89
90 Self::start_with_listener(listener, port).await 90 Self::start_with_listener(listener, port).await
91 } 91 }
@@ -258,7 +258,10 @@ fn derive_accept_key(request_key: &[u8]) -> String {
258 engine.input(request_key); 258 engine.input(request_key);
259 engine.input(WS_GUID); 259 engine.input(WS_GUID);
260 let hash = Sha1Hash::from_engine(engine); 260 let hash = Sha1Hash::from_engine(engine);
261 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash.as_byte_array()) 261 base64::Engine::encode(
262 &base64::engine::general_purpose::STANDARD,
263 hash.as_byte_array(),
264 )
262} 265}
263 266
264/// Wait for the server to be ready to accept connections. 267/// Wait for the server to be ready to accept connections.
@@ -275,10 +278,7 @@ async fn wait_for_server_ready(port: u16) {
275 } 278 }
276 Err(_) => { 279 Err(_) => {
277 if attempt == max_attempts - 1 { 280 if attempt == max_attempts - 1 {
278 panic!( 281 panic!("MockRelay failed to start after {} attempts", max_attempts);
279 "MockRelay failed to start after {} attempts",
280 max_attempts
281 );
282 } 282 }
283 tokio::time::sleep(delay).await; 283 tokio::time::sleep(delay).await;
284 } 284 }
@@ -309,7 +309,10 @@ mod tests {
309 // Create a client and connect 309 // Create a client and connect
310 let keys = Keys::generate(); 310 let keys = Keys::generate();
311 let client = Client::new(keys.clone()); 311 let client = Client::new(keys.clone());
312 client.add_relay(mock.url()).await.expect("Failed to add relay"); 312 client
313 .add_relay(mock.url())
314 .await
315 .expect("Failed to add relay");
313 client.connect().await; 316 client.connect().await;
314 317
315 // Wait for connection 318 // Wait for connection
diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs
index 125f485..b39982e 100644
--- a/tests/common/purgatory_helpers.rs
+++ b/tests/common/purgatory_helpers.rs
@@ -51,7 +51,7 @@ pub fn create_test_repo_with_commit(path: &Path, variant: CommitVariant) -> Resu
51 // Configure git user for commits 51 // Configure git user for commits
52 run_git(path, &["config", "user.email", "test@example.com"])?; 52 run_git(path, &["config", "user.email", "test@example.com"])?;
53 run_git(path, &["config", "user.name", "Test User"])?; 53 run_git(path, &["config", "user.name", "Test User"])?;
54 54
55 // Disable GPG signing for tests (prevents yubikey prompts) 55 // Disable GPG signing for tests (prevents yubikey prompts)
56 run_git(path, &["config", "commit.gpgsign", "false"])?; 56 run_git(path, &["config", "commit.gpgsign", "false"])?;
57 run_git(path, &["config", "tag.gpgsign", "false"])?; 57 run_git(path, &["config", "tag.gpgsign", "false"])?;
@@ -710,7 +710,8 @@ mod tests {
710 // Check d-tag 710 // Check d-tag
711 let has_d_tag = event.tags.iter().any(|tag| { 711 let has_d_tag = event.tags.iter().any(|tag| {
712 let slice = tag.as_slice(); 712 let slice = tag.as_slice();
713 slice.first().is_some_and(|t| t == "d") && slice.get(1).is_some_and(|v| v == "test-repo") 713 slice.first().is_some_and(|t| t == "d")
714 && slice.get(1).is_some_and(|v| v == "test-repo")
714 }); 715 });
715 assert!(has_d_tag, "Event should have 'd' tag with identifier"); 716 assert!(has_d_tag, "Event should have 'd' tag with identifier");
716 717
@@ -751,7 +752,8 @@ mod tests {
751 // Check a-tag 752 // Check a-tag
752 let has_a_tag = event.tags.iter().any(|tag| { 753 let has_a_tag = event.tags.iter().any(|tag| {
753 let slice = tag.as_slice(); 754 let slice = tag.as_slice();
754 slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord) 755 slice.first().is_some_and(|t| t == "a")
756 && slice.get(1).is_some_and(|v| v == &repo_coord)
755 }); 757 });
756 assert!(has_a_tag, "Event should have 'a' tag"); 758 assert!(has_a_tag, "Event should have 'a' tag");
757 759
@@ -806,7 +808,10 @@ mod tests {
806 &repo_coord, 808 &repo_coord,
807 "abc123def456", 809 "abc123def456",
808 "Test PR with clone", 810 "Test PR with clone",
809 &["http://fork-server.com/repo.git", "http://another-server.com/repo.git"], 811 &[
812 "http://fork-server.com/repo.git",
813 "http://another-server.com/repo.git",
814 ],
810 ) 815 )
811 .expect("Failed to create PR event with clone"); 816 .expect("Failed to create PR event with clone");
812 817
@@ -815,7 +820,8 @@ mod tests {
815 // Check a-tag 820 // Check a-tag
816 let has_a_tag = event.tags.iter().any(|tag| { 821 let has_a_tag = event.tags.iter().any(|tag| {
817 let slice = tag.as_slice(); 822 let slice = tag.as_slice();
818 slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord) 823 slice.first().is_some_and(|t| t == "a")
824 && slice.get(1).is_some_and(|v| v == &repo_coord)
819 }); 825 });
820 assert!(has_a_tag, "Event should have 'a' tag"); 826 assert!(has_a_tag, "Event should have 'a' tag");
821 827
@@ -831,8 +837,12 @@ mod tests {
831 let has_clone_tag = event.tags.iter().any(|tag| { 837 let has_clone_tag = event.tags.iter().any(|tag| {
832 let slice = tag.as_slice(); 838 let slice = tag.as_slice();
833 slice.first().is_some_and(|t| t == "clone") 839 slice.first().is_some_and(|t| t == "clone")
834 && slice.get(1).is_some_and(|v| v == "http://fork-server.com/repo.git") 840 && slice
835 && slice.get(2).is_some_and(|v| v == "http://another-server.com/repo.git") 841 .get(1)
842 .is_some_and(|v| v == "http://fork-server.com/repo.git")
843 && slice
844 .get(2)
845 .is_some_and(|v| v == "http://another-server.com/repo.git")
836 }); 846 });
837 assert!(has_clone_tag, "Event should have 'clone' tag with URLs"); 847 assert!(has_clone_tag, "Event should have 'clone' tag with URLs");
838 } 848 }
@@ -855,6 +865,9 @@ mod tests {
855 let slice = tag.as_slice(); 865 let slice = tag.as_slice();
856 slice.first().is_some_and(|t| t == "clone") 866 slice.first().is_some_and(|t| t == "clone")
857 }); 867 });
858 assert!(!has_clone_tag, "Event should not have 'clone' tag when no URLs provided"); 868 assert!(
869 !has_clone_tag,
870 "Event should not have 'clone' tag when no URLs provided"
871 );
859 } 872 }
860} 873}
diff --git a/tests/common/relay.rs b/tests/common/relay.rs
index 8d20da6..fb5d421 100644
--- a/tests/common/relay.rs
+++ b/tests/common/relay.rs
@@ -144,7 +144,10 @@ impl TestRelay {
144 .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests 144 .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests
145 .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests 145 .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests
146 .env("NGIT_SYNC_BASE_BACKOFF_SECS", "1") // Fast backoff for tests (1s instead of 5s default) 146 .env("NGIT_SYNC_BASE_BACKOFF_SECS", "1") // Fast backoff for tests (1s instead of 5s default)
147 .env("RUST_LOG", std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string())) // Use RUST_LOG from environment or default to info 147 .env(
148 "RUST_LOG",
149 std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
150 ) // Use RUST_LOG from environment or default to info
148 .stdout(Stdio::null()) // Suppress stdout for cleaner test output 151 .stdout(Stdio::null()) // Suppress stdout for cleaner test output
149 .stderr(Stdio::null()); // Suppress stderr for cleaner test output 152 .stderr(Stdio::null()); // Suppress stderr for cleaner test output
150 153