diff --git a/docs/explanation/git-family-object-storage.md b/docs/explanation/git-family-object-storage.md index 319f33f..95e1502 100644 --- a/docs/explanation/git-family-object-storage.md +++ b/docs/explanation/git-family-object-storage.md @@ -209,7 +209,7 @@ cost follows the new history instead of the whole repository. Detecting damage behind a root remains the integrity pass's job; a damaged root fails the check and therefore the promotion. -Promotion happens in two places: +Promotion happens at these boundaries: - **PR acceptance.** A PR or PR Update event is accepted only after its tip is promoted. On failure the event is rejected and its placeholder is kept, so @@ -217,8 +217,11 @@ Promotion happens in two places: - **Signed push into a staged view.** Its signed tips are promoted when receive-pack finishes, while the push still holds the family lease. -State events never wait on promotion. Their history is in the family when the -push that carries it completes. +- **State acceptance and purgatory release.** All concrete branch and tag tips + are promoted before ref alignment and acceptance. A State can adopt an + unsigned upload already in the view without another push. Failure rejects + a directly submitted State or leaves a waiting State in purgatory; its + history must become durable before later ref changes can make it disposable. ### Owed history diff --git a/src/git/staging.rs b/src/git/staging.rs index e68e6a7..ee08eb7 100644 --- a/src/git/staging.rs +++ b/src/git/staging.rs @@ -587,6 +587,31 @@ fn has_local_objects(view: &Path) -> Result { /// A view without staged objects received its history into the family, so /// there is nothing to copy and no lease is taken. pub async fn promote_accepted(view: &Path, tip: Tip) -> Result<()> { + promote_accepted_tips(view, vec![tip]).await +} + +/// Promote every concrete State tip before accepting or releasing the event. +/// A State can adopt an unsigned upload without a subsequent Git push. +pub async fn promote_state( + view: &Path, + state: &crate::nostr::events::RepositoryState, +) -> Result<()> { + let tips = state + .branches + .iter() + .map(|branch| Tip::new(format!("refs/heads/{}", branch.name), &branch.commit)) + .chain( + state + .tags + .iter() + .map(|tag| Tip::new(format!("refs/tags/{}", tag.name), &tag.commit)), + ) + .filter(|tip| !tip.oid.starts_with("ref: ")) + .collect(); + promote_accepted_tips(view, tips).await +} + +async fn promote_accepted_tips(view: &Path, tips: Vec) -> Result<()> { if !is_staged(view) { return Ok(()); } @@ -597,7 +622,7 @@ pub async fn promote_accepted(view: &Path, tip: Tip) -> Result<()> { let path = resolved.path.clone(); tokio::task::spawn_blocking(move || { let _lease = lease; - promote(&path, std::slice::from_ref(&tip)) + promote(&path, &tips) }) .await??; request_maintenance(view); diff --git a/src/git/sync.rs b/src/git/sync.rs index feda90b..a7c72f2 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs @@ -1169,6 +1169,16 @@ async fn process_purgatory_state_events( "State event author authorized via maintainer set" ); + // An unsigned upload can satisfy a waiting State. Do not release it + // from purgatory until its history is durable independently of staging. + if let Err(error) = crate::git::staging::promote_state(source_repo_path, &state).await { + result.errors.push(format!( + "State {} history could not be stored durably: {error:#}", + entry.event.id + )); + continue; + } + // Use unified processing function let process_result = crate::git::process::process_state_with_git_data( &state, diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index f856d55..826624c 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs @@ -218,6 +218,12 @@ impl StatePolicy { event.id, ); + // Ref alignment can consume staged history without another push. + // Make it durable before accepting the State or moving any refs. + git::staging::promote_state(&repo_with_git_data, &state) + .await + .context("State history could not be stored durably")?; + // Use unified processing function let result = crate::git::process::process_state_with_git_data( &state, diff --git a/tests/pending_upload_staging.rs b/tests/pending_upload_staging.rs index 696cd52..463c056 100644 --- a/tests/pending_upload_staging.rs +++ b/tests/pending_upload_staging.rs @@ -286,3 +286,79 @@ async fn maintainer_push_into_a_staged_view_reaches_the_family() { served.relay.stop().await; } + +async fn state_adopts_unsigned_upload(state_first: bool) { + let served = Served::start("state-adopts-unsigned").await; + let original = ngit_grasp::git::get_ref_commit(&served.view, "refs/heads/main").unwrap(); + let local = tempfile::tempdir().unwrap(); + let tip = common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let pending = served.pr_event(&tip, "never published"); + let state = common::event_ordering::event_after( + common::create_state_event( + served.client.keys(), + &served.identifier, + &[("main", &tip)], + &[("staged-tag", &tip)], + &[], + &[], + ) + .unwrap(), + served.client.keys(), + served.state.created_at, + ); + if state_first { + served + .client + .send_event_and_note_purgatory(state.clone()) + .await + .unwrap(); + } + served.push(local.path(), &tip, &pending); + if !state_first { + served.accept(&state).await; + } + common::wait_for_event_served(served.relay.url(), &state.id, DEADLINE) + .await + .unwrap(); + assert_eq!( + ngit_grasp::git::get_ref_commit(&served.view, "refs/heads/main"), + Some(tip.clone()) + ); + assert!( + holds_history(&served.family, &tip), + "accepted State history must leave staging" + ); + + let next = common::event_ordering::event_after( + common::create_state_event( + served.client.keys(), + &served.identifier, + &[("main", &original)], + &[], + &[], + &[], + ) + .unwrap(), + served.client.keys(), + state.created_at, + ); + served.accept(&next).await; + // Stop the writer before simulating expiry and compaction directly. + served.relay.stop().await; + ngit_grasp::git::delete_ref(&served.view, &format!("refs/nostr/{}", pending.id)).unwrap(); + staging::compact(&served.view).unwrap(); + assert!( + holds_history(&served.family, &tip), + "rollback history survives ref removal" + ); +} + +#[tokio::test] +async fn arriving_state_promotes_an_existing_unsigned_upload() { + state_adopts_unsigned_upload(false).await; +} + +#[tokio::test] +async fn waiting_state_promotes_an_unsigned_upload_before_release() { + state_adopts_unsigned_upload(true).await; +}