upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-26 14:00:12 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-26 15:26:20 +0000
commitd8b85cbce5cba9bfb8b15a8bd5c1b7200ff3e488 (patch)
tree608d535034e73fe61c5edbf1bbc3c51621f70faa
parentb85683201250e97a30bfe7a5dbba5508f8e86f65 (diff)
fix: advertise only state events with resolvable git objects
git-remote-nostr now walks the per-relay state events captured in FetchReport::state_per_relay (newest first) and advertises the first one whose every OID is either present on at least one git server (confirmed via list_refs) or already available locally. If no such state event exists it falls back to the raw git server state. Previously the latest nostr state event was always used regardless of whether its OIDs had been pushed to any server, causing catastrophic missing-object errors during clone or fetch when a state event was published ahead of the corresponding git push.
-rw-r--r--CHANGELOG.md1
-rw-r--r--src/bin/git_remote_nostr/list.rs56
-rw-r--r--src/bin/git_remote_nostr/main.rs18
-rw-r--r--tests/git_remote_nostr/list.rs261
4 files changed, 327 insertions, 9 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e4fdbd..4130ce2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14 14
15### Fixed 15### Fixed
16 16
17- `git-remote-nostr` list now advertises the newest state event whose OIDs are all confirmed present on a git server or locally, rather than unconditionally using the latest nostr state event; this prevents catastrophic fetch/clone failures when a state event was published before the corresponding git push completed
17- Tag tracking refs written with wrong path (`refs/remotes/origin/refs/tags/v1.0.0` instead of `refs/remotes/origin/v1.0.0`) after a push via `git-remote-nostr`, causing `ngit sync` to fail with "src refspec does not match any existing object" when syncing tags 18- Tag tracking refs written with wrong path (`refs/remotes/origin/refs/tags/v1.0.0` instead of `refs/remotes/origin/v1.0.0`) after a push via `git-remote-nostr`, causing `ngit sync` to fail with "src refspec does not match any existing object" when syncing tags
18- `ngit sync` using wrong refspec source (`refs/remotes/origin/refs/heads/master` instead of `refs/remotes/origin/master`), causing sync to fail with "src refspec does not match any existing object" 19- `ngit sync` using wrong refspec source (`refs/remotes/origin/refs/heads/master` instead of `refs/remotes/origin/master`), causing sync to fail with "src refspec does not match any existing object"
19- State event publish failures silently swallowed during push; summary now shows `"Published to X/N relays (failed: relay1 relay2)"` instead of unconditional success message 20- State event publish failures silently swallowed during push; summary now shows `"Published to X/N relays (failed: relay1 relay2)"` instead of unconditional success message
diff --git a/src/bin/git_remote_nostr/list.rs b/src/bin/git_remote_nostr/list.rs
index 4a7c1ec..a32ed67 100644
--- a/src/bin/git_remote_nostr/list.rs
+++ b/src/bin/git_remote_nostr/list.rs
@@ -4,13 +4,14 @@ use anyhow::{Context, Result};
4use client::get_state_from_cache; 4use client::get_state_from_cache;
5use git::RepoActions; 5use git::RepoActions;
6use ngit::{ 6use ngit::{
7 client::{self, is_verbose}, 7 client::{self, FetchReport, is_verbose},
8 fetch::fetch_from_git_server, 8 fetch::fetch_from_git_server,
9 git::{self}, 9 git::{self},
10 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, tag_value}, 10 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, tag_value},
11 list::list_from_remotes, 11 list::list_from_remotes,
12 login::get_curent_user, 12 login::get_curent_user,
13 repo_ref::{self}, 13 repo_ref::{self},
14 repo_state::RepoState,
14 utils::{get_all_proposals, get_open_or_draft_proposals}, 15 utils::{get_all_proposals, get_open_or_draft_proposals},
15}; 16};
16use repo_ref::RepoRef; 17use repo_ref::RepoRef;
@@ -22,6 +23,7 @@ pub async fn run_list(
22 git_repo: &Repo, 23 git_repo: &Repo,
23 repo_ref: &RepoRef, 24 repo_ref: &RepoRef,
24 for_push: bool, 25 for_push: bool,
26 fetch_report: &FetchReport,
25) -> Result<HashMap<String, (HashMap<String, String>, bool)>> { 27) -> Result<HashMap<String, (HashMap<String, String>, bool)>> {
26 let nostr_state = (get_state_from_cache(Some(git_repo.get_path()?), repo_ref).await).ok(); 28 let nostr_state = (get_state_from_cache(Some(git_repo.get_path()?), repo_ref).await).ok();
27 29
@@ -30,6 +32,8 @@ pub async fn run_list(
30 if is_verbose() { 32 if is_verbose() {
31 term.write_line("git servers: listing refs...")?; 33 term.write_line("git servers: listing refs...")?;
32 } 34 }
35 // nostr_state is passed to list_from_remotes only for the sync-status
36 // display; the actual ref state we advertise is determined below.
33 let remote_states = list_from_remotes( 37 let remote_states = list_from_remotes(
34 &term, 38 &term,
35 git_repo, 39 git_repo,
@@ -39,9 +43,55 @@ pub async fn run_list(
39 ) 43 )
40 .await; 44 .await;
41 45
42 let mut state = if let Some(nostr_state) = nostr_state { 46 // Collect all OIDs confirmed present on at least one git server.
43 nostr_state.state 47 let git_server_oids: std::collections::HashSet<String> = remote_states
48 .values()
49 .flat_map(|(state, _)| state.values())
50 .filter(|v| !v.starts_with("ref: "))
51 .cloned()
52 .collect();
53
54 // From the per-relay state events captured during the nostr fetch, find
55 // the newest state event whose every OID is either:
56 // (a) confirmed present on at least one git server, or
57 // (b) already available locally.
58 // This prevents advertising refs whose git objects haven't been pushed to
59 // any server yet, which would cause `git clone` / `git fetch` to fail.
60 let mut candidates: Vec<&nostr::Event> = fetch_report
61 .state_per_relay
62 .values()
63 .filter_map(|maybe| maybe.as_ref())
64 .collect();
65 // Sort newest-first (by created_at, then by id for tie-breaking).
66 candidates.sort_by(|a, b| {
67 b.created_at
68 .cmp(&a.created_at)
69 .then_with(|| b.id.cmp(&a.id))
70 });
71 // Deduplicate by event id so we don't check the same event twice.
72 candidates.dedup_by_key(|e| e.id);
73
74 let best_state: Option<HashMap<String, String>> = candidates.into_iter().find_map(|event| {
75 if let Ok(rs) = RepoState::try_from(vec![event.clone()]) {
76 let all_resolvable = rs.state.values().all(|v| {
77 v.starts_with("ref: ")
78 || git_server_oids.contains(v)
79 || git_repo.does_commit_exist(v).is_ok_and(|exists| exists)
80 });
81 if all_resolvable { Some(rs.state) } else { None }
82 } else {
83 None
84 }
85 });
86
87 let mut state = if let Some(state) = best_state {
88 state
44 } else { 89 } else {
90 // No relay returned a state event whose OIDs are all resolvable
91 // (either no state events were seen on any relay, or every candidate
92 // references git objects not yet on any server). Fall back to
93 // whatever the git servers actually report so we never advertise OIDs
94 // that cannot be fetched.
45 let (state, _is_grasp_server) = repo_ref 95 let (state, _is_grasp_server) = repo_ref
46 .git_server 96 .git_server
47 .iter() 97 .iter()
diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs
index 6186ed3..dad8a99 100644
--- a/src/bin/git_remote_nostr/main.rs
+++ b/src/bin/git_remote_nostr/main.rs
@@ -12,7 +12,9 @@ use std::{
12}; 12};
13 13
14use anyhow::{Context, Result, bail}; 14use anyhow::{Context, Result, bail};
15use client::{Connect, consolidate_fetch_reports, get_repo_ref_from_cache, is_verbose}; 15use client::{
16 Connect, FetchReport, consolidate_fetch_reports, get_repo_ref_from_cache, is_verbose,
17};
16use git::{RepoActions, nostr_url::NostrUrlDecoded}; 18use git::{RepoActions, nostr_url::NostrUrlDecoded};
17use ngit::{ 19use ngit::{
18 client::{self, Params}, 20 client::{self, Params},
@@ -149,7 +151,9 @@ async fn main() -> Result<()> {
149 client.set_signer(signer).await; 151 client.set_signer(signer).await;
150 } 152 }
151 153
152 fetching_with_report_for_helper(git_repo_path, &client, &decoded_nostr_url.coordinate).await?; 154 let fetch_report =
155 fetching_with_report_for_helper(git_repo_path, &client, &decoded_nostr_url.coordinate)
156 .await?;
153 157
154 let mut repo_ref = 158 let mut repo_ref =
155 get_repo_ref_from_cache(Some(git_repo_path), &decoded_nostr_url.coordinate).await?; 159 get_repo_ref_from_cache(Some(git_repo_path), &decoded_nostr_url.coordinate).await?;
@@ -221,10 +225,12 @@ async fn main() -> Result<()> {
221 push_options = PushOptions::default(); 225 push_options = PushOptions::default();
222 } 226 }
223 ["list"] => { 227 ["list"] => {
224 list_outputs = Some(list::run_list(&git_repo, &repo_ref, false).await?); 228 list_outputs =
229 Some(list::run_list(&git_repo, &repo_ref, false, &fetch_report).await?);
225 } 230 }
226 ["list", "for-push"] => { 231 ["list", "for-push"] => {
227 list_outputs = Some(list::run_list(&git_repo, &repo_ref, true).await?); 232 list_outputs =
233 Some(list::run_list(&git_repo, &repo_ref, true, &fetch_report).await?);
228 } 234 }
229 [] => { 235 [] => {
230 return Ok(()); 236 return Ok(());
@@ -283,7 +289,7 @@ async fn fetching_with_report_for_helper(
283 git_repo_path: &Path, 289 git_repo_path: &Path,
284 client: &Client, 290 client: &Client,
285 trusted_maintainer_coordinate: &Nip19Coordinate, 291 trusted_maintainer_coordinate: &Nip19Coordinate,
286) -> Result<()> { 292) -> Result<FetchReport> {
287 let term = console::Term::stderr(); 293 let term = console::Term::stderr();
288 let verbose = is_verbose(); 294 let verbose = is_verbose();
289 if verbose { 295 if verbose {
@@ -308,7 +314,7 @@ async fn fetching_with_report_for_helper(
308 } else { 314 } else {
309 term.write_line(&format!("nostr updates: {report}"))?; 315 term.write_line(&format!("nostr updates: {report}"))?;
310 } 316 }
311 Ok(()) 317 Ok(report)
312} 318}
313 319
314#[cfg(test)] 320#[cfg(test)]
diff --git a/tests/git_remote_nostr/list.rs b/tests/git_remote_nostr/list.rs
index 88bd3f7..71e7114 100644
--- a/tests/git_remote_nostr/list.rs
+++ b/tests/git_remote_nostr/list.rs
@@ -151,6 +151,267 @@ mod with_state_announcement {
151 Ok(()) 151 Ok(())
152 } 152 }
153 } 153 }
154 mod when_state_event_references_oids_not_on_git_server {
155
156 use super::*;
157
158 /// Regression test for the bug where a state event published ahead of
159 /// the corresponding `git push` caused `git clone` / `git fetch` to
160 /// fail with missing-object errors.
161 ///
162 /// The fix walks per-relay state events newest-first and picks the
163 /// first one whose every OID is either present on a git server or
164 /// already available locally. When no such event exists it falls back
165 /// to the raw git-server state.
166 #[tokio::test]
167 #[serial]
168 async fn falls_back_to_git_server_state() -> Result<()> {
169 // Build a real git repo that acts as the git server.
170 let source_git_repo = prep_git_repo()?;
171 std::fs::write(source_git_repo.dir.join("initial.md"), "initial")?;
172 let main_commit_id = source_git_repo.stage_and_commit("initial.md")?;
173
174 // Craft a state event that claims main points at a commit that has
175 // NOT been pushed to the git server yet (a plausible OID that does
176 // not exist anywhere).
177 let fake_oid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
178 let root_commit = "9ee507fc4357d7ee16a5d8901bedcd103f23c17d";
179 let state_event = nostr::event::EventBuilder::new(STATE_KIND, "")
180 .tags([
181 nostr::Tag::identifier(format!("{root_commit}-consider-it-random")),
182 nostr::Tag::custom(
183 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("HEAD")),
184 vec!["ref: refs/heads/main".to_string()],
185 ),
186 nostr::Tag::custom(
187 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("refs/heads/main")),
188 vec![fake_oid.to_string()],
189 ),
190 ])
191 .sign_with_keys(&TEST_KEY_1_KEYS)
192 .unwrap();
193
194 let git_repo = prep_git_repo()?;
195 let events = vec![
196 generate_test_key_1_metadata_event("fred"),
197 generate_test_key_1_relay_list_event(),
198 generate_repo_ref_event_with_git_server(vec![
199 source_git_repo.dir.to_str().unwrap().to_string(),
200 ]),
201 state_event,
202 ];
203 // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57)
204 let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = (
205 Relay::new(8051, None, None),
206 Relay::new(8052, None, None),
207 Relay::new(8053, None, None),
208 Relay::new(8055, None, None),
209 Relay::new(8056, None, None),
210 Relay::new(8057, None, None),
211 );
212 r51.events = events.clone();
213 r55.events = events;
214
215 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
216 let mut p = cli_tester_after_fetch(&git_repo)?;
217 p.send_line("list")?;
218 p.expect("git servers: listing refs...\r\n")?;
219 let res = p.expect_eventually("\r\n\r\n")?;
220 p.exit()?;
221 for p in [51, 52, 53, 55, 56, 57] {
222 relay::shutdown_relay(8000 + p)?;
223 }
224 let lines: HashSet<String> = res
225 .split("\r\n")
226 .map(|e| e.to_string())
227 .filter(|s| {
228 !s.contains("remote: ")
229 && !s.contains("Receiving objects")
230 && !s.contains("Resolving deltas")
231 && !s.contains("fetching /")
232 })
233 .collect();
234 // The fake OID must NOT appear – the list must fall back to
235 // what the git server actually has.
236 assert!(
237 !lines.iter().any(|l| l.contains(fake_oid)),
238 "fake OID from unresolvable state event must not be advertised; got: {lines:?}"
239 );
240 // The real commit that IS on the git server must be advertised.
241 assert!(
242 lines.contains(&format!("{main_commit_id} refs/heads/main")),
243 "real git-server commit must be advertised; got: {lines:?}"
244 );
245 Ok(())
246 });
247 // launch relays
248 let _ = join!(
249 r51.listen_until_close(),
250 r52.listen_until_close(),
251 r53.listen_until_close(),
252 r55.listen_until_close(),
253 r56.listen_until_close(),
254 r57.listen_until_close(),
255 );
256 cli_tester_handle.join().unwrap()?;
257 Ok(())
258 }
259 }
260
261 mod when_newer_relay_state_has_missing_oid_but_older_relay_state_is_resolvable {
262
263 use super::*;
264
265 /// Two relays serve different state events; two git servers each have
266 /// different OIDs.
267 ///
268 /// - Relay 55 (repo relay A): **newer** state event → main = fake_oid
269 /// (not on any git server)
270 /// - Relay 56 (repo relay B): **older** state event → main = commit_a
271 /// (present on git_server_1)
272 /// - git_server_1: main = commit_a
273 /// - git_server_2: main = commit_b (a different real commit)
274 ///
275 /// Expected: `list` skips the newer unresolvable event and advertises
276 /// `commit_a` from the older-but-resolvable state event.
277 #[tokio::test]
278 #[serial]
279 async fn uses_older_resolvable_state_event() -> Result<()> {
280 // --- git_server_1: has commit_a on main ---
281 let git_server_1 = prep_git_repo()?;
282 std::fs::write(git_server_1.dir.join("server1.md"), "server1")?;
283 let commit_a = git_server_1.stage_and_commit("server1.md")?;
284 let bare_server_1 = GitTestRepo::recreate_as_bare(&git_server_1)?;
285
286 // --- git_server_2: has commit_b on main (different commit) ---
287 let git_server_2 = prep_git_repo()?;
288 std::fs::write(git_server_2.dir.join("server2.md"), "server2")?;
289 let commit_b = git_server_2.stage_and_commit("server2.md")?;
290 let bare_server_2 = GitTestRepo::recreate_as_bare(&git_server_2)?;
291
292 assert_ne!(commit_a, commit_b);
293
294 let fake_oid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
295 let root_commit = "9ee507fc4357d7ee16a5d8901bedcd103f23c17d";
296 let identifier = format!("{root_commit}-consider-it-random");
297
298 // Older state event: main = commit_a (resolvable via git_server_1)
299 let older_state_event = make_event_old_or_change_user(
300 nostr::event::EventBuilder::new(STATE_KIND, "")
301 .tags([
302 nostr::Tag::identifier(identifier.clone()),
303 nostr::Tag::custom(
304 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("HEAD")),
305 vec!["ref: refs/heads/main".to_string()],
306 ),
307 nostr::Tag::custom(
308 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("refs/heads/main")),
309 vec![commit_a.to_string()],
310 ),
311 ])
312 .sign_with_keys(&TEST_KEY_1_KEYS)
313 .unwrap(),
314 &TEST_KEY_1_KEYS,
315 60, // 60 seconds old
316 );
317
318 // Newer state event: main = fake_oid (NOT on any git server)
319 let newer_state_event = nostr::event::EventBuilder::new(STATE_KIND, "")
320 .tags([
321 nostr::Tag::identifier(identifier.clone()),
322 nostr::Tag::custom(
323 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("HEAD")),
324 vec!["ref: refs/heads/main".to_string()],
325 ),
326 nostr::Tag::custom(
327 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("refs/heads/main")),
328 vec![fake_oid.to_string()],
329 ),
330 ])
331 .sign_with_keys(&TEST_KEY_1_KEYS)
332 .unwrap();
333
334 let git_repo = prep_git_repo()?;
335
336 // Base events (metadata + relay list + repo ref) go on both relays.
337 let repo_ref_event = generate_repo_ref_event_with_git_server(vec![
338 bare_server_1.dir.to_str().unwrap().to_string(),
339 bare_server_2.dir.to_str().unwrap().to_string(),
340 ]);
341 let base_events = vec![
342 generate_test_key_1_metadata_event("fred"),
343 generate_test_key_1_relay_list_event(),
344 repo_ref_event,
345 ];
346
347 // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57)
348 let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = (
349 Relay::new(8051, None, None),
350 Relay::new(8052, None, None),
351 Relay::new(8053, None, None),
352 Relay::new(8055, None, None),
353 Relay::new(8056, None, None),
354 Relay::new(8057, None, None),
355 );
356 r51.events = base_events.clone();
357 // r55 (repo relay A) serves the newer state event with the fake OID
358 r55.events = [base_events.clone(), vec![newer_state_event]].concat();
359 // r56 (repo relay B) serves the older state event with commit_a
360 r56.events = [base_events, vec![older_state_event]].concat();
361
362 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
363 let mut p = cli_tester_after_fetch(&git_repo)?;
364 p.send_line("list")?;
365 p.expect("git servers: listing refs...\r\n")?;
366 let res = p.expect_eventually("\r\n\r\n")?;
367 p.exit()?;
368 for p in [51, 52, 53, 55, 56, 57] {
369 relay::shutdown_relay(8000 + p)?;
370 }
371 let lines: HashSet<String> = res
372 .split("\r\n")
373 .map(|e| e.to_string())
374 .filter(|s| {
375 !s.contains("remote: ")
376 && !s.contains("Receiving objects")
377 && !s.contains("Resolving deltas")
378 && !s.contains("fetching /")
379 })
380 .collect();
381 // The fake OID from the newer-but-unresolvable state event must
382 // NOT appear.
383 assert!(
384 !lines.iter().any(|l| l.contains(fake_oid)),
385 "fake OID from newer unresolvable state event must not be advertised; got: {lines:?}"
386 );
387 // commit_a from the older-but-resolvable state event must be
388 // advertised for main.
389 assert!(
390 lines.contains(&format!("{commit_a} refs/heads/main")),
391 "commit_a from older resolvable state event must be advertised; got: {lines:?}"
392 );
393 // commit_b (only on git_server_2, not referenced by any state
394 // event) must NOT appear for main.
395 assert!(
396 !lines.contains(&format!("{commit_b} refs/heads/main")),
397 "commit_b from git_server_2 must not override the chosen state event; got: {lines:?}"
398 );
399 Ok(())
400 });
401 // launch relays
402 let _ = join!(
403 r51.listen_until_close(),
404 r52.listen_until_close(),
405 r53.listen_until_close(),
406 r55.listen_until_close(),
407 r56.listen_until_close(),
408 r57.listen_until_close(),
409 );
410 cli_tester_handle.join().unwrap()?;
411 Ok(())
412 }
413 }
414
154 mod when_announcement_doesnt_match_git_server { 415 mod when_announcement_doesnt_match_git_server {
155 416
156 use super::*; 417 use super::*;