upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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::*;