From fded5a3b0fc49345cea71d8b0e36eaab52e24005 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 17 Sep 2026 15:49:07 +0000 Subject: [PATCH] fix(git): honor replacement ordering in owner repository sync The public sync_to_owner_repos helper skipped all same-second candidates and selected prior states by timestamp alone. A lower-ID replacement could therefore never reach Git alignment. Reuse latest_authorized_state and require strictly preferred replacement ordering. Exact replays and losing candidates still skip alignment. Authorization and Git object copying are unchanged. No internal callers of this public helper currently exist. Validation: a regression against a temporary bare repository fails before the fix and passes afterward. It covers both ID directions, replay, both prior-state orders and a later timestamp. Focused owner tests, workspace all-target Clippy and formatting pass. Assisted-by: GPT-6 --- CHANGELOG.md | 3 ++ src/git/sync.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea28d74..5687c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Honor lower-ID same-second replacements in the public owner-repository sync + helper while preserving replay suppression. + - Restore relay-owner profiles and relay lists using the lower event ID when fetched versions share a timestamp. diff --git a/src/git/sync.rs b/src/git/sync.rs index dea30af..657bbfb 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs @@ -403,16 +403,12 @@ pub fn sync_to_owner_repos( } // Find the previous latest state for this owner's maintainer set - let previous_state = db_repo_data - .states - .iter() - .filter(|s| maintainers.contains(&s.event.pubkey.to_hex())) - .max_by_key(|s| s.event.created_at); + let previous_state = latest_authorized_state(maintainers, &db_repo_data.states); - // Only update if this state is newer than any existing state - // TODO: in event of a tie, the event with the biggest event id wins + // Apply only a strictly preferred state; replaying the same event is a no-op. if let Some(prev) = previous_state { - if state.event.created_at <= prev.event.created_at { + if !crate::nostr::events::compare_replacement_events(&state.event, &prev.event).is_gt() + { debug!( identifier = %state.identifier, owner = %owner, @@ -1843,6 +1839,75 @@ mod tests { use super::*; use nostr_sdk::prelude::Keys; + #[test] + fn owner_repo_sync_honors_same_second_replacement_order() { + use crate::nostr::events::RepositoryAnnouncement; + use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Kind, Tag, Timestamp}; + + let keys = Keys::generate(); + let announcement = RepositoryAnnouncement::from_event( + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags([Tag::identifier("tie-test")]) + .finalize(&keys) + .unwrap(), + ) + .unwrap(); + let root = tempfile::tempdir().unwrap(); + let repo = root.path().join(announcement.repo_path()); + std::fs::create_dir_all(&repo).unwrap(); + assert!(Command::new("git") + .args(["init", "--bare"]) + .arg(&repo) + .output() + .unwrap() + .status + .success()); + let build = |content: &str, timestamp| { + RepositoryState::from_event( + EventBuilder::new(Kind::RepoState, content) + .tags([Tag::identifier("tie-test")]) + .custom_created_at(Timestamp::from_secs(timestamp)) + .finalize(&keys) + .unwrap(), + ) + .unwrap() + }; + let mut pair = [build("first", 1000), build("second", 1000)]; + pair.sort_by_key(|state| state.event.id); + let [winner, loser] = pair; + let mut data = RepositoryData { + announcements: vec![announcement], + states: vec![loser.clone()], + }; + assert_eq!( + sync_to_owner_repos(&repo, &winner, &data, root.path()).repos_synced, + 1 + ); + data.states = vec![winner.clone()]; + assert_eq!( + sync_to_owner_repos(&repo, &loser, &data, root.path()).repos_synced, + 0 + ); + assert_eq!( + sync_to_owner_repos(&repo, &winner, &data, root.path()).repos_synced, + 0 + ); + for states in [ + vec![winner.clone(), loser.clone()], + vec![loser, winner.clone()], + ] { + data.states = states; + assert_eq!( + sync_to_owner_repos(&repo, &winner, &data, root.path()).repos_synced, + 0 + ); + assert_eq!( + sync_to_owner_repos(&repo, &build("later", 1001), &data, root.path()).repos_synced, + 1 + ); + } + } + #[test] fn test_process_result_default() { let result = ProcessResult::default();