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>2026-01-05 14:54:29 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-05 14:54:29 +0000
commit3f50107062d55a15decc47e93fd4e9f473de86e8 (patch)
tree8242bf52608afd08a9adc12d9223cb08f42fa517 /src/git
parentf8235b7977c673524c12a229eddb7ace6b0c2c0d (diff)
sync all repos when authorised state data push received
Diffstat (limited to 'src/git')
-rw-r--r--src/git/handlers.rs38
-rw-r--r--src/git/mod.rs1
-rw-r--r--src/git/sync.rs453
3 files changed, 490 insertions, 2 deletions
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
index 2930852..e86d2a3 100644
--- a/src/git/handlers.rs
+++ b/src/git/handlers.rs
@@ -15,7 +15,8 @@ use super::protocol::{GitService, PktLine};
15use super::subprocess::GitSubprocess; 15use super::subprocess::GitSubprocess;
16use super::try_set_head_if_available; 16use super::try_set_head_if_available;
17 17
18use crate::git::authorization::authorize_push; 18use crate::git::authorization::{authorize_push, fetch_repository_data};
19use crate::git::sync::sync_to_owner_repos;
19use crate::nostr::builder::SharedDatabase; 20use crate::nostr::builder::SharedDatabase;
20use crate::nostr::events::{KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE}; 21use crate::nostr::events::{KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE};
21use crate::purgatory::Purgatory; 22use crate::purgatory::Purgatory;
@@ -180,6 +181,7 @@ pub async fn handle_upload_pack(
180/// * `database` - Database reference for authorization queries 181/// * `database` - Database reference for authorization queries
181/// * `identifier` - The repository identifier (d tag) for authorization lookup 182/// * `identifier` - The repository identifier (d tag) for authorization lookup
182/// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization 183/// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization
184/// * `git_data_path` - Base path for git repositories (for syncing to other owner repos)
183pub async fn handle_receive_pack( 185pub async fn handle_receive_pack(
184 repo_path: PathBuf, 186 repo_path: PathBuf,
185 request_body: Bytes, 187 request_body: Bytes,
@@ -188,6 +190,7 @@ pub async fn handle_receive_pack(
188 identifier: &str, 190 identifier: &str,
189 owner_pubkey: &str, 191 owner_pubkey: &str,
190 purgatory: Arc<Purgatory>, 192 purgatory: Arc<Purgatory>,
193 git_data_path: &str,
191) -> Result<Response<Full<Bytes>>, GitError> { 194) -> Result<Response<Full<Bytes>>, GitError> {
192 debug!("Handling receive-pack for {:?}", repo_path); 195 debug!("Handling receive-pack for {:?}", repo_path);
193 196
@@ -347,7 +350,38 @@ pub async fn handle_receive_pack(
347 } 350 }
348 351
349 // TODO figure out what atomic pushes look like in GRASP (we cant accepted differnte state events changing different branches at the same time) 352 // TODO figure out what atomic pushes look like in GRASP (we cant accepted differnte state events changing different branches at the same time)
350 // TODO sync git data to other repos that these events authorise. 353
354 // Sync git data to other owner repositories that authorize the same state event
355 // This ensures all owners who share maintainers get the same git data
356 if let Some(ref state) = auth_result.state {
357 // Fetch repository data for sync
358 match fetch_repository_data(&database, identifier).await {
359 Ok(db_repo_data) => {
360 let git_data_path_buf = std::path::PathBuf::from(git_data_path);
361 let sync_result =
362 sync_to_owner_repos(&repo_path, state, &db_repo_data, &git_data_path_buf);
363
364 if sync_result.repos_synced > 0 {
365 info!(
366 "Synced git data to {} other owner repositories for {}",
367 sync_result.repos_synced, identifier
368 );
369 }
370
371 if !sync_result.errors.is_empty() {
372 for (repo, error) in &sync_result.errors {
373 warn!("Error syncing to {}: {}", repo, error);
374 }
375 }
376 }
377 Err(e) => {
378 warn!(
379 "Failed to fetch repository data for sync after push to {}: {}",
380 identifier, e
381 );
382 }
383 }
384 }
351 385
352 Ok(Response::builder() 386 Ok(Response::builder()
353 .status(StatusCode::OK) 387 .status(StatusCode::OK)
diff --git a/src/git/mod.rs b/src/git/mod.rs
index d34f98b..fb17c53 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -21,6 +21,7 @@ pub mod authorization;
21pub mod handlers; 21pub mod handlers;
22pub mod protocol; 22pub mod protocol;
23pub mod subprocess; 23pub mod subprocess;
24pub mod sync;
24 25
25use std::path::{Path, PathBuf}; 26use std::path::{Path, PathBuf};
26use std::process::Command; 27use std::process::Command;
diff --git a/src/git/sync.rs b/src/git/sync.rs
new file mode 100644
index 0000000..c99eb43
--- /dev/null
+++ b/src/git/sync.rs
@@ -0,0 +1,453 @@
1//! Git Data Synchronization Across Owner Repositories
2//!
3//! This module provides functions to sync git data across multiple owner repositories
4//! that are authorized by the same state event. This is used when:
5//!
6//! 1. A push is received that satisfies a state event - the git data needs to be
7//! copied to other owner repos that authorize the same state
8//! 2. Purgatory sync fetches git data from remote - needs to distribute to all
9//! authorized owner repos
10//!
11//! ## Architecture
12//!
13//! The key insight is that multiple owners can have announcements for the same
14//! repository identifier, and they may share maintainers. When a state event
15//! authorizes a push, that push should be reflected in ALL owner repositories
16//! that would authorize the same state.
17
18use std::collections::HashMap;
19use std::path::Path;
20use std::process::Command;
21use tracing::{debug, info, warn};
22
23use crate::git::{self, oid_exists};
24use crate::git::authorization::{collect_authorized_maintainers, RepositoryData};
25use crate::nostr::events::RepositoryState;
26
27/// Result of syncing git data to owner repositories
28#[derive(Debug, Default)]
29pub struct SyncResult {
30 /// Number of repositories synced
31 pub repos_synced: usize,
32 /// Number of refs created across all repos
33 pub refs_created: usize,
34 /// Number of refs updated across all repos
35 pub refs_updated: usize,
36 /// Number of refs deleted across all repos
37 pub refs_deleted: usize,
38 /// Number of repositories where HEAD was set
39 pub heads_set: usize,
40 /// Errors encountered (repo path -> error message)
41 pub errors: Vec<(String, String)>,
42}
43
44/// Result of aligning a single repository with state
45#[derive(Debug, Default)]
46pub struct AlignmentResult {
47 /// Number of refs created
48 pub refs_created: usize,
49 /// Number of refs updated
50 pub refs_updated: usize,
51 /// Number of refs deleted
52 pub refs_deleted: usize,
53 /// Whether HEAD was set
54 pub head_set: bool,
55}
56
57/// Sync git data from a source repository to all other owner repositories
58/// that authorize the given state event.
59///
60/// This function:
61/// 1. Collects all authorized maintainers per owner from announcements
62/// 2. For each owner whose maintainer set authorizes the state author:
63/// - Skips if a newer state already exists for that owner
64/// - Copies missing OIDs from source repo to target repo
65/// - Aligns refs with the state
66///
67/// # Arguments
68/// * `source_repo_path` - Path to the repository that has the git data
69/// * `state` - The repository state event that authorized the push
70/// * `db_repo_data` - Repository data from database (announcements + states)
71/// * `git_data_path` - Base path for git repositories
72///
73/// # Returns
74/// A `SyncResult` with statistics about what was synced
75pub fn sync_to_owner_repos(
76 source_repo_path: &Path,
77 state: &RepositoryState,
78 db_repo_data: &RepositoryData,
79 git_data_path: &Path,
80) -> SyncResult {
81 let mut result = SyncResult::default();
82
83 // Collect authorized maintainers per owner
84 let by_owner = collect_authorized_maintainers(&db_repo_data.announcements);
85 let state_author = state.event.pubkey.to_hex();
86
87 debug!(
88 identifier = %state.identifier,
89 owners = by_owner.len(),
90 "Syncing git data to owner repositories"
91 );
92
93 for (owner, maintainers) in &by_owner {
94 // Check if this state's author is authorized for this owner
95 if !maintainers.contains(&state_author) {
96 debug!(
97 identifier = %state.identifier,
98 owner = %owner,
99 "Skipping owner - state author not in maintainer set"
100 );
101 continue;
102 }
103
104 // Find the previous latest state for this owner's maintainer set
105 let previous_state = db_repo_data
106 .states
107 .iter()
108 .filter(|s| maintainers.contains(&s.event.pubkey.to_hex()))
109 .max_by_key(|s| s.event.created_at);
110
111 // Only update if this state is newer than any existing state
112 // TODO: in event of a tie, the event with the biggest event id wins
113 if let Some(prev) = previous_state {
114 if state.event.created_at <= prev.event.created_at {
115 debug!(
116 identifier = %state.identifier,
117 owner = %owner,
118 "Skipping owner - existing state is newer or equal"
119 );
120 continue;
121 }
122 }
123
124 // Find the announcement for this owner
125 let announcement = db_repo_data
126 .announcements
127 .iter()
128 .find(|a| a.event.pubkey.to_hex() == *owner);
129
130 let Some(announcement) = announcement else {
131 continue;
132 };
133
134 let target_repo_path = git_data_path.join(announcement.repo_path());
135
136 if !target_repo_path.exists() {
137 // Repository doesn't exist (e.g., announcement doesn't list this service)
138 debug!(
139 identifier = %state.identifier,
140 owner = %owner,
141 repo_path = %target_repo_path.display(),
142 "Skipping owner - repository doesn't exist"
143 );
144 continue;
145 }
146
147 // Copy missing OIDs from source repo to target repo if different
148 if target_repo_path != source_repo_path {
149 if let Err(e) = copy_missing_oids_between_repos(source_repo_path, &target_repo_path, state)
150 {
151 warn!(
152 identifier = %state.identifier,
153 source = %source_repo_path.display(),
154 target = %target_repo_path.display(),
155 error = %e,
156 "Failed to copy OIDs between repos"
157 );
158 result.errors.push((target_repo_path.display().to_string(), e));
159 // Continue anyway - we'll try to align what we can
160 }
161 }
162
163 // Align refs with state
164 let align_result = align_repository_with_state(&target_repo_path, state);
165 result.repos_synced += 1;
166 result.refs_created += align_result.refs_created;
167 result.refs_updated += align_result.refs_updated;
168 result.refs_deleted += align_result.refs_deleted;
169 if align_result.head_set {
170 result.heads_set += 1;
171 }
172
173 info!(
174 identifier = %state.identifier,
175 owner = %owner,
176 repo_path = %target_repo_path.display(),
177 refs_created = align_result.refs_created,
178 refs_updated = align_result.refs_updated,
179 refs_deleted = align_result.refs_deleted,
180 head_set = align_result.head_set,
181 "Aligned repository with state"
182 );
183 }
184
185 info!(
186 identifier = %state.identifier,
187 repos_synced = result.repos_synced,
188 refs_created = result.refs_created,
189 refs_updated = result.refs_updated,
190 refs_deleted = result.refs_deleted,
191 heads_set = result.heads_set,
192 "Completed git data sync to owner repositories"
193 );
194
195 result
196}
197
198/// Copy missing OIDs from a source repository to a target repository.
199///
200/// Identifies commits referenced in the state that are missing from the target
201/// repository and copies them from the source repository using git fetch.
202pub fn copy_missing_oids_between_repos(
203 source_repo: &Path,
204 target_repo: &Path,
205 state: &RepositoryState,
206) -> Result<(), String> {
207 // Collect all commits referenced in the state
208 let mut commits_to_check = Vec::new();
209
210 for branch in &state.branches {
211 if !branch.commit.starts_with("ref: ") {
212 commits_to_check.push(&branch.commit);
213 }
214 }
215
216 for tag in &state.tags {
217 if !tag.commit.starts_with("ref: ") {
218 commits_to_check.push(&tag.commit);
219 }
220 }
221
222 // Identify missing commits
223 let mut missing_commits = Vec::new();
224 for commit in commits_to_check {
225 if !oid_exists(target_repo, commit) {
226 missing_commits.push(commit);
227 }
228 }
229
230 if missing_commits.is_empty() {
231 debug!(
232 "No missing commits to copy from {} to {}",
233 source_repo.display(),
234 target_repo.display()
235 );
236 return Ok(());
237 }
238
239 info!(
240 "Copying {} missing commits from {} to {}",
241 missing_commits.len(),
242 source_repo.display(),
243 target_repo.display()
244 );
245
246 // Fetch each missing commit from source to target
247 for commit in &missing_commits {
248 let output = Command::new("git")
249 .args([
250 "fetch",
251 source_repo.to_str().ok_or("Invalid source path")?,
252 commit,
253 ])
254 .current_dir(target_repo)
255 .output()
256 .map_err(|e| format!("Failed to execute git fetch: {}", e))?;
257
258 if !output.status.success() {
259 let stderr = String::from_utf8_lossy(&output.stderr);
260 return Err(format!(
261 "git fetch failed for commit {}: {}",
262 commit, stderr
263 ));
264 }
265
266 debug!("Copied commit {} to {}", commit, target_repo.display());
267 }
268
269 Ok(())
270}
271
272/// Align a repository's refs with the authorized state.
273///
274/// This function:
275/// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
276/// 2. Updates refs that exist in state if we have the commit
277/// 3. Sets HEAD if the HEAD branch's commit is available
278pub fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) -> AlignmentResult {
279 let mut result = AlignmentResult::default();
280
281 // Check if repository exists
282 if !repo_path.exists() {
283 debug!(
284 "Repository not found at {}, cannot align with state",
285 repo_path.display()
286 );
287 return result;
288 }
289
290 // Get current refs from the repository
291 let current_refs = match git::list_refs(repo_path) {
292 Ok(refs) => refs,
293 Err(e) => {
294 warn!("Failed to list refs in {}: {}", repo_path.display(), e);
295 return result;
296 }
297 };
298
299 // Build expected refs from state
300 let mut expected_refs: HashMap<String, String> = HashMap::new();
301
302 for branch in &state.branches {
303 let ref_name = format!("refs/heads/{}", branch.name);
304 expected_refs.insert(ref_name, branch.commit.clone());
305 }
306
307 for tag in &state.tags {
308 let ref_name = format!("refs/tags/{}", tag.name);
309 expected_refs.insert(ref_name, tag.commit.clone());
310 }
311
312 // Delete refs that exist in repo but not in state (only for refs/heads/ and refs/tags/)
313 for (ref_name, _current_commit) in &current_refs {
314 if (ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/"))
315 && !expected_refs.contains_key(ref_name)
316 {
317 match git::delete_ref(repo_path, ref_name) {
318 Ok(()) => {
319 info!(
320 "Deleted {} from {} (not in state)",
321 ref_name,
322 repo_path.display()
323 );
324 result.refs_deleted += 1;
325 }
326 Err(e) => {
327 warn!(
328 "Failed to delete {} from {}: {}",
329 ref_name,
330 repo_path.display(),
331 e
332 );
333 }
334 }
335 }
336 }
337
338 // Update refs that exist in state (if we have the commit)
339 for (ref_name, expected_commit) in &expected_refs {
340 // Skip symbolic refs
341 if expected_commit.starts_with("ref: ") {
342 continue;
343 }
344
345 // Check if we have the commit
346 if !git::oid_exists(repo_path, expected_commit) {
347 debug!(
348 "Commit {} not available for {} in {}",
349 expected_commit,
350 ref_name,
351 repo_path.display()
352 );
353 continue;
354 }
355
356 // Check current value
357 let current_commit = current_refs
358 .iter()
359 .find(|(r, _)| r == ref_name)
360 .map(|(_, c)| c.as_str());
361
362 if current_commit == Some(expected_commit.as_str()) {
363 // Already correct
364 continue;
365 }
366
367 // Update or create the ref
368 match git::update_ref(repo_path, ref_name, expected_commit) {
369 Ok(()) => {
370 if current_commit.is_some() {
371 info!(
372 "Updated {} to {} in {}",
373 ref_name,
374 expected_commit,
375 repo_path.display()
376 );
377 result.refs_updated += 1;
378 } else {
379 info!(
380 "Created {} at {} in {}",
381 ref_name,
382 expected_commit,
383 repo_path.display()
384 );
385 result.refs_created += 1;
386 }
387 }
388 Err(e) => {
389 warn!(
390 "Failed to update {} in {}: {}",
391 ref_name,
392 repo_path.display(),
393 e
394 );
395 }
396 }
397 }
398
399 // Set HEAD if specified in state
400 if let Some(head_ref) = &state.head {
401 if let Some(branch_name) = state.get_head_branch() {
402 if let Some(head_commit) = state.get_branch_commit(branch_name) {
403 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
404 Ok(true) => {
405 info!(
406 "Set HEAD to {} in {}",
407 head_ref,
408 repo_path.display()
409 );
410 result.head_set = true;
411 }
412 Ok(false) => {
413 debug!(
414 "HEAD commit {} not available yet in {}",
415 head_commit,
416 repo_path.display()
417 );
418 }
419 Err(e) => {
420 warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
421 }
422 }
423 }
424 }
425 }
426
427 result
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn test_sync_result_default() {
436 let result = SyncResult::default();
437 assert_eq!(result.repos_synced, 0);
438 assert_eq!(result.refs_created, 0);
439 assert_eq!(result.refs_updated, 0);
440 assert_eq!(result.refs_deleted, 0);
441 assert_eq!(result.heads_set, 0);
442 assert!(result.errors.is_empty());
443 }
444
445 #[test]
446 fn test_alignment_result_default() {
447 let result = AlignmentResult::default();
448 assert_eq!(result.refs_created, 0);
449 assert_eq!(result.refs_updated, 0);
450 assert_eq!(result.refs_deleted, 0);
451 assert!(!result.head_set);
452 }
453}