diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-26 12:47:12 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-26 15:26:18 +0000 |
| commit | ee68ccadce6a6c90747cbdaae557babb4683413e (patch) | |
| tree | c3be43110d6f9d0f82fb56c9b839644e51b4788f /src/bin/git_remote_nostr | |
| parent | f252dd0f1fb7374b5b6d44e77facdc902ee52c43 (diff) | |
fix: rollback local state event cache on total push failure
When all git servers reject or skip a push, delete the newly-published
state event from the local nostr cache and restore the previous state
event (if any), so that a subsequent retry starts from a clean baseline
rather than a state that no server ever accepted.
Diffstat (limited to 'src/bin/git_remote_nostr')
| -rw-r--r-- | src/bin/git_remote_nostr/push.rs | 136 |
1 files changed, 104 insertions, 32 deletions
diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 2ab01cb..e5c33b6 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs | |||
| @@ -6,7 +6,10 @@ use std::{ | |||
| 6 | }; | 6 | }; |
| 7 | 7 | ||
| 8 | use anyhow::{Context, Result, bail}; | 8 | use anyhow::{Context, Result, bail}; |
| 9 | use client::{get_events_from_local_cache, get_state_from_cache, send_events, sign_event}; | 9 | use client::{ |
| 10 | delete_event_from_local_cache, get_events_from_local_cache, get_state_from_cache, send_events, | ||
| 11 | sign_event, | ||
| 12 | }; | ||
| 10 | use console::Term; | 13 | use console::Term; |
| 11 | use git::{RepoActions, sha1_to_oid}; | 14 | use git::{RepoActions, sha1_to_oid}; |
| 12 | use git_events::{ | 15 | use git_events::{ |
| @@ -15,7 +18,9 @@ use git_events::{ | |||
| 15 | use git2::{Oid, Repository}; | 18 | use git2::{Oid, Repository}; |
| 16 | use ngit::{ | 19 | use ngit::{ |
| 17 | accept_maintainership::accept_maintainership_with_defaults, | 20 | accept_maintainership::accept_maintainership_with_defaults, |
| 18 | client::{self, get_event_from_cache_by_id}, | 21 | client::{ |
| 22 | self, get_event_from_cache_by_id, get_filter_state_events, save_event_in_local_cache, | ||
| 23 | }, | ||
| 19 | git::{self, nostr_url::NostrUrlDecoded}, | 24 | git::{self, nostr_url::NostrUrlDecoded}, |
| 20 | git_events::{ | 25 | git_events::{ |
| 21 | self, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, get_event_root, | 26 | self, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, get_event_root, |
| @@ -128,19 +133,24 @@ pub async fn run_push( | |||
| 128 | 133 | ||
| 129 | // all refspecs aren't rejected | 134 | // all refspecs aren't rejected |
| 130 | if !(git_state_refspecs.is_empty() && proposal_refspecs.is_empty()) { | 135 | if !(git_state_refspecs.is_empty() && proposal_refspecs.is_empty()) { |
| 131 | let (rejected_proposal_refspecs, rejected, relay_results) = | 136 | let ( |
| 132 | create_and_publish_events_and_proposals( | 137 | rejected_proposal_refspecs, |
| 133 | git_repo, | 138 | rejected, |
| 134 | repo_ref, | 139 | relay_results, |
| 135 | &git_state_refspecs, | 140 | old_state_event, |
| 136 | &proposal_refspecs, | 141 | new_state_event_id, |
| 137 | client, // &mut Client | 142 | ) = create_and_publish_events_and_proposals( |
| 138 | existing_state, | 143 | git_repo, |
| 139 | &term, | 144 | repo_ref, |
| 140 | title_description.as_ref(), | 145 | &git_state_refspecs, |
| 141 | &git_server_push_options, | 146 | &proposal_refspecs, |
| 142 | ) | 147 | client, // &mut Client |
| 143 | .await?; | 148 | existing_state, |
| 149 | &term, | ||
| 150 | title_description.as_ref(), | ||
| 151 | &git_server_push_options, | ||
| 152 | ) | ||
| 153 | .await?; | ||
| 144 | 154 | ||
| 145 | if !rejected { | 155 | if !rejected { |
| 146 | for refspec in git_state_refspecs.iter().chain(proposal_refspecs.iter()) { | 156 | for refspec in git_state_refspecs.iter().chain(proposal_refspecs.iter()) { |
| @@ -167,12 +177,8 @@ pub async fn run_push( | |||
| 167 | .filter(|refspec| git_state_refspecs.contains(refspec)) | 177 | .filter(|refspec| git_state_refspecs.contains(refspec)) |
| 168 | .cloned() | 178 | .cloned() |
| 169 | .collect::<Vec<String>>(); | 179 | .collect::<Vec<String>>(); |
| 170 | if is_grasp_server_clone_url(&git_server_url) | 180 | if is_grasp_server_clone_url(&git_server_url) && !relay_results.is_empty() { |
| 171 | && !relay_results.is_empty() | 181 | if let Ok(relay_url) = format_grasp_server_url_as_relay_url(&git_server_url) { |
| 172 | { | ||
| 173 | if let Ok(relay_url) = | ||
| 174 | format_grasp_server_url_as_relay_url(&git_server_url) | ||
| 175 | { | ||
| 176 | let relay_failed = relay_results | 182 | let relay_failed = relay_results |
| 177 | .iter() | 183 | .iter() |
| 178 | .any(|(url, succeeded)| url == &relay_url && !succeeded); | 184 | .any(|(url, succeeded)| url == &relay_url && !succeeded); |
| @@ -190,19 +196,23 @@ pub async fn run_push( | |||
| 190 | 196 | ||
| 191 | // If all git servers were skipped and there were refspecs to push, | 197 | // If all git servers were skipped and there were refspecs to push, |
| 192 | // emit error lines for each ref using the git remote helper protocol | 198 | // emit error lines for each ref using the git remote helper protocol |
| 199 | // and roll back the state event in the local cache | ||
| 193 | if servers_to_push.is_empty() && !git_state_refspecs.is_empty() { | 200 | if servers_to_push.is_empty() && !git_state_refspecs.is_empty() { |
| 194 | for refspec in &git_state_refspecs { | 201 | for refspec in &git_state_refspecs { |
| 195 | let (_, to) = refspec_to_from_to(refspec)?; | 202 | let (_, to) = refspec_to_from_to(refspec)?; |
| 196 | println!( | 203 | println!("error {to} state event failed to reach any git server relay"); |
| 197 | "error {to} state event failed to reach any git server relay" | 204 | } |
| 198 | ); | 205 | if let Some(new_id) = new_state_event_id { |
| 206 | rollback_state_event(git_repo.get_path()?, new_id, old_state_event.as_ref()) | ||
| 207 | .await; | ||
| 199 | } | 208 | } |
| 200 | } else { | 209 | } else { |
| 210 | let mut any_push_succeeded = false; | ||
| 201 | for (git_server_url, server_refspecs) in &servers_to_push { | 211 | for (git_server_url, server_refspecs) in &servers_to_push { |
| 202 | if !server_refspecs.is_empty() { | 212 | if !server_refspecs.is_empty() { |
| 203 | let push_options_refs: Vec<&str> = | 213 | let push_options_refs: Vec<&str> = |
| 204 | git_server_push_options.iter().map(String::as_str).collect(); | 214 | git_server_push_options.iter().map(String::as_str).collect(); |
| 205 | let _ = push_to_remote( | 215 | if push_to_remote( |
| 206 | git_repo, | 216 | git_repo, |
| 207 | git_server_url, | 217 | git_server_url, |
| 208 | &repo_ref.to_nostr_git_url(&None), | 218 | &repo_ref.to_nostr_git_url(&None), |
| @@ -210,7 +220,22 @@ pub async fn run_push( | |||
| 210 | &term, | 220 | &term, |
| 211 | is_grasp_server_clone_url(git_server_url), | 221 | is_grasp_server_clone_url(git_server_url), |
| 212 | &push_options_refs, | 222 | &push_options_refs, |
| 213 | ); | 223 | ) |
| 224 | .is_ok() | ||
| 225 | { | ||
| 226 | any_push_succeeded = true; | ||
| 227 | } | ||
| 228 | } | ||
| 229 | } | ||
| 230 | // If every git server push failed, roll back the state event | ||
| 231 | if !any_push_succeeded && !git_state_refspecs.is_empty() { | ||
| 232 | if let Some(new_id) = new_state_event_id { | ||
| 233 | rollback_state_event( | ||
| 234 | git_repo.get_path()?, | ||
| 235 | new_id, | ||
| 236 | old_state_event.as_ref(), | ||
| 237 | ) | ||
| 238 | .await; | ||
| 214 | } | 239 | } |
| 215 | } | 240 | } |
| 216 | } | 241 | } |
| @@ -221,6 +246,25 @@ pub async fn run_push( | |||
| 221 | Ok(()) | 246 | Ok(()) |
| 222 | } | 247 | } |
| 223 | 248 | ||
| 249 | /// Remove the newly-published state event from the local nostr cache and | ||
| 250 | /// restore the previous state event (if any). This prevents a subsequent | ||
| 251 | /// `ngit sync` or push from using a state that no git server ever accepted. | ||
| 252 | async fn rollback_state_event( | ||
| 253 | git_repo_path: &std::path::Path, | ||
| 254 | new_state_event_id: EventId, | ||
| 255 | old_state_event: Option<&Event>, | ||
| 256 | ) { | ||
| 257 | if let Err(e) = delete_event_from_local_cache(git_repo_path, new_state_event_id).await { | ||
| 258 | eprintln!("WARNING: failed to roll back state event from local cache: {e}"); | ||
| 259 | return; | ||
| 260 | } | ||
| 261 | if let Some(old_event) = old_state_event { | ||
| 262 | if let Err(e) = save_event_in_local_cache(git_repo_path, old_event).await { | ||
| 263 | eprintln!("WARNING: failed to restore previous state event in local cache: {e}"); | ||
| 264 | } | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 224 | #[allow(clippy::too_many_lines)] | 268 | #[allow(clippy::too_many_lines)] |
| 225 | #[allow(clippy::too_many_arguments)] | 269 | #[allow(clippy::too_many_arguments)] |
| 226 | async fn create_and_publish_events_and_proposals( | 270 | async fn create_and_publish_events_and_proposals( |
| @@ -233,7 +277,13 @@ async fn create_and_publish_events_and_proposals( | |||
| 233 | term: &Term, | 277 | term: &Term, |
| 234 | title_description: Option<&(String, String)>, | 278 | title_description: Option<&(String, String)>, |
| 235 | git_server_push_options: &[String], | 279 | git_server_push_options: &[String], |
| 236 | ) -> Result<(Vec<String>, bool, Vec<(String, bool)>)> { | 280 | ) -> Result<( |
| 281 | Vec<String>, | ||
| 282 | bool, | ||
| 283 | Vec<(String, bool)>, | ||
| 284 | Option<Event>, | ||
| 285 | Option<EventId>, | ||
| 286 | )> { | ||
| 237 | let (signer, mut user_ref, _) = load_existing_login( | 287 | let (signer, mut user_ref, _) = load_existing_login( |
| 238 | &Some(git_repo), | 288 | &Some(git_repo), |
| 239 | &None, | 289 | &None, |
| @@ -256,7 +306,7 @@ async fn create_and_publish_events_and_proposals( | |||
| 256 | ); | 306 | ); |
| 257 | } | 307 | } |
| 258 | if proposal_refspecs.is_empty() { | 308 | if proposal_refspecs.is_empty() { |
| 259 | return Ok((vec![], true, vec![])); | 309 | return Ok((vec![], true, vec![], None, None)); |
| 260 | } | 310 | } |
| 261 | } else if repo_ref | 311 | } else if repo_ref |
| 262 | .maintainers_without_annoucnement | 312 | .maintainers_without_annoucnement |
| @@ -274,6 +324,8 @@ async fn create_and_publish_events_and_proposals( | |||
| 274 | } | 324 | } |
| 275 | 325 | ||
| 276 | let mut events = vec![]; | 326 | let mut events = vec![]; |
| 327 | let mut old_state_event: Option<Event> = None; | ||
| 328 | let mut new_state_event_id: Option<EventId> = None; | ||
| 277 | 329 | ||
| 278 | if !git_server_refspecs.is_empty() { | 330 | if !git_server_refspecs.is_empty() { |
| 279 | let new_state = generate_updated_state(git_repo, &existing_state, git_server_refspecs)?; | 331 | let new_state = generate_updated_state(git_repo, &existing_state, git_server_refspecs)?; |
| @@ -286,8 +338,22 @@ async fn create_and_publish_events_and_proposals( | |||
| 286 | }; | 338 | }; |
| 287 | 339 | ||
| 288 | if store_state { | 340 | if store_state { |
| 341 | // Capture the existing state event before publishing the new one, | ||
| 342 | // so we can restore it if all git server pushes fail. | ||
| 343 | old_state_event = get_events_from_local_cache( | ||
| 344 | git_repo.get_path()?, | ||
| 345 | vec![get_filter_state_events(&repo_ref.coordinates(), true)], | ||
| 346 | ) | ||
| 347 | .await | ||
| 348 | .ok() | ||
| 349 | .and_then(|mut events| { | ||
| 350 | events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); | ||
| 351 | events.into_iter().next() | ||
| 352 | }); | ||
| 353 | |||
| 289 | let new_repo_state = | 354 | let new_repo_state = |
| 290 | RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; | 355 | RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; |
| 356 | new_state_event_id = Some(new_repo_state.event.id); | ||
| 291 | events.push(new_repo_state.event); | 357 | events.push(new_repo_state.event); |
| 292 | } | 358 | } |
| 293 | 359 | ||
| @@ -336,7 +402,9 @@ async fn create_and_publish_events_and_proposals( | |||
| 336 | 402 | ||
| 337 | // TODO check whether tip of each branch pushed is on at least one git server | 403 | // TODO check whether tip of each branch pushed is on at least one git server |
| 338 | // before broadcasting the nostr state | 404 | // before broadcasting the nostr state |
| 339 | let relay_results = if !events.is_empty() { | 405 | let relay_results = if events.is_empty() { |
| 406 | vec![] | ||
| 407 | } else { | ||
| 340 | send_events( | 408 | send_events( |
| 341 | client, | 409 | client, |
| 342 | Some(git_repo.get_path()?), | 410 | Some(git_repo.get_path()?), |
| @@ -347,10 +415,14 @@ async fn create_and_publish_events_and_proposals( | |||
| 347 | false, | 415 | false, |
| 348 | ) | 416 | ) |
| 349 | .await? | 417 | .await? |
| 350 | } else { | ||
| 351 | vec![] | ||
| 352 | }; | 418 | }; |
| 353 | Ok((rejected_proposal_refspecs, false, relay_results)) | 419 | Ok(( |
| 420 | rejected_proposal_refspecs, | ||
| 421 | false, | ||
| 422 | relay_results, | ||
| 423 | old_state_event, | ||
| 424 | new_state_event_id, | ||
| 425 | )) | ||
| 354 | } | 426 | } |
| 355 | 427 | ||
| 356 | #[allow(clippy::too_many_lines)] | 428 | #[allow(clippy::too_many_lines)] |