mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user