upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/git_remote_nostr/main.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 16:44:43 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 17:01:07 +0100
commitc4c262a5e9bfeb30bc0106d9ea51dfce7e4fa1f3 (patch)
treed02ba9ab1d461147c6ae2ae5e98e785c553a999f /src/bin/git_remote_nostr/main.rs
parent90c53e2dc859b47615ebaa08199b7460615ce3e4 (diff)
refactor(remote): split into modules
to make it easier to read
Diffstat (limited to 'src/bin/git_remote_nostr/main.rs')
-rw-r--r--src/bin/git_remote_nostr/main.rs1542
1 files changed, 18 insertions, 1524 deletions
diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs
index 8d67552..7bfe3f4 100644
--- a/src/bin/git_remote_nostr/main.rs
+++ b/src/bin/git_remote_nostr/main.rs
@@ -1,44 +1,31 @@
1#![cfg_attr(not(test), warn(clippy::pedantic))] 1#![cfg_attr(not(test), warn(clippy::pedantic))]
2#![allow(clippy::large_futures)] 2#![allow(clippy::large_futures, clippy::module_name_repetitions)]
3// better solution to dead_code error on multiple binaries than https://stackoverflow.com/a/66196291 3// better solution to dead_code error on multiple binaries than https://stackoverflow.com/a/66196291
4#![allow(dead_code)] 4#![allow(dead_code)]
5#![cfg_attr(not(test), warn(clippy::expect_used))] 5#![cfg_attr(not(test), warn(clippy::expect_used))]
6 6
7use core::str; 7use core::str;
8use std::{ 8use std::{
9 collections::{HashMap, HashSet}, 9 collections::HashSet,
10 env, 10 env, io,
11 io::{self, Stdin},
12 path::{Path, PathBuf}, 11 path::{Path, PathBuf},
13 str::FromStr, 12 str::FromStr,
14}; 13};
15 14
16use anyhow::{anyhow, bail, Context, Result}; 15use anyhow::{bail, Context, Result};
17use auth_git2::GitAuthenticator; 16use client::{consolidate_fetch_reports, get_repo_ref_from_cache, Connect};
18use client::{ 17use git::{nostr_url::NostrUrlDecoded, RepoActions};
19 consolidate_fetch_reports, get_all_proposal_patch_events_from_cache, get_events_from_cache, 18use ngit::{client, git};
20 get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, get_state_from_cache, 19use nostr::nips::nip01::Coordinate;
21 send_events, sign_event, Connect, STATE_KIND, 20use utils::read_line;
22};
23use console::Term;
24use git::{nostr_url::NostrUrlDecoded, sha1_to_oid, RepoActions};
25use git2::{Oid, Repository};
26use git_events::{
27 event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events,
28 generate_patch_event, get_commit_id_from_patch, get_most_recent_patch_with_ancestors,
29 status_kinds, tag_value,
30};
31use ngit::{client, git, git_events, login, repo_ref, repo_state};
32use nostr::nips::{nip01::Coordinate, nip10::Marker};
33use nostr_sdk::{
34 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url,
35};
36use nostr_signer::NostrSigner;
37use repo_ref::RepoRef;
38use repo_state::RepoState;
39 21
40use crate::{client::Client, git::Repo}; 22use crate::{client::Client, git::Repo};
41 23
24mod fetch;
25mod list;
26mod push;
27mod utils;
28
42#[tokio::main] 29#[tokio::main]
43async fn main() -> Result<()> { 30async fn main() -> Result<()> {
44 let args = env::args(); 31 let args = env::args();
@@ -88,10 +75,10 @@ async fn main() -> Result<()> {
88 println!("unsupported"); 75 println!("unsupported");
89 } 76 }
90 ["fetch", oid, refstr] => { 77 ["fetch", oid, refstr] => {
91 fetch(&git_repo, &repo_ref, &stdin, oid, refstr).await?; 78 fetch::run_fetch(&git_repo, &repo_ref, &stdin, oid, refstr).await?;
92 } 79 }
93 ["push", refspec] => { 80 ["push", refspec] => {
94 push( 81 push::run_push(
95 &git_repo, 82 &git_repo,
96 &repo_ref, 83 &repo_ref,
97 nostr_remote_url, 84 nostr_remote_url,
@@ -103,10 +90,10 @@ async fn main() -> Result<()> {
103 .await?; 90 .await?;
104 } 91 }
105 ["list"] => { 92 ["list"] => {
106 list_outputs = Some(list(&git_repo, &repo_ref, false).await?); 93 list_outputs = Some(list::run_list(&git_repo, &repo_ref, false).await?);
107 } 94 }
108 ["list", "for-push"] => { 95 ["list", "for-push"] => {
109 list_outputs = Some(list(&git_repo, &repo_ref, true).await?); 96 list_outputs = Some(list::run_list(&git_repo, &repo_ref, true).await?);
110 } 97 }
111 [] => { 98 [] => {
112 return Ok(()); 99 return Ok(());
@@ -118,20 +105,6 @@ async fn main() -> Result<()> {
118 } 105 }
119} 106}
120 107
121/// Read one line from stdin, and split it into tokens.
122pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
123 line.clear();
124
125 let read = stdin.read_line(line)?;
126 if read == 0 {
127 return Ok(vec![]);
128 }
129 let line = line.trim();
130 let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
131
132 Ok(tokens)
133}
134
135async fn fetching_with_report_for_helper( 108async fn fetching_with_report_for_helper(
136 git_repo_path: &Path, 109 git_repo_path: &Path,
137 client: &Client, 110 client: &Client,
@@ -154,1482 +127,3 @@ async fn fetching_with_report_for_helper(
154 } 127 }
155 Ok(()) 128 Ok(())
156} 129}
157
158async fn list(
159 git_repo: &Repo,
160 repo_ref: &RepoRef,
161 for_push: bool,
162) -> Result<HashMap<String, HashMap<String, String>>> {
163 let nostr_state =
164 if let Ok(nostr_state) = get_state_from_cache(git_repo.get_path()?, repo_ref).await {
165 Some(nostr_state)
166 } else {
167 None
168 };
169
170 let term = console::Term::stderr();
171
172 let remote_states = list_from_remotes(&term, git_repo, &repo_ref.git_server)?;
173
174 let mut state = if let Some(nostr_state) = nostr_state {
175 for (name, value) in &nostr_state.state {
176 for (url, remote_state) in &remote_states {
177 let remote_name = get_short_git_server_name(git_repo, url);
178 if let Some(remote_value) = remote_state.get(name) {
179 if value.ne(remote_value) {
180 term.write_line(
181 format!(
182 "WARNING: {remote_name} {name} is {} nostr ",
183 if let Ok((ahead, behind)) =
184 get_ahead_behind(git_repo, value, remote_value)
185 {
186 format!("{} ahead {} behind", ahead.len(), behind.len())
187 } else {
188 "out of sync with".to_string()
189 }
190 )
191 .as_str(),
192 )?;
193 }
194 } else {
195 term.write_line(
196 format!("WARNING: {remote_name} {name} is missing but tracked on nostr")
197 .as_str(),
198 )?;
199 }
200 }
201 }
202 nostr_state.state
203 } else {
204 repo_ref
205 .git_server
206 .iter()
207 .filter_map(|server| remote_states.get(server))
208 .cloned()
209 .collect::<Vec<HashMap<String, String>>>()
210 .first()
211 .context("failed to get refs from git server")?
212 .clone()
213 };
214
215 state.retain(|k, _| !k.starts_with("refs/heads/pr/"));
216
217 let open_proposals = get_open_proposals(git_repo, repo_ref).await?;
218 let current_user = get_curent_user(git_repo)?;
219 for (_, (proposal, patches)) in open_proposals {
220 if let Ok(cl) = event_to_cover_letter(&proposal) {
221 if let Ok(mut branch_name) = cl.get_branch_name() {
222 branch_name = if let Some(public_key) = current_user {
223 if proposal.author().eq(&public_key) {
224 cl.branch_name.to_string()
225 } else {
226 branch_name
227 }
228 } else {
229 branch_name
230 };
231 if let Some(patch) = patches.first() {
232 // TODO this isn't resilient because the commit id stated may not be correct
233 // we will need to check whether the commit id exists in the repo or apply the
234 // proposal and each patch to check
235 if let Ok(commit_id) = get_commit_id_from_patch(patch) {
236 state.insert(format!("refs/heads/{branch_name}"), commit_id);
237 }
238 }
239 }
240 }
241 }
242
243 // TODO 'for push' should we check with the git servers to see if any of them
244 // allow push from the user?
245 for (name, value) in state {
246 if value.starts_with("ref: ") {
247 if !for_push {
248 println!("{} {name}", value.replace("ref: ", "@"));
249 }
250 } else {
251 println!("{value} {name}");
252 }
253 }
254
255 println!();
256 Ok(remote_states)
257}
258
259fn list_from_remotes(
260 term: &console::Term,
261 git_repo: &Repo,
262 git_servers: &Vec<String>,
263) -> Result<HashMap<String, HashMap<String, String>>> {
264 let mut remote_states = HashMap::new();
265 for url in git_servers {
266 let short_name = get_short_git_server_name(git_repo, url);
267 term.write_line(format!("fetching refs list: {short_name}...").as_str())?;
268 match list_from_remote(git_repo, url) {
269 Ok(remote_state) => {
270 remote_states.insert(url.clone(), remote_state);
271 }
272 Err(error1) => {
273 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(url) {
274 match list_from_remote(git_repo, &alternative_url) {
275 Ok(remote_state) => {
276 remote_states.insert(url.clone(), remote_state);
277 }
278 Err(error2) => {
279 term.write_line(
280 format!("WARNING: {short_name} failed to list refs error: {error1}\r\nand alternative protocol {alternative_url}: {error2}").as_str(),
281 )?;
282 }
283 }
284 } else {
285 term.write_line(
286 format!("WARNING: {short_name} failed to list refs error: {error1}",)
287 .as_str(),
288 )?;
289 }
290 }
291 }
292 term.clear_last_lines(1)?;
293 }
294 Ok(remote_states)
295}
296
297fn switch_clone_url_between_ssh_and_https(url: &str) -> Result<String> {
298 if url.starts_with("https://") {
299 // Convert HTTPS to git@ syntax
300 let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect();
301 if parts.len() >= 2 {
302 // Construct the git@ URL
303 Ok(format!("git@{}:{}", parts[0], parts[1..].join("/")))
304 } else {
305 // If the format is unexpected, return an error
306 bail!("Invalid HTTPS URL format: {}", url);
307 }
308 } else if url.starts_with("ssh://") {
309 // Convert SSH to git@ syntax
310 let parts: Vec<&str> = url.trim_start_matches("ssh://").split('/').collect();
311 if parts.len() >= 2 {
312 // Construct the git@ URL
313 Ok(format!("git@{}:{}", parts[0], parts[1..].join("/")))
314 } else {
315 // If the format is unexpected, return an error
316 bail!("Invalid SSH URL format: {}", url);
317 }
318 } else if url.starts_with("git@") {
319 // Convert git@ syntax to HTTPS
320 let parts: Vec<&str> = url.split(':').collect();
321 if parts.len() == 2 {
322 // Construct the HTTPS URL
323 Ok(format!(
324 "https://{}/{}",
325 parts[0].trim_end_matches('@'),
326 parts[1]
327 ))
328 } else {
329 // If the format is unexpected, return an error
330 bail!("Invalid git@ URL format: {}", url);
331 }
332 } else {
333 // If the URL is neither HTTPS, SSH, nor git@, return an error
334 bail!("Unsupported URL protocol: {}", url);
335 }
336}
337
338fn list_from_remote(
339 git_repo: &Repo,
340 git_server_remote_url: &str,
341) -> Result<HashMap<String, String>> {
342 let git_config = git_repo.git_repo.config()?;
343
344 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_remote_url)?;
345 // authentication may be required
346 let auth = GitAuthenticator::default();
347 let mut remote_callbacks = git2::RemoteCallbacks::new();
348 remote_callbacks.credentials(auth.credentials(&git_config));
349 git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?;
350 let mut state = HashMap::new();
351 for head in git_server_remote.list()? {
352 if let Some(symbolic_reference) = head.symref_target() {
353 state.insert(
354 head.name().to_string(),
355 format!("ref: {symbolic_reference}"),
356 );
357 } else {
358 state.insert(head.name().to_string(), head.oid().to_string());
359 }
360 }
361 git_server_remote.disconnect()?;
362 Ok(state)
363}
364
365fn get_ahead_behind(
366 git_repo: &Repo,
367 base_ref_or_oid: &str,
368 latest_ref_or_oid: &str,
369) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> {
370 let base = git_repo.get_commit_or_tip_of_reference(base_ref_or_oid)?;
371 let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?;
372 git_repo.get_commits_ahead_behind(&base, &latest)
373}
374
375async fn get_open_proposals(
376 git_repo: &Repo,
377 repo_ref: &RepoRef,
378) -> Result<HashMap<EventId, (Event, Vec<Event>)>> {
379 let git_repo_path = git_repo.get_path()?;
380 let proposals: Vec<nostr::Event> =
381 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
382 .await?
383 .iter()
384 .filter(|e| !event_is_revision_root(e))
385 .cloned()
386 .collect();
387
388 let statuses: Vec<nostr::Event> = {
389 let mut statuses = get_events_from_cache(
390 git_repo_path,
391 vec![
392 nostr::Filter::default()
393 .kinds(status_kinds().clone())
394 .events(proposals.iter().map(nostr::Event::id)),
395 ],
396 )
397 .await?;
398 statuses.sort_by_key(|e| e.created_at);
399 statuses.reverse();
400 statuses
401 };
402 let mut open_proposals = HashMap::new();
403
404 for proposal in proposals {
405 let status = if let Some(e) = statuses
406 .iter()
407 .filter(|e| {
408 status_kinds().contains(&e.kind())
409 && e.tags()
410 .iter()
411 .any(|t| t.as_vec()[1].eq(&proposal.id.to_string()))
412 })
413 .collect::<Vec<&nostr::Event>>()
414 .first()
415 {
416 e.kind()
417 } else {
418 Kind::GitStatusOpen
419 };
420 if status.eq(&Kind::GitStatusOpen) {
421 if let Ok(commits_events) =
422 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id)
423 .await
424 {
425 if let Ok(most_recent_proposal_patch_chain) =
426 get_most_recent_patch_with_ancestors(commits_events.clone())
427 {
428 open_proposals
429 .insert(proposal.id(), (proposal, most_recent_proposal_patch_chain));
430 }
431 }
432 }
433 }
434 Ok(open_proposals)
435}
436
437fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> {
438 Ok(
439 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
440 if let Ok(public_key) = PublicKey::parse(npub) {
441 Some(public_key)
442 } else {
443 None
444 }
445 } else {
446 None
447 },
448 )
449}
450
451async fn get_all_proposals(
452 git_repo: &Repo,
453 repo_ref: &RepoRef,
454) -> Result<HashMap<EventId, (Event, Vec<Event>)>> {
455 let git_repo_path = git_repo.get_path()?;
456 let proposals: Vec<nostr::Event> =
457 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
458 .await?
459 .iter()
460 .filter(|e| !event_is_revision_root(e))
461 .cloned()
462 .collect();
463
464 let mut all_proposals = HashMap::new();
465
466 for proposal in proposals {
467 if let Ok(commits_events) =
468 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id).await
469 {
470 if let Ok(most_recent_proposal_patch_chain) =
471 get_most_recent_patch_with_ancestors(commits_events.clone())
472 {
473 all_proposals.insert(proposal.id(), (proposal, most_recent_proposal_patch_chain));
474 }
475 }
476 }
477 Ok(all_proposals)
478}
479
480async fn fetch(
481 git_repo: &Repo,
482 repo_ref: &RepoRef,
483 stdin: &Stdin,
484 oid: &str,
485 refstr: &str,
486) -> Result<()> {
487 let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?;
488
489 let oids_from_git_servers = fetch_batch
490 .iter()
491 .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/"))
492 .map(|(_, oid)| oid.clone())
493 .collect::<Vec<String>>();
494
495 let mut errors = HashMap::new();
496 let term = console::Term::stderr();
497
498 for git_server_url in &repo_ref.git_server {
499 let term = console::Term::stderr();
500 let short_name = get_short_git_server_name(git_repo, git_server_url);
501 term.write_line(format!("fetching from {short_name}...").as_str())?;
502 let res = fetch_from_git_server(&git_repo.git_repo, &oids_from_git_servers, git_server_url);
503 term.clear_last_lines(1)?;
504 if let Err(error1) = res {
505 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(git_server_url) {
506 let res2 = fetch_from_git_server(
507 &git_repo.git_repo,
508 &oids_from_git_servers,
509 &alternative_url,
510 );
511 if let Err(error2) = res2 {
512 term.write_line(
513 format!(
514 "WARNING: failed to fetch from {short_name} error:{error1}\r\nand using alternative protocol {alternative_url}: {error2}"
515 ).as_str()
516 )?;
517 errors.insert(
518 short_name.to_string(),
519 anyhow!(
520 "{error1} and using alternative protocol {alternative_url}: {error2}"
521 ),
522 );
523 } else {
524 break;
525 }
526 } else {
527 term.write_line(
528 format!("WARNING: failed to fetch from {short_name} error:{error1}").as_str(),
529 )?;
530 errors.insert(short_name.to_string(), error1);
531 }
532 } else {
533 break;
534 }
535 }
536
537 if oids_from_git_servers
538 .iter()
539 .any(|oid| !git_repo.does_commit_exist(oid).unwrap())
540 && !errors.is_empty()
541 {
542 bail!(
543 "failed to fetch objects in nostr state event from:\r\n{}",
544 errors
545 .iter()
546 .map(|(url, error)| format!("{url}: {error}"))
547 .collect::<Vec<String>>()
548 .join("\r\n")
549 );
550 }
551
552 fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/"));
553
554 if !fetch_batch.is_empty() {
555 let open_proposals = get_open_proposals(git_repo, repo_ref).await?;
556
557 let current_user = get_curent_user(git_repo)?;
558
559 for (refstr, oid) in fetch_batch {
560 if let Some((_, (_, patches))) =
561 find_proposal_and_patches_by_branch_name(&refstr, &open_proposals, &current_user)
562 {
563 if !git_repo.does_commit_exist(&oid)? {
564 let mut patches_ancestor_first = patches.clone();
565 patches_ancestor_first.reverse();
566 if git_repo.does_commit_exist(&tag_value(
567 patches_ancestor_first.first().unwrap(),
568 "parent-commit",
569 )?)? {
570 for patch in &patches_ancestor_first {
571 git_repo.create_commit_from_patch(patch)?;
572 }
573 } else {
574 term.write_line(
575 format!("WARNING: cannot find parent commit for {refstr}").as_str(),
576 )?;
577 }
578 }
579 } else {
580 term.write_line(format!("WARNING: cannot find proposal for {refstr}").as_str())?;
581 }
582 }
583 }
584
585 term.flush()?;
586 println!();
587 Ok(())
588}
589
590fn find_proposal_and_patches_by_branch_name<'a>(
591 refstr: &'a str,
592 open_proposals: &'a HashMap<EventId, (Event, Vec<Event>)>,
593 current_user: &Option<PublicKey>,
594) -> Option<(&'a EventId, &'a (Event, Vec<Event>))> {
595 open_proposals.iter().find(|(_, (proposal, _))| {
596 if let Ok(cl) = event_to_cover_letter(proposal) {
597 if let Ok(mut branch_name) = cl.get_branch_name() {
598 branch_name = if let Some(public_key) = current_user {
599 if proposal.author().eq(public_key) {
600 cl.branch_name.to_string()
601 } else {
602 branch_name
603 }
604 } else {
605 branch_name
606 };
607 branch_name.eq(&refstr.replace("refs/heads/", ""))
608 } else {
609 false
610 }
611 } else {
612 false
613 }
614 })
615}
616
617fn fetch_from_git_server(
618 git_repo: &Repository,
619 oids: &[String],
620 git_server_url: &str,
621) -> Result<()> {
622 let git_config = git_repo.config()?;
623
624 let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?;
625 // authentication may be required (and will be requird if clone url is ssh)
626 let auth = GitAuthenticator::default();
627 let mut fetch_options = git2::FetchOptions::new();
628 let mut remote_callbacks = git2::RemoteCallbacks::new();
629 remote_callbacks.credentials(auth.credentials(&git_config));
630 fetch_options.remote_callbacks(remote_callbacks);
631 git_server_remote.download(oids, Some(&mut fetch_options))?;
632 git_server_remote.disconnect()?;
633 Ok(())
634}
635
636#[allow(clippy::too_many_lines)]
637async fn push(
638 git_repo: &Repo,
639 repo_ref: &RepoRef,
640 nostr_remote_url: &str,
641 stdin: &Stdin,
642 initial_refspec: &str,
643 client: &Client,
644 list_outputs: Option<HashMap<String, HashMap<String, String>>>,
645) -> Result<()> {
646 let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?;
647
648 let proposal_refspecs = refspecs
649 .iter()
650 .filter(|r| r.contains("refs/heads/pr/"))
651 .cloned()
652 .collect::<Vec<String>>();
653
654 let mut git_server_refspecs = refspecs
655 .iter()
656 .filter(|r| !r.contains("refs/heads/pr/"))
657 .cloned()
658 .collect::<Vec<String>>();
659
660 let term = console::Term::stderr();
661
662 let list_outputs = match list_outputs {
663 Some(outputs) => outputs,
664 _ => list_from_remotes(&term, git_repo, &repo_ref.git_server)?,
665 };
666
667 let nostr_state = get_state_from_cache(git_repo.get_path()?, repo_ref).await;
668
669 let existing_state = {
670 // if no state events - create from first git server listed
671 if let Ok(nostr_state) = &nostr_state {
672 nostr_state.state.clone()
673 } else if let Some(url) = repo_ref
674 .git_server
675 .iter()
676 .find(|&url| list_outputs.contains_key(url))
677 {
678 list_outputs.get(url).unwrap().to_owned()
679 } else {
680 bail!(
681 "cannot connect to git servers: {}",
682 repo_ref.git_server.join(" ")
683 );
684 }
685 };
686
687 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs(
688 &term,
689 git_repo,
690 &git_server_refspecs,
691 &existing_state,
692 &list_outputs,
693 )?;
694
695 git_server_refspecs.retain(|refspec| {
696 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) {
697 let (_, to) = refspec_to_from_to(refspec).unwrap();
698 println!("error {to} {} out of sync with nostr", rejected.join(" "));
699 false
700 } else {
701 true
702 }
703 });
704
705 let mut events = vec![];
706
707 if git_server_refspecs.is_empty() && proposal_refspecs.is_empty() {
708 // all refspecs rejected
709 println!();
710 return Ok(());
711 }
712
713 let (signer, user_ref) = login::launch(
714 git_repo,
715 &None,
716 &None,
717 &None,
718 &None,
719 Some(client),
720 false,
721 true,
722 )
723 .await?;
724
725 if !repo_ref.maintainers.contains(&user_ref.public_key) {
726 for refspec in &git_server_refspecs {
727 let (_, to) = refspec_to_from_to(refspec).unwrap();
728 println!(
729 "error {to} your nostr account {} isn't listed as a maintainer of the repo",
730 user_ref.metadata.name
731 );
732 }
733 git_server_refspecs.clear();
734 if proposal_refspecs.is_empty() {
735 println!();
736 return Ok(());
737 }
738 }
739
740 if !git_server_refspecs.is_empty() {
741 let new_state = generate_updated_state(git_repo, &existing_state, &git_server_refspecs)?;
742
743 let new_repo_state =
744 RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?;
745
746 events.push(new_repo_state.event);
747
748 for event in get_merged_status_events(
749 &term,
750 repo_ref,
751 git_repo,
752 nostr_remote_url,
753 &signer,
754 &git_server_refspecs,
755 )
756 .await?
757 {
758 events.push(event);
759 }
760 }
761
762 let mut rejected_proposal_refspecs = vec![];
763 if !proposal_refspecs.is_empty() {
764 let all_proposals = get_all_proposals(git_repo, repo_ref).await?;
765 let current_user = get_curent_user(git_repo)?;
766
767 for refspec in &proposal_refspecs {
768 let (from, to) = refspec_to_from_to(refspec).unwrap();
769 let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?;
770
771 if let Some((_, (proposal, patches))) =
772 find_proposal_and_patches_by_branch_name(to, &all_proposals, &current_user)
773 {
774 if [repo_ref.maintainers.clone(), vec![proposal.author()]]
775 .concat()
776 .contains(&user_ref.public_key)
777 {
778 if refspec.starts_with('+') {
779 // force push
780 let (_, main_tip) = git_repo.get_main_or_master_branch()?;
781 let (mut ahead, _) =
782 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
783 ahead.reverse();
784 for patch in generate_cover_letter_and_patch_events(
785 None,
786 git_repo,
787 &ahead,
788 &signer,
789 repo_ref,
790 &Some(proposal.id().to_string()),
791 &[],
792 )
793 .await?
794 {
795 events.push(patch);
796 }
797 } else {
798 // fast forward push
799 let tip_patch = patches.first().unwrap();
800 let tip_of_proposal = get_commit_id_from_patch(tip_patch)?;
801 let tip_of_proposal_commit =
802 git_repo.get_commit_or_tip_of_reference(&tip_of_proposal)?;
803
804 let (mut ahead, behind) = git_repo.get_commits_ahead_behind(
805 &tip_of_proposal_commit,
806 &tip_of_pushed_branch,
807 )?;
808 if behind.is_empty() {
809 let thread_id = if let Ok(root_event_id) = get_event_root(tip_patch) {
810 root_event_id
811 } else {
812 // tip patch is the root proposal
813 tip_patch.id()
814 };
815 let mut parent_patch = tip_patch.clone();
816 ahead.reverse();
817 for (i, commit) in ahead.iter().enumerate() {
818 let new_patch = generate_patch_event(
819 git_repo,
820 &git_repo.get_root_commit()?,
821 commit,
822 Some(thread_id),
823 &signer,
824 repo_ref,
825 Some(parent_patch.id()),
826 Some((
827 (patches.len() + i + 1).try_into().unwrap(),
828 (patches.len() + ahead.len()).try_into().unwrap(),
829 )),
830 None,
831 &None,
832 &[],
833 )
834 .await
835 .context("cannot make patch event from commit")?;
836 events.push(new_patch.clone());
837 parent_patch = new_patch;
838 }
839 } else {
840 // we shouldn't get here
841 term.write_line(
842 format!(
843 "WARNING: failed to push {from} as nostr proposal. Try and force push ",
844 )
845 .as_str(),
846 )
847 .unwrap();
848 println!(
849 "error {to} cannot fastforward as newer patches found on proposal"
850 );
851 rejected_proposal_refspecs.push(refspec.to_string());
852 }
853 }
854 } else {
855 println!(
856 "error {to} permission denied. you are not the proposal author or a repo maintainer"
857 );
858 rejected_proposal_refspecs.push(refspec.to_string());
859 }
860 } else {
861 // TODO new proposal / couldn't find exisiting proposal
862 let (_, main_tip) = git_repo.get_main_or_master_branch()?;
863 let (mut ahead, _) =
864 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
865 ahead.reverse();
866 for patch in generate_cover_letter_and_patch_events(
867 None,
868 git_repo,
869 &ahead,
870 &signer,
871 repo_ref,
872 &None,
873 &[],
874 )
875 .await?
876 {
877 events.push(patch);
878 }
879 }
880 }
881 }
882
883 // TODO check whether tip of each branch pushed is on at least one git server
884 // before broadcasting the nostr state
885 if !events.is_empty() {
886 send_events(
887 client,
888 git_repo.get_path()?,
889 events,
890 user_ref.relays.write(),
891 repo_ref.relays.clone(),
892 false,
893 true,
894 )
895 .await?;
896 }
897
898 for refspec in &[git_server_refspecs.clone(), proposal_refspecs.clone()].concat() {
899 if rejected_proposal_refspecs.contains(refspec) {
900 continue;
901 }
902 let (_, to) = refspec_to_from_to(refspec)?;
903 println!("ok {to}");
904 update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url)
905 .context("could not update remote_ref locally")?;
906 }
907
908 // TODO make async - check gitlib2 callbacks work async
909 for (git_server_url, remote_refspecs) in remote_refspecs {
910 let remote_refspecs = remote_refspecs
911 .iter()
912 .filter(|refspec| git_server_refspecs.contains(refspec))
913 .cloned()
914 .collect::<Vec<String>>();
915 if !refspecs.is_empty()
916 && push_to_remote(git_repo, &git_server_url, &remote_refspecs, &term).is_err()
917 {
918 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(&git_server_url) {
919 if push_to_remote(git_repo, &alternative_url, &remote_refspecs, &term).is_err() {
920 // errors get printed as part of callback
921 // TODO prevent 2 warning messages and instead use one
922 // to say it didnt work over either https or ssh
923 } else {
924 term.write_line(
925 format!("but succeed over alterantive protocol {alternative_url}",)
926 .as_str(),
927 )?;
928 }
929 }
930 }
931 }
932 println!();
933 Ok(())
934}
935
936fn push_to_remote(
937 git_repo: &Repo,
938 git_server_url: &str,
939 remote_refspecs: &[String],
940 term: &Term,
941) -> Result<()> {
942 let git_config = git_repo.git_repo.config()?;
943 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?;
944 let auth = GitAuthenticator::default();
945 let mut push_options = git2::PushOptions::new();
946 let mut remote_callbacks = git2::RemoteCallbacks::new();
947 remote_callbacks.credentials(auth.credentials(&git_config));
948 remote_callbacks.push_update_reference(|name, error| {
949 if let Some(error) = error {
950 term.write_line(
951 format!(
952 "WARNING: {} failed to push {name} error: {error}",
953 get_short_git_server_name(git_repo, git_server_url),
954 )
955 .as_str(),
956 )
957 .unwrap();
958 }
959 Ok(())
960 });
961 push_options.remote_callbacks(remote_callbacks);
962 git_server_remote.push(remote_refspecs, Some(&mut push_options))?;
963 let _ = git_server_remote.disconnect();
964 Ok(())
965}
966
967fn get_event_root(event: &nostr::Event) -> Result<EventId> {
968 Ok(EventId::parse(
969 event
970 .tags()
971 .iter()
972 .find(|t| t.is_root())
973 .context("no thread root in event")?
974 .as_vec()
975 .get(1)
976 .unwrap(),
977 )?)
978}
979
980type HashMapUrlRefspecs = HashMap<String, Vec<String>>;
981
982#[allow(clippy::too_many_lines)]
983fn create_rejected_refspecs_and_remotes_refspecs(
984 term: &console::Term,
985 git_repo: &Repo,
986 refspecs: &Vec<String>,
987 nostr_state: &HashMap<String, String>,
988 list_outputs: &HashMap<String, HashMap<String, String>>,
989) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> {
990 let mut refspecs_for_remotes = HashMap::new();
991
992 let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new();
993
994 for (url, remote_state) in list_outputs {
995 let short_name = get_short_git_server_name(git_repo, url);
996 let mut refspecs_for_remote = vec![];
997 for refspec in refspecs {
998 let (from, to) = refspec_to_from_to(refspec)?;
999 let nostr_value = nostr_state.get(to);
1000 let remote_value = remote_state.get(to);
1001 if from.is_empty() {
1002 if remote_value.is_some() {
1003 // delete remote branch
1004 refspecs_for_remote.push(refspec.clone());
1005 }
1006 continue;
1007 }
1008 let from_tip = git_repo.get_commit_or_tip_of_reference(from)?;
1009 if let Some(nostr_value) = nostr_value {
1010 if let Some(remote_value) = remote_value {
1011 if nostr_value.eq(remote_value) {
1012 // in sync - existing branch at same state
1013 let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) =
1014 git_repo.get_commit_or_tip_of_reference(remote_value)
1015 {
1016 if let Ok((_, behind)) =
1017 git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip)
1018 {
1019 behind.is_empty()
1020 } else {
1021 false
1022 }
1023 } else {
1024 false
1025 };
1026 if is_remote_tip_ancestor_of_commit {
1027 refspecs_for_remote.push(refspec.clone());
1028 } else {
1029 // this is a force push so we need to force push to git server too
1030 if refspec.starts_with('+') {
1031 refspecs_for_remote.push(refspec.clone());
1032 } else {
1033 refspecs_for_remote.push(format!("+{refspec}"));
1034 }
1035 }
1036 } else if let Ok(remote_value_tip) =
1037 git_repo.get_commit_or_tip_of_reference(remote_value)
1038 {
1039 if from_tip.eq(&remote_value_tip) {
1040 // remote already at correct state
1041 term.write_line(
1042 format!("{short_name} {to} already up-to-date").as_str(),
1043 )?;
1044 }
1045 let (ahead_of_local, behind_local) =
1046 git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?;
1047 if ahead_of_local.is_empty() {
1048 // can soft push
1049 refspecs_for_remote.push(refspec.clone());
1050 } else {
1051 // cant soft push
1052 let (ahead_of_nostr, behind_nostr) = git_repo
1053 .get_commits_ahead_behind(
1054 &git_repo.get_commit_or_tip_of_reference(nostr_value)?,
1055 &remote_value_tip,
1056 )?;
1057 if ahead_of_nostr.is_empty() {
1058 // ancestor of nostr and we are force pushing anyway...
1059 refspecs_for_remote.push(refspec.clone());
1060 } else {
1061 rejected_refspecs
1062 .entry(refspec.to_string())
1063 .and_modify(|a| a.push(url.to_string()))
1064 .or_insert(vec![url.to_string()]);
1065 term.write_line(
1066 format!(
1067 "ERROR: {short_name} {to} conflicts with nostr ({} ahead {} behind) and local ({} ahead {} behind). either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote",
1068 ahead_of_nostr.len(),
1069 behind_nostr.len(),
1070 ahead_of_local.len(),
1071 behind_local.len(),
1072 ).as_str(),
1073 )?;
1074 }
1075 };
1076 } else {
1077 // remote_value oid is not present locally
1078 // TODO can we download the remote reference?
1079
1080 // cant soft push
1081 rejected_refspecs
1082 .entry(refspec.to_string())
1083 .and_modify(|a| a.push(url.to_string()))
1084 .or_insert(vec![url.to_string()]);
1085 term.write_line(
1086 format!("ERROR: {short_name} {to} conflicts with nostr and is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(),
1087 )?;
1088 }
1089 } else {
1090 // existing nostr branch not on remote
1091 // report - creating new branch
1092 term.write_line(
1093 format!(
1094 "{short_name} {to} doesn't exist and will be added as a new branch"
1095 )
1096 .as_str(),
1097 )?;
1098 refspecs_for_remote.push(refspec.clone());
1099 }
1100 } else if let Some(remote_value) = remote_value {
1101 // new to nostr but on remote
1102 if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value)
1103 {
1104 let (ahead, behind) =
1105 git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?;
1106 if behind.is_empty() {
1107 // can soft push
1108 refspecs_for_remote.push(refspec.clone());
1109 } else {
1110 // cant soft push
1111 rejected_refspecs
1112 .entry(refspec.to_string())
1113 .and_modify(|a| a.push(url.to_string()))
1114 .or_insert(vec![url.to_string()]);
1115 term.write_line(
1116 format!(
1117 "ERROR: {short_name} already contains {to} {} ahead and {} behind local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote",
1118 ahead.len(),
1119 behind.len(),
1120 ).as_str(),
1121 )?;
1122 }
1123 } else {
1124 // havn't fetched oid from remote
1125 // TODO fetch oid from remote
1126 // cant soft push
1127 rejected_refspecs
1128 .entry(refspec.to_string())
1129 .and_modify(|a| a.push(url.to_string()))
1130 .or_insert(vec![url.to_string()]);
1131 term.write_line(
1132 format!("ERROR: {short_name} already contains {to} at {remote_value} which is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(),
1133 )?;
1134 }
1135 } else {
1136 // in sync - new branch
1137 refspecs_for_remote.push(refspec.clone());
1138 }
1139 }
1140 if !refspecs_for_remote.is_empty() {
1141 refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote);
1142 }
1143 }
1144
1145 // remove rejected refspecs so they dont get pushed to some remotes
1146 let mut remotes_refspecs_without_rejected = HashMap::new();
1147 for (url, value) in &refspecs_for_remotes {
1148 remotes_refspecs_without_rejected.insert(
1149 url.to_string(),
1150 value
1151 .iter()
1152 .filter(|refspec| !rejected_refspecs.contains_key(*refspec))
1153 .cloned()
1154 .collect(),
1155 );
1156 }
1157 Ok((rejected_refspecs, remotes_refspecs_without_rejected))
1158}
1159
1160fn generate_updated_state(
1161 git_repo: &Repo,
1162 existing_state: &HashMap<String, String>,
1163 refspecs: &Vec<String>,
1164) -> Result<HashMap<String, String>> {
1165 let mut new_state = existing_state.clone();
1166
1167 for refspec in refspecs {
1168 let (from, to) = refspec_to_from_to(refspec)?;
1169 if from.is_empty() {
1170 // delete
1171 new_state.remove(to);
1172 if to.contains("refs/tags") {
1173 new_state.remove(&format!("{to}{}", "^{}"));
1174 }
1175 } else if to.contains("refs/tags") {
1176 new_state.insert(
1177 format!("{to}{}", "^{}"),
1178 git_repo
1179 .get_commit_or_tip_of_reference(from)
1180 .unwrap()
1181 .to_string(),
1182 );
1183 new_state.insert(
1184 to.to_string(),
1185 git_repo
1186 .git_repo
1187 .find_reference(to)
1188 .unwrap()
1189 .peel(git2::ObjectType::Tag)
1190 .unwrap()
1191 .id()
1192 .to_string(),
1193 );
1194 } else {
1195 // add or update
1196 new_state.insert(
1197 to.to_string(),
1198 git_repo
1199 .get_commit_or_tip_of_reference(from)
1200 .unwrap()
1201 .to_string(),
1202 );
1203 }
1204 }
1205 Ok(new_state)
1206}
1207
1208async fn get_merged_status_events(
1209 term: &console::Term,
1210 repo_ref: &RepoRef,
1211 git_repo: &Repo,
1212 remote_nostr_url: &str,
1213 signer: &NostrSigner,
1214 refspecs_to_git_server: &Vec<String>,
1215) -> Result<Vec<Event>> {
1216 let mut events = vec![];
1217 for refspec in refspecs_to_git_server {
1218 let (from, to) = refspec_to_from_to(refspec)?;
1219 if to.eq("refs/heads/main") || to.eq("refs/heads/master") {
1220 let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?;
1221 let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference(
1222 &refspec_remote_ref_name(&git_repo.git_repo, refspec, remote_nostr_url)?,
1223 ) else {
1224 // branch not on remote
1225 continue;
1226 };
1227 let (ahead, _) =
1228 git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?;
1229 for commit_hash in ahead {
1230 let commit = git_repo.git_repo.find_commit(sha1_to_oid(&commit_hash)?)?;
1231 if commit.parent_count() > 1 {
1232 // merge commit
1233 for parent in commit.parents() {
1234 // lookup parent id
1235 let commit_events = get_events_from_cache(
1236 git_repo.get_path()?,
1237 vec![
1238 nostr::Filter::default()
1239 .kind(nostr::Kind::GitPatch)
1240 .reference(parent.id().to_string()),
1241 ],
1242 )
1243 .await?;
1244 if let Some(commit_event) = commit_events.iter().find(|e| {
1245 e.tags.iter().any(|t| {
1246 t.as_vec()[0].eq("commit")
1247 && t.as_vec()[1].eq(&parent.id().to_string())
1248 })
1249 }) {
1250 let (proposal_id, revision_id) =
1251 get_proposal_and_revision_root_from_patch(git_repo, commit_event)
1252 .await?;
1253 term.write_line(
1254 format!(
1255 "merge commit {}: create nostr proposal status event",
1256 &commit.id().to_string()[..7],
1257 )
1258 .as_str(),
1259 )?;
1260
1261 events.push(
1262 create_merge_status(
1263 signer,
1264 repo_ref,
1265 &get_event_from_cache_by_id(git_repo, &proposal_id).await?,
1266 &if let Some(revision_id) = revision_id {
1267 Some(
1268 get_event_from_cache_by_id(git_repo, &revision_id)
1269 .await?,
1270 )
1271 } else {
1272 None
1273 },
1274 &commit_hash,
1275 commit_event.id(),
1276 )
1277 .await?,
1278 );
1279 }
1280 }
1281 }
1282 }
1283 }
1284 }
1285 Ok(events)
1286}
1287
1288async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> {
1289 Ok(get_events_from_cache(
1290 git_repo.get_path()?,
1291 vec![nostr::Filter::default().id(*event_id)],
1292 )
1293 .await?
1294 .first()
1295 .context("cannot find event in cache")?
1296 .clone())
1297}
1298
1299async fn create_merge_status(
1300 signer: &NostrSigner,
1301 repo_ref: &RepoRef,
1302 proposal: &Event,
1303 revision: &Option<Event>,
1304 merge_commit: &Sha1Hash,
1305 merged_patch: EventId,
1306) -> Result<Event> {
1307 let mut public_keys = repo_ref
1308 .maintainers
1309 .iter()
1310 .copied()
1311 .collect::<HashSet<PublicKey>>();
1312 public_keys.insert(proposal.author());
1313 if let Some(revision) = revision {
1314 public_keys.insert(revision.author());
1315 }
1316 sign_event(
1317 EventBuilder::new(
1318 nostr::event::Kind::GitStatusApplied,
1319 String::new(),
1320 [
1321 vec![
1322 Tag::custom(
1323 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
1324 vec!["git proposal merged / applied".to_string()],
1325 ),
1326 Tag::from_standardized(nostr::TagStandard::Event {
1327 event_id: proposal.id(),
1328 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
1329 marker: Some(Marker::Root),
1330 public_key: None,
1331 }),
1332 Tag::from_standardized(nostr::TagStandard::Event {
1333 event_id: merged_patch,
1334 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
1335 marker: Some(Marker::Mention),
1336 public_key: None,
1337 }),
1338 ],
1339 if let Some(revision) = revision {
1340 vec![Tag::from_standardized(nostr::TagStandard::Event {
1341 event_id: revision.id(),
1342 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
1343 marker: Some(Marker::Root),
1344 public_key: None,
1345 })]
1346 } else {
1347 vec![]
1348 },
1349 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
1350 repo_ref
1351 .coordinates()
1352 .iter()
1353 .map(|c| Tag::coordinate(c.clone()))
1354 .collect::<Vec<Tag>>(),
1355 vec![
1356 Tag::from_standardized(nostr::TagStandard::Reference(
1357 repo_ref.root_commit.to_string(),
1358 )),
1359 Tag::from_standardized(nostr::TagStandard::Reference(format!(
1360 "{merge_commit}"
1361 ))),
1362 Tag::custom(
1363 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-commit-id")),
1364 vec![format!("{merge_commit}")],
1365 ),
1366 ],
1367 ]
1368 .concat(),
1369 ),
1370 signer,
1371 )
1372 .await
1373}
1374
1375async fn get_proposal_and_revision_root_from_patch(
1376 git_repo: &Repo,
1377 patch: &Event,
1378) -> Result<(EventId, Option<EventId>)> {
1379 let proposal_or_revision = if patch.tags.iter().any(|t| t.as_vec()[1].eq("root")) {
1380 patch.clone()
1381 } else {
1382 let proposal_or_revision_id = EventId::parse(
1383 if let Some(t) = patch.tags.iter().find(|t| t.is_root()) {
1384 t.clone()
1385 } else if let Some(t) = patch.tags.iter().find(|t| t.is_reply()) {
1386 t.clone()
1387 } else {
1388 Tag::event(patch.id())
1389 }
1390 .as_vec()[1]
1391 .clone(),
1392 )?;
1393
1394 get_events_from_cache(
1395 git_repo.get_path()?,
1396 vec![nostr::Filter::default().id(proposal_or_revision_id)],
1397 )
1398 .await?
1399 .first()
1400 .unwrap()
1401 .clone()
1402 };
1403
1404 if !proposal_or_revision.kind().eq(&Kind::GitPatch) {
1405 bail!("thread root is not a git patch");
1406 }
1407
1408 if proposal_or_revision
1409 .tags
1410 .iter()
1411 .any(|t| t.as_vec()[1].eq("revision-root"))
1412 {
1413 Ok((
1414 EventId::parse(
1415 proposal_or_revision
1416 .tags
1417 .iter()
1418 .find(|t| t.is_reply())
1419 .unwrap()
1420 .as_vec()[1]
1421 .clone(),
1422 )?,
1423 Some(proposal_or_revision.id()),
1424 ))
1425 } else {
1426 Ok((proposal_or_revision.id(), None))
1427 }
1428}
1429
1430fn update_remote_refs_pushed(
1431 git_repo: &Repository,
1432 refspec: &str,
1433 nostr_remote_url: &str,
1434) -> Result<()> {
1435 let (from, _) = refspec_to_from_to(refspec)?;
1436
1437 let target_ref_name = refspec_remote_ref_name(git_repo, refspec, nostr_remote_url)?;
1438
1439 if from.is_empty() {
1440 if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) {
1441 remote_ref.delete()?;
1442 }
1443 } else {
1444 let commit = reference_to_commit(git_repo, from)
1445 .context(format!("cannot get commit of reference {from}"))?;
1446 if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) {
1447 remote_ref.set_target(commit, "updated by nostr remote helper")?;
1448 } else {
1449 git_repo.reference(
1450 &target_ref_name,
1451 commit,
1452 false,
1453 "created by nostr remote helper",
1454 )?;
1455 }
1456 }
1457 Ok(())
1458}
1459
1460fn refspec_to_from_to(refspec: &str) -> Result<(&str, &str)> {
1461 if !refspec.contains(':') {
1462 bail!(
1463 "refspec should contain a colon (:) but consists of: {}",
1464 refspec
1465 );
1466 }
1467 let parts = refspec.split(':').collect::<Vec<&str>>();
1468 Ok((
1469 if parts.first().unwrap().starts_with('+') {
1470 &parts.first().unwrap()[1..]
1471 } else {
1472 parts.first().unwrap()
1473 },
1474 parts.get(1).unwrap(),
1475 ))
1476}
1477
1478fn refspec_remote_ref_name(
1479 git_repo: &Repository,
1480 refspec: &str,
1481 nostr_remote_url: &str,
1482) -> Result<String> {
1483 let (_, to) = refspec_to_from_to(refspec)?;
1484 let nostr_remote = git_repo
1485 .find_remote(&get_remote_name_by_url(git_repo, nostr_remote_url)?)
1486 .context("we should have just located this remote")?;
1487 Ok(format!(
1488 "refs/remotes/{}/{}",
1489 nostr_remote.name().context("remote should have a name")?,
1490 to.replace("refs/heads/", ""), /* TODO only replace if it begins with this
1491 * TODO what about tags? */
1492 ))
1493}
1494
1495fn reference_to_commit(git_repo: &Repository, reference: &str) -> Result<Oid> {
1496 Ok(git_repo
1497 .find_reference(reference)
1498 .context(format!("cannot find reference: {reference}"))?
1499 .peel_to_commit()
1500 .context(format!("cannot get commit from reference: {reference}"))?
1501 .id())
1502}
1503
1504// this maybe a commit id or a ref: pointer
1505fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result<String> {
1506 let reference_obj = git_repo
1507 .find_reference(reference)
1508 .context(format!("cannot find reference: {reference}"))?;
1509 if let Some(symref) = reference_obj.symbolic_target() {
1510 Ok(symref.to_string())
1511 } else {
1512 Ok(reference_obj
1513 .peel_to_commit()
1514 .context(format!("cannot get commit from reference: {reference}"))?
1515 .id()
1516 .to_string())
1517 }
1518}
1519
1520fn get_remote_name_by_url(git_repo: &Repository, url: &str) -> Result<String> {
1521 let remotes = git_repo.remotes()?;
1522 Ok(remotes
1523 .iter()
1524 .find(|r| {
1525 if let Some(name) = r {
1526 if let Some(remote_url) = git_repo.find_remote(name).unwrap().url() {
1527 url == remote_url
1528 } else {
1529 false
1530 }
1531 } else {
1532 false
1533 }
1534 })
1535 .context("could not find remote with matching url")?
1536 .context("remote with matching url must be named")?
1537 .to_string())
1538}
1539
1540fn get_short_git_server_name(git_repo: &Repo, url: &str) -> std::string::String {
1541 if let Ok(name) = get_remote_name_by_url(&git_repo.git_repo, url) {
1542 return name;
1543 }
1544 if let Ok(url) = Url::parse(url) {
1545 if let Some(domain) = url.domain() {
1546 return domain.to_string();
1547 }
1548 }
1549 url.to_string()
1550}
1551
1552fn get_oids_from_fetch_batch(
1553 stdin: &Stdin,
1554 initial_oid: &str,
1555 initial_refstr: &str,
1556) -> Result<HashMap<String, String>> {
1557 let mut line = String::new();
1558 let mut batch = HashMap::new();
1559 batch.insert(initial_refstr.to_string(), initial_oid.to_string());
1560 loop {
1561 let tokens = read_line(stdin, &mut line)?;
1562 match tokens.as_slice() {
1563 ["fetch", oid, refstr] => {
1564 batch.insert((*refstr).to_string(), (*oid).to_string());
1565 }
1566 [] => break,
1567 _ => bail!(
1568 "after a `fetch` command we are only expecting another fetch or an empty line"
1569 ),
1570 }
1571 }
1572 Ok(batch)
1573}
1574
1575fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result<Vec<String>> {
1576 let mut line = String::new();
1577 let mut refspecs = vec![initial_refspec.to_string()];
1578 loop {
1579 let tokens = read_line(stdin, &mut line)?;
1580 match tokens.as_slice() {
1581 ["push", spec] => {
1582 refspecs.push((*spec).to_string());
1583 }
1584 [] => break,
1585 _ => {
1586 bail!("after a `push` command we are only expecting another push or an empty line")
1587 }
1588 }
1589 }
1590 Ok(refspecs)
1591}
1592
1593trait BuildRepoState {
1594 async fn build(
1595 identifier: String,
1596 state: HashMap<String, String>,
1597 signer: &NostrSigner,
1598 ) -> Result<RepoState>;
1599}
1600impl BuildRepoState for RepoState {
1601 async fn build(
1602 identifier: String,
1603 state: HashMap<String, String>,
1604 signer: &NostrSigner,
1605 ) -> Result<RepoState> {
1606 let mut tags = vec![Tag::identifier(identifier.clone())];
1607 for (name, value) in &state {
1608 tags.push(Tag::custom(
1609 nostr_sdk::TagKind::Custom(name.into()),
1610 vec![value.clone()],
1611 ));
1612 }
1613 let event = sign_event(EventBuilder::new(STATE_KIND, "", tags), signer).await?;
1614 Ok(RepoState {
1615 identifier,
1616 state,
1617 event,
1618 })
1619 }
1620}
1621
1622#[cfg(test)]
1623mod tests {
1624 use super::*;
1625
1626 mod refspec_to_from_to {
1627 use super::*;
1628
1629 #[test]
1630 fn trailing_plus_stripped() {
1631 let (from, _) = refspec_to_from_to("+testing:testingb").unwrap();
1632 assert_eq!(from, "testing");
1633 }
1634 }
1635}