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-05 14:54:29 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-05 14:54:29 +0000
commit3f50107062d55a15decc47e93fd4e9f473de86e8 (patch)
tree8242bf52608afd08a9adc12d9223cb08f42fa517
parentf8235b7977c673524c12a229eddb7ace6b0c2c0d (diff)
sync all repos when authorised state data push received
-rw-r--r--src/git/handlers.rs38
-rw-r--r--src/git/mod.rs1
-rw-r--r--src/git/sync.rs453
-rw-r--r--src/http/mod.rs1
-rw-r--r--src/purgatory/mod.rs344
5 files changed, 497 insertions, 340 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}
diff --git a/src/http/mod.rs b/src/http/mod.rs
index 10563da..e2caf5d 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -237,6 +237,7 @@ impl Service<Request<Incoming>> for HttpService {
237 &identifier, 237 &identifier,
238 &owner_pubkey_hex, 238 &owner_pubkey_hex,
239 purgatory.clone(), 239 purgatory.clone(),
240 &git_data_path,
240 ) 241 )
241 .await; 242 .await;
242 243
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index f15d6bd..88377fb 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -26,11 +26,9 @@ use std::process::Command;
26use std::sync::Arc; 26use std::sync::Arc;
27use std::time::{Duration, Instant}; 27use std::time::{Duration, Instant};
28 28
29use crate::git::authorization::{ 29use crate::git::authorization::{fetch_repository_data, pubkey_authorised_for_repo_owners, RepositoryData};
30 collect_authorized_maintainers, fetch_repository_data, pubkey_authorised_for_repo_owners,
31 RepositoryData,
32};
33use crate::git::oid_exists; 30use crate::git::oid_exists;
31use crate::git::sync::sync_to_owner_repos;
34use crate::nostr::builder::SharedDatabase; 32use crate::nostr::builder::SharedDatabase;
35use crate::nostr::events::RepositoryState; 33use crate::nostr::events::RepositoryState;
36 34
@@ -596,96 +594,14 @@ async fn sync_state_git_data(
596 } 594 }
597 595
598 // Now that we have all OIDs, sync to other owner repositories and align refs 596 // Now that we have all OIDs, sync to other owner repositories and align refs
599 let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); 597 let sync_result = sync_to_owner_repos(&source_repo_path, &state, &db_repo_data, git_data_path);
600 let mut repo_count = 0;
601
602 for (owner, maintainers) in &by_owner {
603 // Check if this state's author is authorized for this owner
604 if !maintainers.contains(&state.event.pubkey.to_hex()) {
605 continue;
606 }
607
608 // Find the previous latest state for this owner's maintainer set
609 let previous_state = db_repo_data
610 .states
611 .iter()
612 .filter(|s| maintainers.contains(&s.event.pubkey.to_hex()))
613 .max_by_key(|s| s.event.created_at);
614
615 // Only update if this state is newer than any existing state
616 // TODO: in event of a tie, the event with the biggest event id wins
617 if let Some(prev) = previous_state {
618 if state.event.created_at <= prev.event.created_at {
619 tracing::debug!(
620 identifier = %state.identifier,
621 owner = %owner,
622 "Skipping owner - existing state is newer or equal"
623 );
624 continue;
625 }
626 }
627
628 // Find the announcement for this owner
629 let announcement = db_repo_data
630 .announcements
631 .iter()
632 .find(|a| a.event.pubkey.to_hex() == *owner);
633
634 let Some(announcement) = announcement else {
635 continue;
636 };
637
638 let target_repo_path = git_data_path.join(announcement.repo_path());
639
640 if !target_repo_path.exists() {
641 // Repository doesn't exist (e.g., announcement doesn't list this service)
642 tracing::debug!(
643 identifier = %state.identifier,
644 owner = %owner,
645 repo_path = %target_repo_path.display(),
646 "Skipping owner - repository doesn't exist"
647 );
648 continue;
649 }
650
651 // Copy missing OIDs from source repo to target repo if different
652 if target_repo_path != source_repo_path {
653 if let Err(e) =
654 copy_missing_oids_between_repos(&source_repo_path, &target_repo_path, &state)
655 {
656 tracing::warn!(
657 identifier = %state.identifier,
658 source = %source_repo_path.display(),
659 target = %target_repo_path.display(),
660 error = %e,
661 "Failed to copy OIDs between repos"
662 );
663 // Continue anyway - we'll try to align what we can
664 }
665 }
666
667 // Align refs with state
668 let result = align_repository_with_state(&target_repo_path, &state);
669 repo_count += 1;
670
671 tracing::info!(
672 identifier = %state.identifier,
673 owner = %owner,
674 repo_path = %target_repo_path.display(),
675 refs_created = result.refs_created,
676 refs_updated = result.refs_updated,
677 refs_deleted = result.refs_deleted,
678 head_set = result.head_set,
679 "Aligned repository with state from purgatory sync"
680 );
681 }
682 598
683 tracing::info!( 599 tracing::info!(
684 identifier = %state.identifier, 600 identifier = %state.identifier,
685 event_id = %state.event.id, 601 event_id = %state.event.id,
686 repo_count = repo_count, 602 repos_synced = sync_result.repos_synced,
687 "Synced git data and aligned {} repositories", 603 "Synced git data and aligned {} repositories from purgatory",
688 repo_count 604 sync_result.repos_synced
689 ); 605 );
690 606
691 // Save state event to database 607 // Save state event to database
@@ -737,254 +653,6 @@ async fn sync_state_git_data(
737 Ok(()) 653 Ok(())
738} 654}
739 655
740/// Copy missing OIDs from a source repository to a target repository.
741///
742/// Identifies commits referenced in the state that are missing from the target
743/// repository and copies them from the source repository using git fetch.
744fn copy_missing_oids_between_repos(
745 source_repo: &Path,
746 target_repo: &Path,
747 state: &RepositoryState,
748) -> Result<(), String> {
749 // Collect all commits referenced in the state
750 let mut commits_to_check = Vec::new();
751
752 for branch in &state.branches {
753 if !branch.commit.starts_with("ref: ") {
754 commits_to_check.push(&branch.commit);
755 }
756 }
757
758 for tag in &state.tags {
759 if !tag.commit.starts_with("ref: ") {
760 commits_to_check.push(&tag.commit);
761 }
762 }
763
764 // Identify missing commits
765 let mut missing_commits = Vec::new();
766 for commit in commits_to_check {
767 if !oid_exists(target_repo, commit) {
768 missing_commits.push(commit);
769 }
770 }
771
772 if missing_commits.is_empty() {
773 tracing::debug!(
774 "No missing commits to copy from {} to {}",
775 source_repo.display(),
776 target_repo.display()
777 );
778 return Ok(());
779 }
780
781 tracing::info!(
782 "Copying {} missing commits from {} to {}",
783 missing_commits.len(),
784 source_repo.display(),
785 target_repo.display()
786 );
787
788 // Fetch each missing commit from source to target
789 for commit in &missing_commits {
790 let output = Command::new("git")
791 .args([
792 "fetch",
793 source_repo.to_str().ok_or("Invalid source path")?,
794 commit,
795 ])
796 .current_dir(target_repo)
797 .output()
798 .map_err(|e| format!("Failed to execute git fetch: {}", e))?;
799
800 if !output.status.success() {
801 let stderr = String::from_utf8_lossy(&output.stderr);
802 return Err(format!(
803 "git fetch failed for commit {}: {}",
804 commit, stderr
805 ));
806 }
807
808 tracing::debug!("Copied commit {} to {}", commit, target_repo.display());
809 }
810
811 Ok(())
812}
813
814/// Result of aligning a repository with authorized state
815#[derive(Debug, Default)]
816struct SyncAlignmentResult {
817 /// Number of refs created
818 refs_created: usize,
819 /// Number of refs updated
820 refs_updated: usize,
821 /// Number of refs deleted
822 refs_deleted: usize,
823 /// Whether HEAD was set
824 head_set: bool,
825}
826
827/// Align a repository's refs with the authorized state.
828///
829/// This function:
830/// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
831/// 2. Updates refs that exist in state if we have the commit
832/// 3. Sets HEAD if the HEAD branch's commit is available
833fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) -> SyncAlignmentResult {
834 use crate::git;
835
836 let mut result = SyncAlignmentResult::default();
837
838 // Check if repository exists
839 if !repo_path.exists() {
840 tracing::debug!(
841 "Repository not found at {}, cannot align with state",
842 repo_path.display()
843 );
844 return result;
845 }
846
847 // Get current refs from the repository
848 let current_refs = match git::list_refs(repo_path) {
849 Ok(refs) => refs,
850 Err(e) => {
851 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
852 return result;
853 }
854 };
855
856 // Build expected refs from state
857 let mut expected_refs: std::collections::HashMap<String, String> =
858 std::collections::HashMap::new();
859
860 for branch in &state.branches {
861 let ref_name = format!("refs/heads/{}", branch.name);
862 expected_refs.insert(ref_name, branch.commit.clone());
863 }
864
865 for tag in &state.tags {
866 let ref_name = format!("refs/tags/{}", tag.name);
867 expected_refs.insert(ref_name, tag.commit.clone());
868 }
869
870 // Delete refs that exist in repo but not in state (only for refs/heads/ and refs/tags/)
871 for (ref_name, _current_commit) in &current_refs {
872 if (ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/"))
873 && !expected_refs.contains_key(ref_name)
874 {
875 match git::delete_ref(repo_path, ref_name) {
876 Ok(()) => {
877 tracing::info!(
878 "Deleted {} from {} (not in state)",
879 ref_name,
880 repo_path.display()
881 );
882 result.refs_deleted += 1;
883 }
884 Err(e) => {
885 tracing::warn!(
886 "Failed to delete {} from {}: {}",
887 ref_name,
888 repo_path.display(),
889 e
890 );
891 }
892 }
893 }
894 }
895
896 // Update refs that exist in state (if we have the commit)
897 for (ref_name, expected_commit) in &expected_refs {
898 // Skip symbolic refs
899 if expected_commit.starts_with("ref: ") {
900 continue;
901 }
902
903 // Check if we have the commit
904 if !git::oid_exists(repo_path, expected_commit) {
905 tracing::debug!(
906 "Commit {} not available for {} in {}",
907 expected_commit,
908 ref_name,
909 repo_path.display()
910 );
911 continue;
912 }
913
914 // Check current value
915 let current_commit = current_refs
916 .iter()
917 .find(|(r, _)| r == ref_name)
918 .map(|(_, c)| c.as_str());
919
920 if current_commit == Some(expected_commit.as_str()) {
921 // Already correct
922 continue;
923 }
924
925 // Update or create the ref
926 match git::update_ref(repo_path, ref_name, expected_commit) {
927 Ok(()) => {
928 if current_commit.is_some() {
929 tracing::info!(
930 "Updated {} to {} in {}",
931 ref_name,
932 expected_commit,
933 repo_path.display()
934 );
935 result.refs_updated += 1;
936 } else {
937 tracing::info!(
938 "Created {} at {} in {}",
939 ref_name,
940 expected_commit,
941 repo_path.display()
942 );
943 result.refs_created += 1;
944 }
945 }
946 Err(e) => {
947 tracing::warn!(
948 "Failed to update {} in {}: {}",
949 ref_name,
950 repo_path.display(),
951 e
952 );
953 }
954 }
955 }
956
957 // Set HEAD if specified in state
958 if let Some(head_ref) = &state.head {
959 if let Some(branch_name) = state.get_head_branch() {
960 if let Some(head_commit) = state.get_branch_commit(branch_name) {
961 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
962 Ok(true) => {
963 tracing::info!(
964 "Set HEAD to {} in {} (from purgatory sync)",
965 head_ref,
966 repo_path.display()
967 );
968 result.head_set = true;
969 }
970 Ok(false) => {
971 tracing::debug!(
972 "HEAD commit {} not available yet in {}",
973 head_commit,
974 repo_path.display()
975 );
976 }
977 Err(e) => {
978 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
979 }
980 }
981 }
982 }
983 }
984
985 result
986}
987
988/// Fetch missing OIDs from a remote git server. 656/// Fetch missing OIDs from a remote git server.
989/// 657///
990/// Uses `git fetch` to retrieve specific commits from the server. 658/// Uses `git fetch` to retrieve specific commits from the server.