upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/git_remote_nostr/push.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/push.rs
parent90c53e2dc859b47615ebaa08199b7460615ce3e4 (diff)
refactor(remote): split into modules
to make it easier to read
Diffstat (limited to 'src/bin/git_remote_nostr/push.rs')
-rw-r--r--src/bin/git_remote_nostr/push.rs961
1 files changed, 961 insertions, 0 deletions
diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs
new file mode 100644
index 0000000..441c341
--- /dev/null
+++ b/src/bin/git_remote_nostr/push.rs
@@ -0,0 +1,961 @@
1use core::str;
2use std::{
3 collections::{HashMap, HashSet},
4 io::Stdin,
5};
6
7use anyhow::{bail, Context, Result};
8use auth_git2::GitAuthenticator;
9use client::{get_events_from_cache, get_state_from_cache, send_events, sign_event, STATE_KIND};
10use console::Term;
11use git::{sha1_to_oid, RepoActions};
12use git2::{Oid, Repository};
13use git_events::{
14 generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch,
15};
16use ngit::{
17 client::{self, get_event_from_cache_by_id},
18 git,
19 git_events::{self, get_event_root},
20 login::{self, get_curent_user},
21 repo_ref, repo_state,
22};
23use nostr::nips::nip10::Marker;
24use nostr_sdk::{
25 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag,
26};
27use nostr_signer::NostrSigner;
28use repo_ref::RepoRef;
29use repo_state::RepoState;
30
31use crate::{
32 client::Client,
33 git::Repo,
34 list::list_from_remotes,
35 utils::{
36 find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url,
37 get_short_git_server_name, read_line, switch_clone_url_between_ssh_and_https,
38 },
39};
40
41#[allow(clippy::too_many_lines)]
42pub async fn run_push(
43 git_repo: &Repo,
44 repo_ref: &RepoRef,
45 nostr_remote_url: &str,
46 stdin: &Stdin,
47 initial_refspec: &str,
48 client: &Client,
49 list_outputs: Option<HashMap<String, HashMap<String, String>>>,
50) -> Result<()> {
51 let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?;
52
53 let proposal_refspecs = refspecs
54 .iter()
55 .filter(|r| r.contains("refs/heads/pr/"))
56 .cloned()
57 .collect::<Vec<String>>();
58
59 let mut git_server_refspecs = refspecs
60 .iter()
61 .filter(|r| !r.contains("refs/heads/pr/"))
62 .cloned()
63 .collect::<Vec<String>>();
64
65 let term = console::Term::stderr();
66
67 let list_outputs = match list_outputs {
68 Some(outputs) => outputs,
69 _ => list_from_remotes(&term, git_repo, &repo_ref.git_server)?,
70 };
71
72 let nostr_state = get_state_from_cache(git_repo.get_path()?, repo_ref).await;
73
74 let existing_state = {
75 // if no state events - create from first git server listed
76 if let Ok(nostr_state) = &nostr_state {
77 nostr_state.state.clone()
78 } else if let Some(url) = repo_ref
79 .git_server
80 .iter()
81 .find(|&url| list_outputs.contains_key(url))
82 {
83 list_outputs.get(url).unwrap().to_owned()
84 } else {
85 bail!(
86 "cannot connect to git servers: {}",
87 repo_ref.git_server.join(" ")
88 );
89 }
90 };
91
92 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs(
93 &term,
94 git_repo,
95 &git_server_refspecs,
96 &existing_state,
97 &list_outputs,
98 )?;
99
100 git_server_refspecs.retain(|refspec| {
101 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) {
102 let (_, to) = refspec_to_from_to(refspec).unwrap();
103 println!("error {to} {} out of sync with nostr", rejected.join(" "));
104 false
105 } else {
106 true
107 }
108 });
109
110 let mut events = vec![];
111
112 if git_server_refspecs.is_empty() && proposal_refspecs.is_empty() {
113 // all refspecs rejected
114 println!();
115 return Ok(());
116 }
117
118 let (signer, user_ref) = login::launch(
119 git_repo,
120 &None,
121 &None,
122 &None,
123 &None,
124 Some(client),
125 false,
126 true,
127 )
128 .await?;
129
130 if !repo_ref.maintainers.contains(&user_ref.public_key) {
131 for refspec in &git_server_refspecs {
132 let (_, to) = refspec_to_from_to(refspec).unwrap();
133 println!(
134 "error {to} your nostr account {} isn't listed as a maintainer of the repo",
135 user_ref.metadata.name
136 );
137 }
138 git_server_refspecs.clear();
139 if proposal_refspecs.is_empty() {
140 println!();
141 return Ok(());
142 }
143 }
144
145 if !git_server_refspecs.is_empty() {
146 let new_state = generate_updated_state(git_repo, &existing_state, &git_server_refspecs)?;
147
148 let new_repo_state =
149 RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?;
150
151 events.push(new_repo_state.event);
152
153 for event in get_merged_status_events(
154 &term,
155 repo_ref,
156 git_repo,
157 nostr_remote_url,
158 &signer,
159 &git_server_refspecs,
160 )
161 .await?
162 {
163 events.push(event);
164 }
165 }
166
167 let mut rejected_proposal_refspecs = vec![];
168 if !proposal_refspecs.is_empty() {
169 let all_proposals = get_all_proposals(git_repo, repo_ref).await?;
170 let current_user = get_curent_user(git_repo)?;
171
172 for refspec in &proposal_refspecs {
173 let (from, to) = refspec_to_from_to(refspec).unwrap();
174 let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?;
175
176 if let Some((_, (proposal, patches))) =
177 find_proposal_and_patches_by_branch_name(to, &all_proposals, &current_user)
178 {
179 if [repo_ref.maintainers.clone(), vec![proposal.author()]]
180 .concat()
181 .contains(&user_ref.public_key)
182 {
183 if refspec.starts_with('+') {
184 // force push
185 let (_, main_tip) = git_repo.get_main_or_master_branch()?;
186 let (mut ahead, _) =
187 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
188 ahead.reverse();
189 for patch in generate_cover_letter_and_patch_events(
190 None,
191 git_repo,
192 &ahead,
193 &signer,
194 repo_ref,
195 &Some(proposal.id().to_string()),
196 &[],
197 )
198 .await?
199 {
200 events.push(patch);
201 }
202 } else {
203 // fast forward push
204 let tip_patch = patches.first().unwrap();
205 let tip_of_proposal = get_commit_id_from_patch(tip_patch)?;
206 let tip_of_proposal_commit =
207 git_repo.get_commit_or_tip_of_reference(&tip_of_proposal)?;
208
209 let (mut ahead, behind) = git_repo.get_commits_ahead_behind(
210 &tip_of_proposal_commit,
211 &tip_of_pushed_branch,
212 )?;
213 if behind.is_empty() {
214 let thread_id = if let Ok(root_event_id) = get_event_root(tip_patch) {
215 root_event_id
216 } else {
217 // tip patch is the root proposal
218 tip_patch.id()
219 };
220 let mut parent_patch = tip_patch.clone();
221 ahead.reverse();
222 for (i, commit) in ahead.iter().enumerate() {
223 let new_patch = generate_patch_event(
224 git_repo,
225 &git_repo.get_root_commit()?,
226 commit,
227 Some(thread_id),
228 &signer,
229 repo_ref,
230 Some(parent_patch.id()),
231 Some((
232 (patches.len() + i + 1).try_into().unwrap(),
233 (patches.len() + ahead.len()).try_into().unwrap(),
234 )),
235 None,
236 &None,
237 &[],
238 )
239 .await
240 .context("cannot make patch event from commit")?;
241 events.push(new_patch.clone());
242 parent_patch = new_patch;
243 }
244 } else {
245 // we shouldn't get here
246 term.write_line(
247 format!(
248 "WARNING: failed to push {from} as nostr proposal. Try and force push ",
249 )
250 .as_str(),
251 )
252 .unwrap();
253 println!(
254 "error {to} cannot fastforward as newer patches found on proposal"
255 );
256 rejected_proposal_refspecs.push(refspec.to_string());
257 }
258 }
259 } else {
260 println!(
261 "error {to} permission denied. you are not the proposal author or a repo maintainer"
262 );
263 rejected_proposal_refspecs.push(refspec.to_string());
264 }
265 } else {
266 // TODO new proposal / couldn't find exisiting proposal
267 let (_, main_tip) = git_repo.get_main_or_master_branch()?;
268 let (mut ahead, _) =
269 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
270 ahead.reverse();
271 for patch in generate_cover_letter_and_patch_events(
272 None,
273 git_repo,
274 &ahead,
275 &signer,
276 repo_ref,
277 &None,
278 &[],
279 )
280 .await?
281 {
282 events.push(patch);
283 }
284 }
285 }
286 }
287
288 // TODO check whether tip of each branch pushed is on at least one git server
289 // before broadcasting the nostr state
290 if !events.is_empty() {
291 send_events(
292 client,
293 git_repo.get_path()?,
294 events,
295 user_ref.relays.write(),
296 repo_ref.relays.clone(),
297 false,
298 true,
299 )
300 .await?;
301 }
302
303 for refspec in &[git_server_refspecs.clone(), proposal_refspecs.clone()].concat() {
304 if rejected_proposal_refspecs.contains(refspec) {
305 continue;
306 }
307 let (_, to) = refspec_to_from_to(refspec)?;
308 println!("ok {to}");
309 update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url)
310 .context("could not update remote_ref locally")?;
311 }
312
313 // TODO make async - check gitlib2 callbacks work async
314 for (git_server_url, remote_refspecs) in remote_refspecs {
315 let remote_refspecs = remote_refspecs
316 .iter()
317 .filter(|refspec| git_server_refspecs.contains(refspec))
318 .cloned()
319 .collect::<Vec<String>>();
320 if !refspecs.is_empty()
321 && push_to_remote(git_repo, &git_server_url, &remote_refspecs, &term).is_err()
322 {
323 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(&git_server_url) {
324 if push_to_remote(git_repo, &alternative_url, &remote_refspecs, &term).is_err() {
325 // errors get printed as part of callback
326 // TODO prevent 2 warning messages and instead use one
327 // to say it didnt work over either https or ssh
328 } else {
329 term.write_line(
330 format!("but succeed over alterantive protocol {alternative_url}",)
331 .as_str(),
332 )?;
333 }
334 }
335 }
336 }
337 println!();
338 Ok(())
339}
340
341fn push_to_remote(
342 git_repo: &Repo,
343 git_server_url: &str,
344 remote_refspecs: &[String],
345 term: &Term,
346) -> Result<()> {
347 let git_config = git_repo.git_repo.config()?;
348 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?;
349 let auth = GitAuthenticator::default();
350 let mut push_options = git2::PushOptions::new();
351 let mut remote_callbacks = git2::RemoteCallbacks::new();
352 remote_callbacks.credentials(auth.credentials(&git_config));
353 remote_callbacks.push_update_reference(|name, error| {
354 if let Some(error) = error {
355 term.write_line(
356 format!(
357 "WARNING: {} failed to push {name} error: {error}",
358 get_short_git_server_name(git_repo, git_server_url),
359 )
360 .as_str(),
361 )
362 .unwrap();
363 }
364 Ok(())
365 });
366 push_options.remote_callbacks(remote_callbacks);
367 git_server_remote.push(remote_refspecs, Some(&mut push_options))?;
368 let _ = git_server_remote.disconnect();
369 Ok(())
370}
371
372type HashMapUrlRefspecs = HashMap<String, Vec<String>>;
373
374#[allow(clippy::too_many_lines)]
375fn create_rejected_refspecs_and_remotes_refspecs(
376 term: &console::Term,
377 git_repo: &Repo,
378 refspecs: &Vec<String>,
379 nostr_state: &HashMap<String, String>,
380 list_outputs: &HashMap<String, HashMap<String, String>>,
381) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> {
382 let mut refspecs_for_remotes = HashMap::new();
383
384 let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new();
385
386 for (url, remote_state) in list_outputs {
387 let short_name = get_short_git_server_name(git_repo, url);
388 let mut refspecs_for_remote = vec![];
389 for refspec in refspecs {
390 let (from, to) = refspec_to_from_to(refspec)?;
391 let nostr_value = nostr_state.get(to);
392 let remote_value = remote_state.get(to);
393 if from.is_empty() {
394 if remote_value.is_some() {
395 // delete remote branch
396 refspecs_for_remote.push(refspec.clone());
397 }
398 continue;
399 }
400 let from_tip = git_repo.get_commit_or_tip_of_reference(from)?;
401 if let Some(nostr_value) = nostr_value {
402 if let Some(remote_value) = remote_value {
403 if nostr_value.eq(remote_value) {
404 // in sync - existing branch at same state
405 let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) =
406 git_repo.get_commit_or_tip_of_reference(remote_value)
407 {
408 if let Ok((_, behind)) =
409 git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip)
410 {
411 behind.is_empty()
412 } else {
413 false
414 }
415 } else {
416 false
417 };
418 if is_remote_tip_ancestor_of_commit {
419 refspecs_for_remote.push(refspec.clone());
420 } else {
421 // this is a force push so we need to force push to git server too
422 if refspec.starts_with('+') {
423 refspecs_for_remote.push(refspec.clone());
424 } else {
425 refspecs_for_remote.push(format!("+{refspec}"));
426 }
427 }
428 } else if let Ok(remote_value_tip) =
429 git_repo.get_commit_or_tip_of_reference(remote_value)
430 {
431 if from_tip.eq(&remote_value_tip) {
432 // remote already at correct state
433 term.write_line(
434 format!("{short_name} {to} already up-to-date").as_str(),
435 )?;
436 }
437 let (ahead_of_local, behind_local) =
438 git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?;
439 if ahead_of_local.is_empty() {
440 // can soft push
441 refspecs_for_remote.push(refspec.clone());
442 } else {
443 // cant soft push
444 let (ahead_of_nostr, behind_nostr) = git_repo
445 .get_commits_ahead_behind(
446 &git_repo.get_commit_or_tip_of_reference(nostr_value)?,
447 &remote_value_tip,
448 )?;
449 if ahead_of_nostr.is_empty() {
450 // ancestor of nostr and we are force pushing anyway...
451 refspecs_for_remote.push(refspec.clone());
452 } else {
453 rejected_refspecs
454 .entry(refspec.to_string())
455 .and_modify(|a| a.push(url.to_string()))
456 .or_insert(vec![url.to_string()]);
457 term.write_line(
458 format!(
459 "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",
460 ahead_of_nostr.len(),
461 behind_nostr.len(),
462 ahead_of_local.len(),
463 behind_local.len(),
464 ).as_str(),
465 )?;
466 }
467 };
468 } else {
469 // remote_value oid is not present locally
470 // TODO can we download the remote reference?
471
472 // cant soft push
473 rejected_refspecs
474 .entry(refspec.to_string())
475 .and_modify(|a| a.push(url.to_string()))
476 .or_insert(vec![url.to_string()]);
477 term.write_line(
478 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(),
479 )?;
480 }
481 } else {
482 // existing nostr branch not on remote
483 // report - creating new branch
484 term.write_line(
485 format!(
486 "{short_name} {to} doesn't exist and will be added as a new branch"
487 )
488 .as_str(),
489 )?;
490 refspecs_for_remote.push(refspec.clone());
491 }
492 } else if let Some(remote_value) = remote_value {
493 // new to nostr but on remote
494 if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value)
495 {
496 let (ahead, behind) =
497 git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?;
498 if behind.is_empty() {
499 // can soft push
500 refspecs_for_remote.push(refspec.clone());
501 } else {
502 // cant soft push
503 rejected_refspecs
504 .entry(refspec.to_string())
505 .and_modify(|a| a.push(url.to_string()))
506 .or_insert(vec![url.to_string()]);
507 term.write_line(
508 format!(
509 "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",
510 ahead.len(),
511 behind.len(),
512 ).as_str(),
513 )?;
514 }
515 } else {
516 // havn't fetched oid from remote
517 // TODO fetch oid from remote
518 // cant soft push
519 rejected_refspecs
520 .entry(refspec.to_string())
521 .and_modify(|a| a.push(url.to_string()))
522 .or_insert(vec![url.to_string()]);
523 term.write_line(
524 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(),
525 )?;
526 }
527 } else {
528 // in sync - new branch
529 refspecs_for_remote.push(refspec.clone());
530 }
531 }
532 if !refspecs_for_remote.is_empty() {
533 refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote);
534 }
535 }
536
537 // remove rejected refspecs so they dont get pushed to some remotes
538 let mut remotes_refspecs_without_rejected = HashMap::new();
539 for (url, value) in &refspecs_for_remotes {
540 remotes_refspecs_without_rejected.insert(
541 url.to_string(),
542 value
543 .iter()
544 .filter(|refspec| !rejected_refspecs.contains_key(*refspec))
545 .cloned()
546 .collect(),
547 );
548 }
549 Ok((rejected_refspecs, remotes_refspecs_without_rejected))
550}
551
552fn generate_updated_state(
553 git_repo: &Repo,
554 existing_state: &HashMap<String, String>,
555 refspecs: &Vec<String>,
556) -> Result<HashMap<String, String>> {
557 let mut new_state = existing_state.clone();
558
559 for refspec in refspecs {
560 let (from, to) = refspec_to_from_to(refspec)?;
561 if from.is_empty() {
562 // delete
563 new_state.remove(to);
564 if to.contains("refs/tags") {
565 new_state.remove(&format!("{to}{}", "^{}"));
566 }
567 } else if to.contains("refs/tags") {
568 new_state.insert(
569 format!("{to}{}", "^{}"),
570 git_repo
571 .get_commit_or_tip_of_reference(from)
572 .unwrap()
573 .to_string(),
574 );
575 new_state.insert(
576 to.to_string(),
577 git_repo
578 .git_repo
579 .find_reference(to)
580 .unwrap()
581 .peel(git2::ObjectType::Tag)
582 .unwrap()
583 .id()
584 .to_string(),
585 );
586 } else {
587 // add or update
588 new_state.insert(
589 to.to_string(),
590 git_repo
591 .get_commit_or_tip_of_reference(from)
592 .unwrap()
593 .to_string(),
594 );
595 }
596 }
597 Ok(new_state)
598}
599
600async fn get_merged_status_events(
601 term: &console::Term,
602 repo_ref: &RepoRef,
603 git_repo: &Repo,
604 remote_nostr_url: &str,
605 signer: &NostrSigner,
606 refspecs_to_git_server: &Vec<String>,
607) -> Result<Vec<Event>> {
608 let mut events = vec![];
609 for refspec in refspecs_to_git_server {
610 let (from, to) = refspec_to_from_to(refspec)?;
611 if to.eq("refs/heads/main") || to.eq("refs/heads/master") {
612 let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?;
613 let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference(
614 &refspec_remote_ref_name(&git_repo.git_repo, refspec, remote_nostr_url)?,
615 ) else {
616 // branch not on remote
617 continue;
618 };
619 let (ahead, _) =
620 git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?;
621 for commit_hash in ahead {
622 let commit = git_repo.git_repo.find_commit(sha1_to_oid(&commit_hash)?)?;
623 if commit.parent_count() > 1 {
624 // merge commit
625 for parent in commit.parents() {
626 // lookup parent id
627 let commit_events = get_events_from_cache(
628 git_repo.get_path()?,
629 vec![
630 nostr::Filter::default()
631 .kind(nostr::Kind::GitPatch)
632 .reference(parent.id().to_string()),
633 ],
634 )
635 .await?;
636 if let Some(commit_event) = commit_events.iter().find(|e| {
637 e.tags.iter().any(|t| {
638 t.as_vec()[0].eq("commit")
639 && t.as_vec()[1].eq(&parent.id().to_string())
640 })
641 }) {
642 let (proposal_id, revision_id) =
643 get_proposal_and_revision_root_from_patch(git_repo, commit_event)
644 .await?;
645 term.write_line(
646 format!(
647 "merge commit {}: create nostr proposal status event",
648 &commit.id().to_string()[..7],
649 )
650 .as_str(),
651 )?;
652
653 events.push(
654 create_merge_status(
655 signer,
656 repo_ref,
657 &get_event_from_cache_by_id(git_repo, &proposal_id).await?,
658 &if let Some(revision_id) = revision_id {
659 Some(
660 get_event_from_cache_by_id(git_repo, &revision_id)
661 .await?,
662 )
663 } else {
664 None
665 },
666 &commit_hash,
667 commit_event.id(),
668 )
669 .await?,
670 );
671 }
672 }
673 }
674 }
675 }
676 }
677 Ok(events)
678}
679
680async fn create_merge_status(
681 signer: &NostrSigner,
682 repo_ref: &RepoRef,
683 proposal: &Event,
684 revision: &Option<Event>,
685 merge_commit: &Sha1Hash,
686 merged_patch: EventId,
687) -> Result<Event> {
688 let mut public_keys = repo_ref
689 .maintainers
690 .iter()
691 .copied()
692 .collect::<HashSet<PublicKey>>();
693 public_keys.insert(proposal.author());
694 if let Some(revision) = revision {
695 public_keys.insert(revision.author());
696 }
697 sign_event(
698 EventBuilder::new(
699 nostr::event::Kind::GitStatusApplied,
700 String::new(),
701 [
702 vec![
703 Tag::custom(
704 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
705 vec!["git proposal merged / applied".to_string()],
706 ),
707 Tag::from_standardized(nostr::TagStandard::Event {
708 event_id: proposal.id(),
709 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
710 marker: Some(Marker::Root),
711 public_key: None,
712 }),
713 Tag::from_standardized(nostr::TagStandard::Event {
714 event_id: merged_patch,
715 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
716 marker: Some(Marker::Mention),
717 public_key: None,
718 }),
719 ],
720 if let Some(revision) = revision {
721 vec![Tag::from_standardized(nostr::TagStandard::Event {
722 event_id: revision.id(),
723 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
724 marker: Some(Marker::Root),
725 public_key: None,
726 })]
727 } else {
728 vec![]
729 },
730 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
731 repo_ref
732 .coordinates()
733 .iter()
734 .map(|c| Tag::coordinate(c.clone()))
735 .collect::<Vec<Tag>>(),
736 vec![
737 Tag::from_standardized(nostr::TagStandard::Reference(
738 repo_ref.root_commit.to_string(),
739 )),
740 Tag::from_standardized(nostr::TagStandard::Reference(format!(
741 "{merge_commit}"
742 ))),
743 Tag::custom(
744 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-commit-id")),
745 vec![format!("{merge_commit}")],
746 ),
747 ],
748 ]
749 .concat(),
750 ),
751 signer,
752 )
753 .await
754}
755
756async fn get_proposal_and_revision_root_from_patch(
757 git_repo: &Repo,
758 patch: &Event,
759) -> Result<(EventId, Option<EventId>)> {
760 let proposal_or_revision = if patch.tags.iter().any(|t| t.as_vec()[1].eq("root")) {
761 patch.clone()
762 } else {
763 let proposal_or_revision_id = EventId::parse(
764 if let Some(t) = patch.tags.iter().find(|t| t.is_root()) {
765 t.clone()
766 } else if let Some(t) = patch.tags.iter().find(|t| t.is_reply()) {
767 t.clone()
768 } else {
769 Tag::event(patch.id())
770 }
771 .as_vec()[1]
772 .clone(),
773 )?;
774
775 get_events_from_cache(
776 git_repo.get_path()?,
777 vec![nostr::Filter::default().id(proposal_or_revision_id)],
778 )
779 .await?
780 .first()
781 .unwrap()
782 .clone()
783 };
784
785 if !proposal_or_revision.kind().eq(&Kind::GitPatch) {
786 bail!("thread root is not a git patch");
787 }
788
789 if proposal_or_revision
790 .tags
791 .iter()
792 .any(|t| t.as_vec()[1].eq("revision-root"))
793 {
794 Ok((
795 EventId::parse(
796 proposal_or_revision
797 .tags
798 .iter()
799 .find(|t| t.is_reply())
800 .unwrap()
801 .as_vec()[1]
802 .clone(),
803 )?,
804 Some(proposal_or_revision.id()),
805 ))
806 } else {
807 Ok((proposal_or_revision.id(), None))
808 }
809}
810
811fn update_remote_refs_pushed(
812 git_repo: &Repository,
813 refspec: &str,
814 nostr_remote_url: &str,
815) -> Result<()> {
816 let (from, _) = refspec_to_from_to(refspec)?;
817
818 let target_ref_name = refspec_remote_ref_name(git_repo, refspec, nostr_remote_url)?;
819
820 if from.is_empty() {
821 if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) {
822 remote_ref.delete()?;
823 }
824 } else {
825 let commit = reference_to_commit(git_repo, from)
826 .context(format!("cannot get commit of reference {from}"))?;
827 if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) {
828 remote_ref.set_target(commit, "updated by nostr remote helper")?;
829 } else {
830 git_repo.reference(
831 &target_ref_name,
832 commit,
833 false,
834 "created by nostr remote helper",
835 )?;
836 }
837 }
838 Ok(())
839}
840
841fn refspec_to_from_to(refspec: &str) -> Result<(&str, &str)> {
842 if !refspec.contains(':') {
843 bail!(
844 "refspec should contain a colon (:) but consists of: {}",
845 refspec
846 );
847 }
848 let parts = refspec.split(':').collect::<Vec<&str>>();
849 Ok((
850 if parts.first().unwrap().starts_with('+') {
851 &parts.first().unwrap()[1..]
852 } else {
853 parts.first().unwrap()
854 },
855 parts.get(1).unwrap(),
856 ))
857}
858
859fn refspec_remote_ref_name(
860 git_repo: &Repository,
861 refspec: &str,
862 nostr_remote_url: &str,
863) -> Result<String> {
864 let (_, to) = refspec_to_from_to(refspec)?;
865 let nostr_remote = git_repo
866 .find_remote(&get_remote_name_by_url(git_repo, nostr_remote_url)?)
867 .context("we should have just located this remote")?;
868 Ok(format!(
869 "refs/remotes/{}/{}",
870 nostr_remote.name().context("remote should have a name")?,
871 to.replace("refs/heads/", ""), /* TODO only replace if it begins with this
872 * TODO what about tags? */
873 ))
874}
875
876fn reference_to_commit(git_repo: &Repository, reference: &str) -> Result<Oid> {
877 Ok(git_repo
878 .find_reference(reference)
879 .context(format!("cannot find reference: {reference}"))?
880 .peel_to_commit()
881 .context(format!("cannot get commit from reference: {reference}"))?
882 .id())
883}
884
885// this maybe a commit id or a ref: pointer
886fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result<String> {
887 let reference_obj = git_repo
888 .find_reference(reference)
889 .context(format!("cannot find reference: {reference}"))?;
890 if let Some(symref) = reference_obj.symbolic_target() {
891 Ok(symref.to_string())
892 } else {
893 Ok(reference_obj
894 .peel_to_commit()
895 .context(format!("cannot get commit from reference: {reference}"))?
896 .id()
897 .to_string())
898 }
899}
900
901fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result<Vec<String>> {
902 let mut line = String::new();
903 let mut refspecs = vec![initial_refspec.to_string()];
904 loop {
905 let tokens = read_line(stdin, &mut line)?;
906 match tokens.as_slice() {
907 ["push", spec] => {
908 refspecs.push((*spec).to_string());
909 }
910 [] => break,
911 _ => {
912 bail!("after a `push` command we are only expecting another push or an empty line")
913 }
914 }
915 }
916 Ok(refspecs)
917}
918
919trait BuildRepoState {
920 async fn build(
921 identifier: String,
922 state: HashMap<String, String>,
923 signer: &NostrSigner,
924 ) -> Result<RepoState>;
925}
926impl BuildRepoState for RepoState {
927 async fn build(
928 identifier: String,
929 state: HashMap<String, String>,
930 signer: &NostrSigner,
931 ) -> Result<RepoState> {
932 let mut tags = vec![Tag::identifier(identifier.clone())];
933 for (name, value) in &state {
934 tags.push(Tag::custom(
935 nostr_sdk::TagKind::Custom(name.into()),
936 vec![value.clone()],
937 ));
938 }
939 let event = sign_event(EventBuilder::new(STATE_KIND, "", tags), signer).await?;
940 Ok(RepoState {
941 identifier,
942 state,
943 event,
944 })
945 }
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 mod refspec_to_from_to {
953 use super::*;
954
955 #[test]
956 fn trailing_plus_stripped() {
957 let (from, _) = refspec_to_from_to("+testing:testingb").unwrap();
958 assert_eq!(from, "testing");
959 }
960 }
961}