upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/bin/git_remote_nostr/fetch.rs145
-rw-r--r--src/bin/git_remote_nostr/list.rs194
-rw-r--r--src/bin/git_remote_nostr/main.rs1542
-rw-r--r--src/bin/git_remote_nostr/push.rs961
-rw-r--r--src/bin/git_remote_nostr/utils.rs248
-rw-r--r--src/lib/client.rs12
-rw-r--r--src/lib/git_events.rs15
-rw-r--r--src/lib/login/mod.rs14
8 files changed, 1606 insertions, 1525 deletions
diff --git a/src/bin/git_remote_nostr/fetch.rs b/src/bin/git_remote_nostr/fetch.rs
new file mode 100644
index 0000000..5fd8816
--- /dev/null
+++ b/src/bin/git_remote_nostr/fetch.rs
@@ -0,0 +1,145 @@
1use std::{collections::HashMap, io::Stdin};
2
3use anyhow::{anyhow, bail, Result};
4use auth_git2::GitAuthenticator;
5use git2::Repository;
6use ngit::{
7 git::{Repo, RepoActions},
8 git_events::tag_value,
9 login::get_curent_user,
10 repo_ref::RepoRef,
11};
12
13use crate::utils::{
14 find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch, get_open_proposals,
15 get_short_git_server_name, switch_clone_url_between_ssh_and_https,
16};
17
18pub async fn run_fetch(
19 git_repo: &Repo,
20 repo_ref: &RepoRef,
21 stdin: &Stdin,
22 oid: &str,
23 refstr: &str,
24) -> Result<()> {
25 let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?;
26
27 let oids_from_git_servers = fetch_batch
28 .iter()
29 .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/"))
30 .map(|(_, oid)| oid.clone())
31 .collect::<Vec<String>>();
32
33 let mut errors = HashMap::new();
34 let term = console::Term::stderr();
35
36 for git_server_url in &repo_ref.git_server {
37 let term = console::Term::stderr();
38 let short_name = get_short_git_server_name(git_repo, git_server_url);
39 term.write_line(format!("fetching from {short_name}...").as_str())?;
40 let res = fetch_from_git_server(&git_repo.git_repo, &oids_from_git_servers, git_server_url);
41 term.clear_last_lines(1)?;
42 if let Err(error1) = res {
43 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(git_server_url) {
44 let res2 = fetch_from_git_server(
45 &git_repo.git_repo,
46 &oids_from_git_servers,
47 &alternative_url,
48 );
49 if let Err(error2) = res2 {
50 term.write_line(
51 format!(
52 "WARNING: failed to fetch from {short_name} error:{error1}\r\nand using alternative protocol {alternative_url}: {error2}"
53 ).as_str()
54 )?;
55 errors.insert(
56 short_name.to_string(),
57 anyhow!(
58 "{error1} and using alternative protocol {alternative_url}: {error2}"
59 ),
60 );
61 } else {
62 break;
63 }
64 } else {
65 term.write_line(
66 format!("WARNING: failed to fetch from {short_name} error:{error1}").as_str(),
67 )?;
68 errors.insert(short_name.to_string(), error1);
69 }
70 } else {
71 break;
72 }
73 }
74
75 if oids_from_git_servers
76 .iter()
77 .any(|oid| !git_repo.does_commit_exist(oid).unwrap())
78 && !errors.is_empty()
79 {
80 bail!(
81 "failed to fetch objects in nostr state event from:\r\n{}",
82 errors
83 .iter()
84 .map(|(url, error)| format!("{url}: {error}"))
85 .collect::<Vec<String>>()
86 .join("\r\n")
87 );
88 }
89
90 fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/"));
91
92 if !fetch_batch.is_empty() {
93 let open_proposals = get_open_proposals(git_repo, repo_ref).await?;
94
95 let current_user = get_curent_user(git_repo)?;
96
97 for (refstr, oid) in fetch_batch {
98 if let Some((_, (_, patches))) =
99 find_proposal_and_patches_by_branch_name(&refstr, &open_proposals, &current_user)
100 {
101 if !git_repo.does_commit_exist(&oid)? {
102 let mut patches_ancestor_first = patches.clone();
103 patches_ancestor_first.reverse();
104 if git_repo.does_commit_exist(&tag_value(
105 patches_ancestor_first.first().unwrap(),
106 "parent-commit",
107 )?)? {
108 for patch in &patches_ancestor_first {
109 git_repo.create_commit_from_patch(patch)?;
110 }
111 } else {
112 term.write_line(
113 format!("WARNING: cannot find parent commit for {refstr}").as_str(),
114 )?;
115 }
116 }
117 } else {
118 term.write_line(format!("WARNING: cannot find proposal for {refstr}").as_str())?;
119 }
120 }
121 }
122
123 term.flush()?;
124 println!();
125 Ok(())
126}
127
128fn fetch_from_git_server(
129 git_repo: &Repository,
130 oids: &[String],
131 git_server_url: &str,
132) -> Result<()> {
133 let git_config = git_repo.config()?;
134
135 let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?;
136 // authentication may be required (and will be requird if clone url is ssh)
137 let auth = GitAuthenticator::default();
138 let mut fetch_options = git2::FetchOptions::new();
139 let mut remote_callbacks = git2::RemoteCallbacks::new();
140 remote_callbacks.credentials(auth.credentials(&git_config));
141 fetch_options.remote_callbacks(remote_callbacks);
142 git_server_remote.download(oids, Some(&mut fetch_options))?;
143 git_server_remote.disconnect()?;
144 Ok(())
145}
diff --git a/src/bin/git_remote_nostr/list.rs b/src/bin/git_remote_nostr/list.rs
index e69de29..097f070 100644
--- a/src/bin/git_remote_nostr/list.rs
+++ b/src/bin/git_remote_nostr/list.rs
@@ -0,0 +1,194 @@
1use core::str;
2use std::collections::HashMap;
3
4use anyhow::{Context, Result};
5use auth_git2::GitAuthenticator;
6use client::get_state_from_cache;
7use git::RepoActions;
8use git_events::{event_to_cover_letter, get_commit_id_from_patch};
9use ngit::{client, git, git_events, login::get_curent_user, repo_ref};
10use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
11use repo_ref::RepoRef;
12
13use crate::{
14 git::Repo,
15 utils::{
16 get_open_proposals, get_short_git_server_name, switch_clone_url_between_ssh_and_https,
17 },
18};
19
20pub async fn run_list(
21 git_repo: &Repo,
22 repo_ref: &RepoRef,
23 for_push: bool,
24) -> Result<HashMap<String, HashMap<String, String>>> {
25 let nostr_state =
26 if let Ok(nostr_state) = get_state_from_cache(git_repo.get_path()?, repo_ref).await {
27 Some(nostr_state)
28 } else {
29 None
30 };
31
32 let term = console::Term::stderr();
33
34 let remote_states = list_from_remotes(&term, git_repo, &repo_ref.git_server)?;
35
36 let mut state = if let Some(nostr_state) = nostr_state {
37 for (name, value) in &nostr_state.state {
38 for (url, remote_state) in &remote_states {
39 let remote_name = get_short_git_server_name(git_repo, url);
40 if let Some(remote_value) = remote_state.get(name) {
41 if value.ne(remote_value) {
42 term.write_line(
43 format!(
44 "WARNING: {remote_name} {name} is {} nostr ",
45 if let Ok((ahead, behind)) =
46 get_ahead_behind(git_repo, value, remote_value)
47 {
48 format!("{} ahead {} behind", ahead.len(), behind.len())
49 } else {
50 "out of sync with".to_string()
51 }
52 )
53 .as_str(),
54 )?;
55 }
56 } else {
57 term.write_line(
58 format!("WARNING: {remote_name} {name} is missing but tracked on nostr")
59 .as_str(),
60 )?;
61 }
62 }
63 }
64 nostr_state.state
65 } else {
66 repo_ref
67 .git_server
68 .iter()
69 .filter_map(|server| remote_states.get(server))
70 .cloned()
71 .collect::<Vec<HashMap<String, String>>>()
72 .first()
73 .context("failed to get refs from git server")?
74 .clone()
75 };
76
77 state.retain(|k, _| !k.starts_with("refs/heads/pr/"));
78
79 let open_proposals = get_open_proposals(git_repo, repo_ref).await?;
80 let current_user = get_curent_user(git_repo)?;
81 for (_, (proposal, patches)) in open_proposals {
82 if let Ok(cl) = event_to_cover_letter(&proposal) {
83 if let Ok(mut branch_name) = cl.get_branch_name() {
84 branch_name = if let Some(public_key) = current_user {
85 if proposal.author().eq(&public_key) {
86 cl.branch_name.to_string()
87 } else {
88 branch_name
89 }
90 } else {
91 branch_name
92 };
93 if let Some(patch) = patches.first() {
94 // TODO this isn't resilient because the commit id stated may not be correct
95 // we will need to check whether the commit id exists in the repo or apply the
96 // proposal and each patch to check
97 if let Ok(commit_id) = get_commit_id_from_patch(patch) {
98 state.insert(format!("refs/heads/{branch_name}"), commit_id);
99 }
100 }
101 }
102 }
103 }
104
105 // TODO 'for push' should we check with the git servers to see if any of them
106 // allow push from the user?
107 for (name, value) in state {
108 if value.starts_with("ref: ") {
109 if !for_push {
110 println!("{} {name}", value.replace("ref: ", "@"));
111 }
112 } else {
113 println!("{value} {name}");
114 }
115 }
116
117 println!();
118 Ok(remote_states)
119}
120
121pub fn list_from_remotes(
122 term: &console::Term,
123 git_repo: &Repo,
124 git_servers: &Vec<String>,
125) -> Result<HashMap<String, HashMap<String, String>>> {
126 let mut remote_states = HashMap::new();
127 for url in git_servers {
128 let short_name = get_short_git_server_name(git_repo, url);
129 term.write_line(format!("fetching refs list: {short_name}...").as_str())?;
130 match list_from_remote(git_repo, url) {
131 Ok(remote_state) => {
132 remote_states.insert(url.clone(), remote_state);
133 }
134 Err(error1) => {
135 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(url) {
136 match list_from_remote(git_repo, &alternative_url) {
137 Ok(remote_state) => {
138 remote_states.insert(url.clone(), remote_state);
139 }
140 Err(error2) => {
141 term.write_line(
142 format!("WARNING: {short_name} failed to list refs error: {error1}\r\nand alternative protocol {alternative_url}: {error2}").as_str(),
143 )?;
144 }
145 }
146 } else {
147 term.write_line(
148 format!("WARNING: {short_name} failed to list refs error: {error1}",)
149 .as_str(),
150 )?;
151 }
152 }
153 }
154 term.clear_last_lines(1)?;
155 }
156 Ok(remote_states)
157}
158
159fn list_from_remote(
160 git_repo: &Repo,
161 git_server_remote_url: &str,
162) -> Result<HashMap<String, String>> {
163 let git_config = git_repo.git_repo.config()?;
164
165 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_remote_url)?;
166 // authentication may be required
167 let auth = GitAuthenticator::default();
168 let mut remote_callbacks = git2::RemoteCallbacks::new();
169 remote_callbacks.credentials(auth.credentials(&git_config));
170 git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?;
171 let mut state = HashMap::new();
172 for head in git_server_remote.list()? {
173 if let Some(symbolic_reference) = head.symref_target() {
174 state.insert(
175 head.name().to_string(),
176 format!("ref: {symbolic_reference}"),
177 );
178 } else {
179 state.insert(head.name().to_string(), head.oid().to_string());
180 }
181 }
182 git_server_remote.disconnect()?;
183 Ok(state)
184}
185
186fn get_ahead_behind(
187 git_repo: &Repo,
188 base_ref_or_oid: &str,
189 latest_ref_or_oid: &str,
190) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> {
191 let base = git_repo.get_commit_or_tip_of_reference(base_ref_or_oid)?;
192 let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?;
193 git_repo.get_commits_ahead_behind(&base, &latest)
194}
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}
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}
diff --git a/src/bin/git_remote_nostr/utils.rs b/src/bin/git_remote_nostr/utils.rs
new file mode 100644
index 0000000..c53c34f
--- /dev/null
+++ b/src/bin/git_remote_nostr/utils.rs
@@ -0,0 +1,248 @@
1use std::{
2 collections::HashMap,
3 io::{self, Stdin},
4};
5
6use anyhow::{bail, Context, Result};
7use git2::Repository;
8use ngit::{
9 client::{
10 get_all_proposal_patch_events_from_cache, get_events_from_cache,
11 get_proposals_and_revisions_from_cache,
12 },
13 git::{Repo, RepoActions},
14 git_events::{
15 event_is_revision_root, event_to_cover_letter, get_most_recent_patch_with_ancestors,
16 status_kinds,
17 },
18 repo_ref::RepoRef,
19};
20use nostr_sdk::{Event, EventId, Kind, PublicKey, Url};
21
22pub fn get_short_git_server_name(git_repo: &Repo, url: &str) -> std::string::String {
23 if let Ok(name) = get_remote_name_by_url(&git_repo.git_repo, url) {
24 return name;
25 }
26 if let Ok(url) = Url::parse(url) {
27 if let Some(domain) = url.domain() {
28 return domain.to_string();
29 }
30 }
31 url.to_string()
32}
33
34pub fn get_remote_name_by_url(git_repo: &Repository, url: &str) -> Result<String> {
35 let remotes = git_repo.remotes()?;
36 Ok(remotes
37 .iter()
38 .find(|r| {
39 if let Some(name) = r {
40 if let Some(remote_url) = git_repo.find_remote(name).unwrap().url() {
41 url == remote_url
42 } else {
43 false
44 }
45 } else {
46 false
47 }
48 })
49 .context("could not find remote with matching url")?
50 .context("remote with matching url must be named")?
51 .to_string())
52}
53
54pub fn get_oids_from_fetch_batch(
55 stdin: &Stdin,
56 initial_oid: &str,
57 initial_refstr: &str,
58) -> Result<HashMap<String, String>> {
59 let mut line = String::new();
60 let mut batch = HashMap::new();
61 batch.insert(initial_refstr.to_string(), initial_oid.to_string());
62 loop {
63 let tokens = read_line(stdin, &mut line)?;
64 match tokens.as_slice() {
65 ["fetch", oid, refstr] => {
66 batch.insert((*refstr).to_string(), (*oid).to_string());
67 }
68 [] => break,
69 _ => bail!(
70 "after a `fetch` command we are only expecting another fetch or an empty line"
71 ),
72 }
73 }
74 Ok(batch)
75}
76
77/// Read one line from stdin, and split it into tokens.
78pub fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
79 line.clear();
80
81 let read = stdin.read_line(line)?;
82 if read == 0 {
83 return Ok(vec![]);
84 }
85 let line = line.trim();
86 let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
87
88 Ok(tokens)
89}
90
91pub fn switch_clone_url_between_ssh_and_https(url: &str) -> Result<String> {
92 if url.starts_with("https://") {
93 // Convert HTTPS to git@ syntax
94 let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect();
95 if parts.len() >= 2 {
96 // Construct the git@ URL
97 Ok(format!("git@{}:{}", parts[0], parts[1..].join("/")))
98 } else {
99 // If the format is unexpected, return an error
100 bail!("Invalid HTTPS URL format: {}", url);
101 }
102 } else if url.starts_with("ssh://") {
103 // Convert SSH to git@ syntax
104 let parts: Vec<&str> = url.trim_start_matches("ssh://").split('/').collect();
105 if parts.len() >= 2 {
106 // Construct the git@ URL
107 Ok(format!("git@{}:{}", parts[0], parts[1..].join("/")))
108 } else {
109 // If the format is unexpected, return an error
110 bail!("Invalid SSH URL format: {}", url);
111 }
112 } else if url.starts_with("git@") {
113 // Convert git@ syntax to HTTPS
114 let parts: Vec<&str> = url.split(':').collect();
115 if parts.len() == 2 {
116 // Construct the HTTPS URL
117 Ok(format!(
118 "https://{}/{}",
119 parts[0].trim_end_matches('@'),
120 parts[1]
121 ))
122 } else {
123 // If the format is unexpected, return an error
124 bail!("Invalid git@ URL format: {}", url);
125 }
126 } else {
127 // If the URL is neither HTTPS, SSH, nor git@, return an error
128 bail!("Unsupported URL protocol: {}", url);
129 }
130}
131
132pub async fn get_open_proposals(
133 git_repo: &Repo,
134 repo_ref: &RepoRef,
135) -> Result<HashMap<EventId, (Event, Vec<Event>)>> {
136 let git_repo_path = git_repo.get_path()?;
137 let proposals: Vec<nostr::Event> =
138 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
139 .await?
140 .iter()
141 .filter(|e| !event_is_revision_root(e))
142 .cloned()
143 .collect();
144
145 let statuses: Vec<nostr::Event> = {
146 let mut statuses = get_events_from_cache(
147 git_repo_path,
148 vec![
149 nostr::Filter::default()
150 .kinds(status_kinds().clone())
151 .events(proposals.iter().map(nostr::Event::id)),
152 ],
153 )
154 .await?;
155 statuses.sort_by_key(|e| e.created_at);
156 statuses.reverse();
157 statuses
158 };
159 let mut open_proposals = HashMap::new();
160
161 for proposal in proposals {
162 let status = if let Some(e) = statuses
163 .iter()
164 .filter(|e| {
165 status_kinds().contains(&e.kind())
166 && e.tags()
167 .iter()
168 .any(|t| t.as_vec()[1].eq(&proposal.id.to_string()))
169 })
170 .collect::<Vec<&nostr::Event>>()
171 .first()
172 {
173 e.kind()
174 } else {
175 Kind::GitStatusOpen
176 };
177 if status.eq(&Kind::GitStatusOpen) {
178 if let Ok(commits_events) =
179 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id)
180 .await
181 {
182 if let Ok(most_recent_proposal_patch_chain) =
183 get_most_recent_patch_with_ancestors(commits_events.clone())
184 {
185 open_proposals
186 .insert(proposal.id(), (proposal, most_recent_proposal_patch_chain));
187 }
188 }
189 }
190 }
191 Ok(open_proposals)
192}
193
194pub async fn get_all_proposals(
195 git_repo: &Repo,
196 repo_ref: &RepoRef,
197) -> Result<HashMap<EventId, (Event, Vec<Event>)>> {
198 let git_repo_path = git_repo.get_path()?;
199 let proposals: Vec<nostr::Event> =
200 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
201 .await?
202 .iter()
203 .filter(|e| !event_is_revision_root(e))
204 .cloned()
205 .collect();
206
207 let mut all_proposals = HashMap::new();
208
209 for proposal in proposals {
210 if let Ok(commits_events) =
211 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id).await
212 {
213 if let Ok(most_recent_proposal_patch_chain) =
214 get_most_recent_patch_with_ancestors(commits_events.clone())
215 {
216 all_proposals.insert(proposal.id(), (proposal, most_recent_proposal_patch_chain));
217 }
218 }
219 }
220 Ok(all_proposals)
221}
222
223pub fn find_proposal_and_patches_by_branch_name<'a>(
224 refstr: &'a str,
225 open_proposals: &'a HashMap<EventId, (Event, Vec<Event>)>,
226 current_user: &Option<PublicKey>,
227) -> Option<(&'a EventId, &'a (Event, Vec<Event>))> {
228 open_proposals.iter().find(|(_, (proposal, _))| {
229 if let Ok(cl) = event_to_cover_letter(proposal) {
230 if let Ok(mut branch_name) = cl.get_branch_name() {
231 branch_name = if let Some(public_key) = current_user {
232 if proposal.author().eq(public_key) {
233 cl.branch_name.to_string()
234 } else {
235 branch_name
236 }
237 } else {
238 branch_name
239 };
240 branch_name.eq(&refstr.replace("refs/heads/", ""))
241 } else {
242 false
243 }
244 } else {
245 false
246 }
247 })
248}
diff --git a/src/lib/client.rs b/src/lib/client.rs
index ace880b..c29d4b9 100644
--- a/src/lib/client.rs
+++ b/src/lib/client.rs
@@ -38,6 +38,7 @@ use nostr_sqlite::SQLiteDatabase;
38 38
39use crate::{ 39use crate::{
40 get_dirs, 40 get_dirs,
41 git::{Repo, RepoActions},
41 git_events::{ 42 git_events::{
42 event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds, 43 event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds,
43 }, 44 },
@@ -1572,6 +1573,17 @@ pub async fn get_all_proposal_patch_events_from_cache(
1572 .collect()) 1573 .collect())
1573} 1574}
1574 1575
1576pub async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> {
1577 Ok(get_events_from_cache(
1578 git_repo.get_path()?,
1579 vec![nostr::Filter::default().id(*event_id)],
1580 )
1581 .await?
1582 .first()
1583 .context("cannot find event in cache")?
1584 .clone())
1585}
1586
1575#[allow(clippy::module_name_repetitions)] 1587#[allow(clippy::module_name_repetitions)]
1576#[allow(clippy::too_many_lines)] 1588#[allow(clippy::too_many_lines)]
1577pub async fn send_events( 1589pub async fn send_events(
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index 8689b33..2e9f797 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -3,7 +3,7 @@ use std::str::FromStr;
3use anyhow::{bail, Context, Result}; 3use anyhow::{bail, Context, Result};
4use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19}; 4use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19};
5use nostr_sdk::{ 5use nostr_sdk::{
6 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, FromBech32, Kind, Tag, TagKind, 6 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, FromBech32, Kind, Tag, TagKind,
7 TagStandard, UncheckedUrl, 7 TagStandard, UncheckedUrl,
8}; 8};
9use nostr_signer::NostrSigner; 9use nostr_signer::NostrSigner;
@@ -37,6 +37,19 @@ pub fn get_commit_id_from_patch(event: &Event) -> Result<String> {
37 } 37 }
38} 38}
39 39
40pub fn get_event_root(event: &nostr::Event) -> Result<EventId> {
41 Ok(EventId::parse(
42 event
43 .tags()
44 .iter()
45 .find(|t| t.is_root())
46 .context("no thread root in event")?
47 .as_vec()
48 .get(1)
49 .unwrap(),
50 )?)
51}
52
40pub fn status_kinds() -> Vec<Kind> { 53pub fn status_kinds() -> Vec<Kind> {
41 vec![ 54 vec![
42 Kind::GitStatusOpen, 55 Kind::GitStatusOpen,
diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs
index 7364edf..938a6f1 100644
--- a/src/lib/login/mod.rs
+++ b/src/lib/login/mod.rs
@@ -696,3 +696,17 @@ pub async fn get_user_ref_from_cache(
696 relays: extract_user_relays(public_key, &events), 696 relays: extract_user_relays(public_key, &events),
697 }) 697 })
698} 698}
699
700pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> {
701 Ok(
702 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
703 if let Ok(public_key) = PublicKey::parse(npub) {
704 Some(public_key)
705 } else {
706 None
707 }
708 } else {
709 None
710 },
711 )
712}