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