From 6de1d47316bffc5f15eae8519032adb64622c189 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 29 Sep 2026 11:19:31 +0000 Subject: [PATCH] fix(git): promote staged history before State acceptance A State can adopt an unsigned upload already present in its owner view without another push. Previously this accepted history remained disposable and compaction could remove it after the last ref moved. Promote every concrete branch and tag tip under the family lease before direct State acceptance or purgatory release. Failed promotion leaves the event unaccepted and prevents ref alignment. Existing complete family roots remain the promotion integrity boundary; authorization and rollback selection are unchanged. Validation: cargo test --test pending_upload_staging passed all 60 tests, including direct and waiting State adoption followed by ref removal and compaction. cargo fmt and git diff --check passed. Assisted-by: GPT-6 --- docs/explanation/git-family-object-storage.md | 9 ++- src/git/staging.rs | 27 ++++++- src/git/sync.rs | 10 +++ src/nostr/policy/state.rs | 6 ++ tests/pending_upload_staging.rs | 76 +++++++++++++++++++ 5 files changed, 124 insertions(+), 4 deletions(-) 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; +}