From f5c8d167c3bf175dfe08ea3c8ca96055632364c3 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 31 Dec 2025 12:42:26 +0000 Subject: purgatory: when state data recieved sync across repositoies --- .../src/specs/grasp01/push_authorization.rs | 169 ++++++++++++++++++--- src/nostr/policy/state.rs | 107 ++++++++++++- tests/push_authorization.rs | 5 +- 3 files changed, 255 insertions(+), 26 deletions(-) diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index ef494da..8bcf0f7 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -766,32 +766,159 @@ impl PushAuthorizationTests { /// /// GRASP-01: "respecting the recursive maintainer set" /// - /// This test verifies that when a maintainer publishes a state event, it updates - /// the git repository state for all other maintainers' views. This ensures git - /// repositories always reflect the state according to nostr events (including state - /// from recursive maintainers). + /// This test verifies that when a maintainer publishes a state event, the purgatory + /// feature correctly copies git commits between repos so all authorized maintainers' + /// repositories always reflect the state according to nostr events. /// - /// ## Implementation Note + /// ## Implementation /// - /// This test is a stub for the purgatory feature. It will be implemented as part - /// of GRASP-02 purgatory functionality. + /// This test: + /// 1. Uses RecursiveMaintainerStateDataPushed fixture which: + /// - Creates owner repo + state (ValidRepo, OwnerStateDataPushed) + /// - Creates maintainer announcement (separate repo for maintainer) + /// - Pushes recursive maintainer's git data to owner's repo + /// 2. Clones the maintainer's repository (not the owner's) + /// 3. Verifies that the maintainer's repo contains the recursive maintainer's state /// - /// ## Fixture Compatibility - /// - /// This test will use: - /// - `MaintainerStateDataPushed` - maintainer's state event with git data pushed - /// - Multiple maintainer clones to verify state propagation - #[allow(dead_code)] + /// This proves purgatory is working: git data was pushed to owner's repo, and purgatory + /// synced it to the maintainer's repo based on the state event. pub async fn test_push_of_state_by_maintainer_updates_other_maintainer_repos( - _client: &AuditClient, - _relay_domain: &str, + client: &AuditClient, + relay_domain: &str, ) -> TestResult { - TestResult::new( - "test_push_of_state_by_maintainer_updates_other_maintainer_repos", - "GRASP-01:git-http:purgatory", - "Maintainer state updates propagate to other maintainer repos", - ) - .fail("Not yet implemented - requires purgatory feature (GRASP-02)") + let test_name = "test_push_of_state_by_maintainer_updates_other_maintainer_repos"; + let ctx = TestContext::new(client); + + // Get the RecursiveMaintainerStateDataPushed fixture which: + // 1. Creates owner repo + owner state + git data + // 2. Creates maintainer announcement (separate repo) + // 3. Pushes recursive maintainer's git data to owner's repo + // 4. Purgatory should then sync to maintainer's repo + let recursive_state = match ctx + .get_fixture(FixtureKind::RecursiveMaintainerStateDataPushed) + .await + { + Ok(s) => s, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .fail(format!( + "Failed to get RecursiveMaintainerStateDataPushed fixture: {}", + e + )) + } + }; + + // Small delay to ensure state processing completes + tokio::time::sleep(Duration::from_millis(200)).await; + + // Extract repo_id from the recursive maintainer's state event + let repo_id = match recursive_state + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .fail("No repo identifier in recursive maintainer state event") + } + }; + + // Get the maintainer's npub + let maintainer_npub = match client.maintainer_keys().public_key().to_bech32() { + Ok(npub) => npub, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .fail(format!( + "Failed to convert maintainer pubkey to npub: {}", + e + )) + } + }; + + // Use the known recursive maintainer deterministic commit hash from the fixture + let expected_commit = crate::RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH; + + // Clone the maintainer's repository (NOT the owner's) + // This is the key test: git data was pushed to owner's repo, does maintainer's repo have it? + let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) { + Ok(path) => path, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .fail(format!("Failed to clone maintainer's repo: {}", e)) + } + }; + + let cleanup = || { + let _ = fs::remove_dir_all(&clone_path); + }; + + // Verify that the maintainer's repo contains the recursive maintainer's commit + // This proves purgatory copied it from owner's repo + let commit_exists_output = Command::new("git") + .args(["cat-file", "-t", expected_commit]) + .current_dir(&clone_path) + .output(); + + let commit_exists = match commit_exists_output { + Ok(output) => { + if output.status.success() { + let obj_type = String::from_utf8_lossy(&output.stdout); + obj_type.trim() == "commit" + } else { + false + } + } + Err(e) => { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .fail(format!("Failed to check if commit exists: {}", e)); + } + }; + + cleanup(); + + if commit_exists { + TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .pass() + } else { + TestResult::new( + test_name, + "GRASP-01:git-http:purgatory", + "Maintainer state updates propagate to other maintainer repos", + ) + .fail(format!( + "Maintainer's repo does not contain recursive maintainer's commit {}. \ + Git data was pushed to owner's repo, but purgatory did not copy it to \ + maintainer's repo. This indicates the purgatory feature is not working correctly.", + expected_commit + )) + } } /// Test that non-maintainer state event is ignored diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index 1203890..48435ea 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs @@ -125,7 +125,27 @@ impl StatePolicy { { let repo_path = self.ctx.git_data_path.join(annoucement.repo_path().clone()); - // TODO - if repo_path != repo_with_git_data, pass as a datasource for missing data? + + if !repo_path.exists() { + // eg if annoucement doesnt list repo (but stored as its in maintainer set) + continue; + } + // If repo_path != repo_with_git_data, copy missing oids first + if repo_path != repo_with_git_data { + if let Err(e) = self.copy_missing_oids( + &repo_with_git_data, + &repo_path, + &state, + ) { + tracing::warn!( + "Failed to copy oids from {} to {}: {}", + repo_with_git_data.display(), + repo_path.display(), + e + ); + } + } + let result = self.align_repository_with_state(&repo_path, &state); repo_count += 1; tracing::info!( @@ -335,6 +355,91 @@ impl StatePolicy { result } + + /// Copy missing OIDs from a source repository to a target repository + /// + /// Identifies commits referenced in the state that are missing from the target + /// repository and copies them from the source repository using git fetch. + /// + /// # Arguments + /// * `source_repo` - Path to repository containing the commits + /// * `target_repo` - Path to repository to receive the commits + /// * `state` - Repository state containing commit references + /// + /// # Returns + /// Ok(()) on success, Err with error message on failure + fn copy_missing_oids( + &self, + source_repo: &Path, + target_repo: &Path, + state: &RepositoryState, + ) -> Result<(), String> { + use std::process::Command; + + // Collect all commits referenced in the state + let mut commits_to_check = Vec::new(); + + for branch in &state.branches { + if !branch.commit.starts_with("ref: ") { + commits_to_check.push(&branch.commit); + } + } + + for tag in &state.tags { + if !tag.commit.starts_with("ref: ") { + commits_to_check.push(&tag.commit); + } + } + + // Identify missing commits + let mut missing_commits = Vec::new(); + for commit in commits_to_check { + if !git::oid_exists(target_repo, commit) { + missing_commits.push(commit); + } + } + + if missing_commits.is_empty() { + tracing::debug!( + "No missing commits to copy from {} to {}", + source_repo.display(), + target_repo.display() + ); + return Ok(()); + } + + tracing::info!( + "Copying {} missing commits from {} to {}", + missing_commits.len(), + source_repo.display(), + target_repo.display() + ); + + // Fetch each missing commit from source to target + for commit in &missing_commits { + let output = Command::new("git") + .args([ + "fetch", + source_repo.to_str().ok_or("Invalid source path")?, + commit, + ]) + .current_dir(target_repo) + .output() + .map_err(|e| format!("Failed to execute git fetch: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "git fetch failed for commit {}: {}", + commit, stderr + )); + } + + tracing::debug!("Copied commit {} to {}", commit, target_repo.display()); + } + + Ok(()) + } } fn find_repo_with_git_data( diff --git a/tests/push_authorization.rs b/tests/push_authorization.rs index 85b9a5d..7047010 100644 --- a/tests/push_authorization.rs +++ b/tests/push_authorization.rs @@ -72,7 +72,4 @@ isolated_push_test!( ); isolated_push_test!(test_head_set_after_state_event_with_existing_commit); isolated_push_test!(test_head_set_after_git_push_with_required_oids); - -// Note: test_push_of_state_by_maintainer_updates_other_maintainer_repos is not included -// as it's a stub for the purgatory feature. It can be run manually once implemented: -// isolated_push_test!(test_push_of_state_by_maintainer_updates_other_maintainer_repos); +isolated_push_test!(test_push_of_state_by_maintainer_updates_other_maintainer_repos); -- cgit v1.2.3