fix(recovery): prune tombstoned PR refs during restore

This commit is contained in:
DanConwayDev
2026-06-19 09:26:25 +01:00
parent 3945a192b7
commit fba89ba702
2 changed files with 109 additions and 1 deletions
+45 -1
View File
@@ -1771,6 +1771,13 @@ impl Nip34WritePolicy {
}
fn pr_event_identifiers(event: &Event) -> BTreeSet<String> {
Self::pr_event_announcement_scopes(event)
.into_iter()
.map(|(_, identifier)| identifier)
.collect()
}
fn pr_event_announcement_scopes(event: &Event) -> BTreeSet<(PublicKey, String)> {
event
.tags
.iter()
@@ -1785,7 +1792,8 @@ impl Nip34WritePolicy {
return None;
}
Some(coord[2].to_string())
let owner = PublicKey::from_hex(coord[1]).ok()?;
Some((owner, coord[2].to_string()))
})
.collect()
}
@@ -1793,6 +1801,13 @@ impl Nip34WritePolicy {
async fn pr_target_repo_paths_for_recovery(&self, event: &Event) -> BTreeSet<PathBuf> {
let mut paths = BTreeSet::new();
for (owner, identifier) in Self::pr_event_announcement_scopes(event) {
paths.insert(self.repo_path_for_owner_and_identifier(
&Self::owner_directory_component(&owner),
&identifier,
));
}
for identifier in Self::pr_event_identifiers(event) {
let Ok(repo_data) =
crate::git::authorization::fetch_repository_data_excluding_purgatory(
@@ -1876,6 +1891,34 @@ impl Nip34WritePolicy {
Ok(restored_any)
}
async fn cleanup_pr_git_refs_for_tombstoned_event(&self, event: &Event) {
if !matches!(
event.kind,
Kind::GitPullRequest | Kind::GitPullRequestUpdate
) {
return;
}
let ref_name = format!("refs/nostr/{}", event.id.to_hex());
let repo_paths = self.pr_target_repo_paths_for_recovery(event).await;
for repo_path in repo_paths {
if !repo_path.is_dir() {
continue;
}
if let Err(e) = crate::git::delete_ref(&repo_path, &ref_name) {
tracing::debug!(
event_id = %event.id.to_hex(),
repo = %repo_path.display(),
ref_name = %ref_name,
error = %e,
"Recovery tombstone cleanup: PR git ref not deleted for this repository"
);
}
}
}
async fn restore_events_from_holding(
&self,
records: &[RecoveryMetadataRecord],
@@ -1893,6 +1936,7 @@ impl Nip34WritePolicy {
match self.ctx.holding.archived_event(&event_id).await {
Ok(Some(event)) => {
if self.recovery_blocked_by_tombstone(&event).await {
self.cleanup_pr_git_refs_for_tombstoned_event(&event).await;
tracing::info!(
event_id = %event.id.to_hex(),
kind = event.kind.as_u16(),
+64
View File
@@ -536,6 +536,70 @@ async fn purgatory_promotion_recovery_respects_state_tombstones() {
fixture.relay.stop().await;
}
#[tokio::test]
async fn purgatory_promotion_tombstoned_pr_events_do_not_restore_pr_refs() {
let fixture =
setup_parked_announcement_with_failed_ingest_recovery("promotion-recovery-pr-tombstones", false)
.await;
fixture
.client
.send_event(build_deletion(
&fixture.client,
&[fixture.pr.id, fixture.pr_update.id],
&[],
))
.await
.expect("submit PR tombstone deletion request before promotion");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&fixture.archive_path, &fixture.archive_backup)
.expect("restore archive bytes before PR tombstone promotion");
let promoted_state =
submit_new_state_and_push_for_promotion(&fixture, "promotion-pr-tombstone").await;
assert!(fixture
.client
.is_event_on_relay(fixture.reannouncement.id)
.await
.expect("query promoted re-announcement"));
assert!(fixture
.client
.is_event_on_relay(promoted_state.id)
.await
.expect("query promoted new state"));
assert!(
!fixture
.client
.is_event_on_relay(fixture.pr.id)
.await
.expect("query tombstoned pr"),
"tombstoned PR must not resurrect during recovery"
);
assert!(
!fixture
.client
.is_event_on_relay(fixture.pr_update.id)
.await
.expect("query tombstoned pr update"),
"tombstoned PR update must not resurrect during recovery"
);
let pr_ref = format!("refs/nostr/{}", fixture.pr.id.to_hex());
let pr_update_ref = format!("refs/nostr/{}", fixture.pr_update.id.to_hex());
assert!(
bare_ref_value(&fixture.repo_path, &pr_ref).is_none(),
"tombstoned PR should have refs/nostr entry cleaned during recovery"
);
assert!(
bare_ref_value(&fixture.repo_path, &pr_update_ref).is_none(),
"tombstoned PR update should have refs/nostr entry cleaned during recovery"
);
fixture.relay.stop().await;
}
#[tokio::test]
async fn repeated_purgatory_promotion_attempts_remain_idempotent() {
let fixture = setup_parked_announcement_with_failed_ingest_recovery(