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
This commit is contained in:
DanConwayDev
2026-09-29 11:19:31 +00:00
parent 629be17e64
commit 6de1d47316
5 changed files with 124 additions and 4 deletions
@@ -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
+26 -1
View File
@@ -587,6 +587,31 @@ fn has_local_objects(view: &Path) -> Result<bool> {
/// 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<Tip>) -> 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);
+10
View File
@@ -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,
+6
View File
@@ -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,
+76
View File
@@ -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;
}