upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 08:04:48 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 13:30:59 +0100
commit949c6459aa7683453a7160423b689ceadb08954b (patch)
tree230c26ecb11b99916e5570e548673eb09ecf0a36 /src/lib
parenta825311f2c55661aaab3a163bda9109295c96044 (diff)
refactor: organise into lib and bin structure
the make the code more readable this commit just moves the files, the next commit should fix the imports
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/cli_interactor.rs186
-rw-r--r--src/lib/client.rs1480
-rw-r--r--src/lib/git/mod.rs2566
-rw-r--r--src/lib/login/key_encryption.rs105
-rw-r--r--src/lib/login/mod.rs695
-rw-r--r--src/lib/login/user.rs47
-rw-r--r--src/lib/mod.rs16
-rw-r--r--src/lib/repo_ref.rs700
-rw-r--r--src/lib/repo_state.rs40
9 files changed, 5835 insertions, 0 deletions
diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs
new file mode 100644
index 0000000..4cf6357
--- /dev/null
+++ b/src/lib/cli_interactor.rs
@@ -0,0 +1,186 @@
1use anyhow::{Context, Result};
2use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password};
3#[cfg(test)]
4use mockall::*;
5
6#[derive(Default)]
7pub struct Interactor {
8 theme: ColorfulTheme,
9}
10
11#[cfg_attr(test, automock)]
12pub trait InteractorPrompt {
13 fn input(&self, parms: PromptInputParms) -> Result<String>;
14 fn password(&self, parms: PromptPasswordParms) -> Result<String>;
15 fn confirm(&self, params: PromptConfirmParms) -> Result<bool>;
16 fn choice(&self, params: PromptChoiceParms) -> Result<usize>;
17 fn multi_choice(&self, params: PromptMultiChoiceParms) -> Result<Vec<usize>>;
18}
19impl InteractorPrompt for Interactor {
20 fn input(&self, parms: PromptInputParms) -> Result<String> {
21 let mut input = Input::with_theme(&self.theme);
22 input.with_prompt(parms.prompt).allow_empty(parms.optional);
23 if !parms.default.is_empty() {
24 input.default(parms.default);
25 }
26 Ok(input.interact_text()?)
27 }
28 fn password(&self, parms: PromptPasswordParms) -> Result<String> {
29 let mut p = Password::with_theme(&self.theme);
30 p.with_prompt(parms.prompt);
31 if parms.confirm {
32 p.with_confirmation("confirm password", "passwords didnt match...");
33 }
34 let pass: String = p.interact()?;
35 Ok(pass)
36 }
37 fn confirm(&self, params: PromptConfirmParms) -> Result<bool> {
38 let confirm: bool = Confirm::with_theme(&self.theme)
39 .with_prompt(params.prompt)
40 .default(params.default)
41 .interact()?;
42 Ok(confirm)
43 }
44 fn choice(&self, parms: PromptChoiceParms) -> Result<usize> {
45 let mut choice = dialoguer::Select::with_theme(&self.theme);
46 choice
47 .with_prompt(parms.prompt)
48 .report(parms.report)
49 .items(&parms.choices);
50 if let Some(default) = parms.default {
51 if std::env::var("NGITTEST").is_err() {
52 choice.default(default);
53 }
54 }
55 choice.interact().context("failed to get choice")
56 }
57 fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result<Vec<usize>> {
58 // the colorful theme is not very clear so falling back to default
59 let mut choice = dialoguer::MultiSelect::default();
60 choice
61 .with_prompt(parms.prompt)
62 .report(parms.report)
63 .items(&parms.choices);
64 if let Some(defaults) = parms.defaults {
65 choice.defaults(&defaults);
66 }
67 choice.interact().context("failed to get choice")
68 }
69}
70
71#[derive(Default)]
72pub struct PromptInputParms {
73 pub prompt: String,
74 pub default: String,
75 pub optional: bool,
76}
77
78impl PromptInputParms {
79 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
80 self.prompt = prompt.into();
81 self
82 }
83 pub fn with_default<S: Into<String>>(mut self, default: S) -> Self {
84 self.default = default.into();
85 self
86 }
87 pub fn optional(mut self) -> Self {
88 self.optional = true;
89 self
90 }
91}
92
93#[derive(Default)]
94pub struct PromptPasswordParms {
95 pub prompt: String,
96 pub confirm: bool,
97}
98
99impl PromptPasswordParms {
100 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
101 self.prompt = prompt.into();
102 self
103 }
104 pub const fn with_confirm(mut self) -> Self {
105 self.confirm = true;
106 self
107 }
108}
109
110#[derive(Default)]
111pub struct PromptConfirmParms {
112 pub prompt: String,
113 pub default: bool,
114}
115
116impl PromptConfirmParms {
117 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
118 self.prompt = prompt.into();
119 self
120 }
121 pub fn with_default(mut self, default: bool) -> Self {
122 self.default = default;
123 self
124 }
125}
126
127#[derive(Default)]
128pub struct PromptChoiceParms {
129 pub prompt: String,
130 pub choices: Vec<String>,
131 pub default: Option<usize>,
132 pub report: bool,
133}
134
135impl PromptChoiceParms {
136 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
137 self.prompt = prompt.into();
138 self.report = true;
139 self
140 }
141
142 // pub fn dont_report(mut self) -> Self {
143 // self.report = false;
144 // self
145 // }
146 pub fn with_choices(mut self, choices: Vec<String>) -> Self {
147 self.choices = choices;
148 self
149 }
150
151 pub fn with_default(mut self, index: usize) -> Self {
152 self.default = Some(index);
153 self
154 }
155}
156
157#[derive(Default)]
158pub struct PromptMultiChoiceParms {
159 pub prompt: String,
160 pub choices: Vec<String>,
161 pub defaults: Option<Vec<bool>>,
162 pub report: bool,
163}
164
165impl PromptMultiChoiceParms {
166 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
167 self.prompt = prompt.into();
168 self.report = true;
169 self
170 }
171
172 pub fn dont_report(mut self) -> Self {
173 self.report = false;
174 self
175 }
176
177 pub fn with_choices(mut self, choices: Vec<String>) -> Self {
178 self.choices = choices;
179 self
180 }
181
182 pub fn with_defaults(mut self, defaults: Vec<bool>) -> Self {
183 self.defaults = Some(defaults);
184 self
185 }
186}
diff --git a/src/lib/client.rs b/src/lib/client.rs
new file mode 100644
index 0000000..abde217
--- /dev/null
+++ b/src/lib/client.rs
@@ -0,0 +1,1480 @@
1// have you considered
2
3// TO USE ASYNC
4
5// in traits (required for mocking unit tests)
6// https://rust-lang.github.io/async-book/07_workarounds/05_async_in_traits.html
7// https://github.com/dtolnay/async-trait
8// see https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-in-trait-nightly.html
9// I think we can use the async-trait crate and switch to the native feature
10// which is currently in nightly. alternatively we can use nightly as it looks
11// certain that the implementation is going to make it to stable but we don't
12// want to inadvertlty use other features of nightly that might be removed.
13use std::{
14 collections::{HashMap, HashSet},
15 fmt::{Display, Write},
16 fs::create_dir_all,
17 path::Path,
18 time::Duration,
19};
20
21use anyhow::{bail, Context, Result};
22use async_trait::async_trait;
23use console::Style;
24use futures::stream::{self, StreamExt};
25use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle};
26#[cfg(test)]
27use mockall::*;
28use nostr::{nips::nip01::Coordinate, Event};
29use nostr_database::{NostrDatabase, Order};
30use nostr_sdk::{
31 prelude::RelayLimits, EventBuilder, EventId, Kind, NostrSigner, Options, PublicKey,
32 SingleLetterTag, Timestamp, Url,
33};
34use nostr_sqlite::SQLiteDatabase;
35
36use crate::{
37 config::get_dirs,
38 login::{get_logged_in_user, get_user_ref_from_cache},
39 repo_ref::RepoRef,
40 repo_state::RepoState,
41 sub_commands::{
42 list::status_kinds,
43 send::{event_is_patch_set_root, event_is_revision_root},
44 },
45};
46
47#[allow(clippy::struct_field_names)]
48pub struct Client {
49 client: nostr_sdk::Client,
50 fallback_relays: Vec<String>,
51 more_fallback_relays: Vec<String>,
52 blaster_relays: Vec<String>,
53}
54
55#[cfg_attr(test, automock)]
56#[async_trait]
57pub trait Connect {
58 fn default() -> Self;
59 fn new(opts: Params) -> Self;
60 async fn set_signer(&mut self, signer: NostrSigner);
61 async fn connect(&self, relay_url: &Url) -> Result<()>;
62 async fn disconnect(&self) -> Result<()>;
63 fn get_fallback_relays(&self) -> &Vec<String>;
64 fn get_more_fallback_relays(&self) -> &Vec<String>;
65 fn get_blaster_relays(&self) -> &Vec<String>;
66 async fn send_event_to(
67 &self,
68 git_repo_path: &Path,
69 url: &str,
70 event: nostr::event::Event,
71 ) -> Result<nostr::EventId>;
72 async fn get_events(
73 &self,
74 relays: Vec<String>,
75 filters: Vec<nostr::Filter>,
76 ) -> Result<Vec<nostr::Event>>;
77 async fn get_events_per_relay(
78 &self,
79 relays: Vec<Url>,
80 filters: Vec<nostr::Filter>,
81 progress_reporter: MultiProgress,
82 ) -> Result<(Vec<Result<Vec<nostr::Event>>>, MultiProgress)>;
83 async fn fetch_all(
84 &self,
85 git_repo_path: &Path,
86 repo_coordinates: &HashSet<Coordinate>,
87 user_profiles: &HashSet<PublicKey>,
88 ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)>;
89 async fn fetch_all_from_relay(
90 &self,
91 git_repo_path: &Path,
92 request: FetchRequest,
93 pb: &Option<ProgressBar>,
94 ) -> Result<FetchReport>;
95}
96
97#[async_trait]
98impl Connect for Client {
99 fn default() -> Self {
100 let fallback_relays: Vec<String> = if std::env::var("NGITTEST").is_ok() {
101 vec![
102 "ws://localhost:8051".to_string(),
103 "ws://localhost:8052".to_string(),
104 ]
105 } else {
106 vec![
107 "wss://relay.damus.io".to_string(), /* free, good reliability, have been known
108 * to delete all messages */
109 "wss://nos.lol".to_string(),
110 "wss://relay.nostr.band".to_string(),
111 ]
112 };
113
114 let more_fallback_relays: Vec<String> = if std::env::var("NGITTEST").is_ok() {
115 vec![
116 "ws://localhost:8055".to_string(),
117 "ws://localhost:8056".to_string(),
118 ]
119 } else {
120 vec![
121 "wss://purplerelay.com".to_string(), // free but reliability not tested
122 "wss://purplepages.es".to_string(), // for profile events but unreliable
123 "wss://relayable.org".to_string(), // free but not always reliable
124 ]
125 };
126
127 let blaster_relays: Vec<String> = if std::env::var("NGITTEST").is_ok() {
128 vec!["ws://localhost:8057".to_string()]
129 } else {
130 vec!["wss://nostr.mutinywallet.com".to_string()]
131 };
132 Client {
133 client: nostr_sdk::ClientBuilder::new()
134 .opts(Options::new().relay_limits(RelayLimits::disable()))
135 .build(),
136 fallback_relays,
137 more_fallback_relays,
138 blaster_relays,
139 }
140 }
141 fn new(opts: Params) -> Self {
142 Client {
143 client: nostr_sdk::ClientBuilder::new()
144 .opts(Options::new().relay_limits(RelayLimits::disable()))
145 .signer(&opts.keys.unwrap_or(nostr::Keys::generate()))
146 // .database(
147 // SQLiteDatabase::open(get_dirs()?.cache_dir().join("nostr-cache.sqlite")).
148 // await?, )
149 .build(),
150 fallback_relays: opts.fallback_relays,
151 more_fallback_relays: opts.more_fallback_relays,
152 blaster_relays: opts.blaster_relays,
153 }
154 }
155
156 async fn set_signer(&mut self, signer: NostrSigner) {
157 self.client.set_signer(Some(signer)).await;
158 }
159
160 async fn connect(&self, relay_url: &Url) -> Result<()> {
161 self.client
162 .add_relay(relay_url)
163 .await
164 .context("cannot add relay")?;
165
166 let relay = self.client.relay(relay_url).await?;
167
168 if !relay.is_connected().await {
169 #[allow(clippy::large_futures)]
170 relay
171 .connect(Some(std::time::Duration::from_secs(CONNECTION_TIMEOUT)))
172 .await;
173 }
174
175 if !relay.is_connected().await {
176 bail!("connection timeout");
177 }
178 Ok(())
179 }
180
181 async fn disconnect(&self) -> Result<()> {
182 self.client.disconnect().await?;
183 Ok(())
184 }
185
186 fn get_fallback_relays(&self) -> &Vec<String> {
187 &self.fallback_relays
188 }
189
190 fn get_more_fallback_relays(&self) -> &Vec<String> {
191 &self.more_fallback_relays
192 }
193
194 fn get_blaster_relays(&self) -> &Vec<String> {
195 &self.blaster_relays
196 }
197
198 async fn send_event_to(
199 &self,
200 git_repo_path: &Path,
201 url: &str,
202 event: Event,
203 ) -> Result<nostr::EventId> {
204 self.client.add_relay(url).await?;
205 #[allow(clippy::large_futures)]
206 self.client.connect_relay(url).await?;
207 let res = self.client.send_event_to(vec![url], event.clone()).await?;
208 if let Some(err) = res.failed.get(&Url::parse(url)?) {
209 bail!(if let Some(err) = err {
210 err.to_string()
211 } else {
212 "error: unknown".to_string()
213 });
214 }
215 save_event_in_cache(git_repo_path, &event).await?;
216 if event.kind().eq(&Kind::GitRepoAnnouncement) {
217 save_event_in_global_cache(git_repo_path, &event).await?;
218 }
219 Ok(event.id())
220 }
221
222 async fn get_events(
223 &self,
224 relays: Vec<String>,
225 filters: Vec<nostr::Filter>,
226 ) -> Result<Vec<nostr::Event>> {
227 let (relay_results, _) = self
228 .get_events_per_relay(
229 relays.iter().map(|r| Url::parse(r).unwrap()).collect(),
230 filters,
231 MultiProgress::new(),
232 )
233 .await?;
234 Ok(get_dedup_events(relay_results))
235 }
236
237 async fn get_events_per_relay(
238 &self,
239 relays: Vec<Url>,
240 filters: Vec<nostr::Filter>,
241 progress_reporter: MultiProgress,
242 ) -> Result<(Vec<Result<Vec<nostr::Event>>>, MultiProgress)> {
243 // add relays
244 for relay in &relays {
245 self.client
246 .add_relay(relay.as_str())
247 .await
248 .context("cannot add relay")?;
249 }
250
251 let relays_map = self.client.relays().await;
252
253 let futures: Vec<_> = relays
254 .clone()
255 .iter()
256 // don't look for events on blaster
257 .filter(|r| !r.as_str().contains("nostr.mutinywallet.com"))
258 .map(|r| (relays_map.get(r).unwrap(), filters.clone()))
259 .map(|(relay, filters)| async {
260 let pb = if std::env::var("NGITTEST").is_err() {
261 let pb = progress_reporter.add(
262 ProgressBar::new(1)
263 .with_prefix(format!("{: <11}{}", "connecting", relay.url()))
264 .with_style(pb_style()?),
265 );
266 pb.enable_steady_tick(Duration::from_millis(300));
267 Some(pb)
268 } else {
269 None
270 };
271 #[allow(clippy::large_futures)]
272 match get_events_of(relay, filters, &pb).await {
273 Err(error) => {
274 if let Some(pb) = pb {
275 pb.set_style(pb_after_style(false));
276 pb.set_prefix(format!("{: <11}{}", "error", relay.url()));
277 pb.finish_with_message(
278 console::style(
279 error.to_string().replace("relay pool error:", "error:"),
280 )
281 .for_stderr()
282 .red()
283 .to_string(),
284 );
285 }
286 Err(error)
287 }
288 Ok(res) => {
289 if let Some(pb) = pb {
290 pb.set_style(pb_after_style(true));
291 pb.set_prefix(format!(
292 "{: <11}{}",
293 format!("{} events", res.len()),
294 relay.url()
295 ));
296 pb.finish_with_message("");
297 }
298 Ok(res)
299 }
300 }
301 })
302 .collect();
303
304 let relay_results: Vec<Result<Vec<nostr::Event>>> =
305 stream::iter(futures).buffer_unordered(15).collect().await;
306
307 Ok((relay_results, progress_reporter))
308 }
309
310 #[allow(clippy::too_many_lines)]
311 async fn fetch_all(
312 &self,
313 git_repo_path: &Path,
314 repo_coordinates: &HashSet<Coordinate>,
315 user_profiles: &HashSet<PublicKey>,
316 ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)> {
317 let fallback_relays = &self
318 .fallback_relays
319 .iter()
320 .filter_map(|r| Url::parse(r).ok())
321 .collect::<HashSet<Url>>();
322
323 let mut request = create_relays_request(
324 git_repo_path,
325 repo_coordinates,
326 user_profiles,
327 fallback_relays.clone(),
328 )
329 .await?;
330
331 let progress_reporter = MultiProgress::new();
332
333 let mut processed_relays = HashSet::new();
334
335 let mut relay_reports: Vec<Result<FetchReport>> = vec![];
336
337 loop {
338 let relays = request
339 .repo_relays
340 .union(&request.user_relays_for_profiles)
341 // don't look for events on blaster
342 .filter(|&r| !r.as_str().contains("nostr.mutinywallet.com"))
343 .cloned()
344 .collect::<HashSet<Url>>()
345 .difference(&processed_relays)
346 .cloned()
347 .collect::<HashSet<Url>>();
348 if relays.is_empty() {
349 break;
350 }
351 let profile_relays_only = request
352 .user_relays_for_profiles
353 .difference(&request.repo_relays)
354 .collect::<HashSet<&Url>>();
355 for relay in &request.repo_relays {
356 self.client
357 .add_relay(relay.as_str())
358 .await
359 .context("cannot add relay")?;
360 }
361
362 let dim = Style::new().color256(247);
363
364 let futures: Vec<_> = relays
365 .iter()
366 .map(|r| {
367 if profile_relays_only.contains(r) {
368 // if relay isn't a repo relay, just filter for user profile
369 FetchRequest {
370 selected_relay: Some(r.to_owned()),
371 repo_coordinates_without_relays: vec![],
372 proposals: HashSet::new(),
373 missing_contributor_profiles: request
374 .missing_contributor_profiles
375 .union(
376 &request
377 .profiles_to_fetch_from_user_relays
378 .clone()
379 .into_keys()
380 .collect(),
381 )
382 .copied()
383 .collect(),
384 ..request.clone()
385 }
386 } else {
387 FetchRequest {
388 selected_relay: Some(r.to_owned()),
389 ..request.clone()
390 }
391 }
392 })
393 .map(|request| async {
394 let relay_column_width = request.relay_column_width;
395
396 let relay_url = request
397 .selected_relay
398 .clone()
399 .context("fetch_all_from_relay called without a relay")?;
400
401 let pb = if std::env::var("NGITTEST").is_err() {
402 let pb = progress_reporter.add(
403 ProgressBar::new(1)
404 .with_prefix(
405 dim.apply_to(format!(
406 "{: <relay_column_width$} connecting",
407 &relay_url
408 ))
409 .to_string(),
410 )
411 .with_style(pb_style()?),
412 );
413 pb.enable_steady_tick(Duration::from_millis(300));
414 Some(pb)
415 } else {
416 None
417 };
418
419 #[allow(clippy::large_futures)]
420 match self.fetch_all_from_relay(git_repo_path, request, &pb).await {
421 Err(error) => {
422 if let Some(pb) = pb {
423 pb.set_style(pb_after_style(false));
424 pb.set_prefix(
425 dim.apply_to(format!("{: <relay_column_width$}", &relay_url))
426 .to_string(),
427 );
428 pb.finish_with_message(
429 console::style(
430 error.to_string().replace("relay pool error:", "error:"),
431 )
432 .for_stderr()
433 .red()
434 .to_string(),
435 );
436 }
437 Err(error)
438 }
439 Ok(res) => Ok(res),
440 }
441 })
442 .collect();
443
444 for report in stream::iter(futures)
445 .buffer_unordered(15)
446 .collect::<Vec<Result<FetchReport>>>()
447 .await
448 {
449 relay_reports.push(report);
450 }
451 processed_relays.extend(relays.clone());
452
453 if let Ok(repo_ref) = get_repo_ref_from_cache(git_repo_path, repo_coordinates).await {
454 request.repo_relays = repo_ref
455 .relays
456 .iter()
457 .filter_map(|r| Url::parse(r).ok())
458 .collect();
459 }
460
461 request.user_relays_for_profiles = {
462 let mut set = HashSet::new();
463 for user in &request
464 .profiles_to_fetch_from_user_relays
465 .clone()
466 .into_keys()
467 .collect::<Vec<PublicKey>>()
468 {
469 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, user).await {
470 for r in user_ref.relays.write() {
471 if let Ok(url) = Url::parse(&r) {
472 set.insert(url);
473 }
474 }
475 }
476 }
477 set
478 };
479 }
480 Ok((relay_reports, progress_reporter))
481 }
482
483 async fn fetch_all_from_relay(
484 &self,
485 git_repo_path: &Path,
486 request: FetchRequest,
487 pb: &Option<ProgressBar>,
488 ) -> Result<FetchReport> {
489 let mut fresh_coordinates: HashSet<Coordinate> = HashSet::new();
490 for (c, _) in request.repo_coordinates_without_relays.clone() {
491 fresh_coordinates.insert(c);
492 }
493 let mut fresh_proposal_roots = request.proposals.clone();
494 let mut fresh_profiles: HashSet<PublicKey> = request
495 .missing_contributor_profiles
496 .union(
497 &request
498 .profiles_to_fetch_from_user_relays
499 .clone()
500 .into_keys()
501 .collect(),
502 )
503 .copied()
504 .collect();
505
506 let mut report = FetchReport::default();
507
508 let relay_url = request
509 .selected_relay
510 .clone()
511 .context("fetch_all_from_relay called without a relay")?;
512
513 let relay_column_width = request.relay_column_width;
514
515 self.connect(&relay_url).await?;
516
517 let dim = Style::new().color256(247);
518
519 loop {
520 let filters =
521 get_fetch_filters(&fresh_coordinates, &fresh_proposal_roots, &fresh_profiles);
522
523 if let Some(pb) = &pb {
524 pb.set_prefix(
525 dim.apply_to(format!(
526 "{: <relay_column_width$} {}",
527 &relay_url,
528 if report.to_string().is_empty() {
529 "fetching".to_string()
530 } else {
531 format!("fetching... updates: {report}")
532 },
533 ))
534 .to_string(),
535 );
536 }
537
538 fresh_coordinates = HashSet::new();
539 fresh_proposal_roots = HashSet::new();
540 fresh_profiles = HashSet::new();
541
542 let relay = self.client.relay(&relay_url).await?;
543 let events: Vec<nostr::Event> = get_events_of(&relay, filters.clone(), &None)
544 .await?
545 .iter()
546 // don't process events that don't match filters
547 .filter(|e| filters.iter().any(|f| f.match_event(e)))
548 .cloned()
549 .collect();
550 // TODO: try reconcile
551
552 process_fetched_events(
553 events,
554 &request,
555 git_repo_path,
556 &mut fresh_coordinates,
557 &mut fresh_proposal_roots,
558 &mut fresh_profiles,
559 &mut report,
560 )
561 .await?;
562
563 if fresh_coordinates.is_empty()
564 && fresh_proposal_roots.is_empty()
565 && fresh_profiles.is_empty()
566 {
567 break;
568 }
569 }
570 if let Some(pb) = pb {
571 pb.set_style(pb_after_style(true));
572 pb.set_prefix(
573 dim.apply_to(format!(
574 "{: <relay_column_width$} {}",
575 relay_url,
576 if report.to_string().is_empty() {
577 "no new events".to_string()
578 } else {
579 format!("new events: {report}")
580 },
581 ))
582 .to_string(),
583 );
584 pb.finish_with_message("");
585 }
586 Ok(report)
587 }
588}
589
590static CONNECTION_TIMEOUT: u64 = 3;
591static GET_EVENTS_TIMEOUT: u64 = 7;
592
593async fn get_events_of(
594 relay: &nostr_sdk::Relay,
595 filters: Vec<nostr::Filter>,
596 pb: &Option<ProgressBar>,
597) -> Result<Vec<Event>> {
598 // relay.reconcile(filter, opts).await?;
599
600 if !relay.is_connected().await {
601 #[allow(clippy::large_futures)]
602 relay
603 .connect(Some(std::time::Duration::from_secs(CONNECTION_TIMEOUT)))
604 .await;
605 }
606
607 if !relay.is_connected().await {
608 bail!("connection timeout");
609 } else if let Some(pb) = pb {
610 pb.set_prefix(format!("connected {}", relay.url()));
611 }
612 let events = relay
613 .get_events_of(
614 filters,
615 // 20 is nostr_sdk default
616 std::time::Duration::from_secs(GET_EVENTS_TIMEOUT),
617 nostr_sdk::FilterOptions::ExitOnEOSE,
618 )
619 .await?;
620 Ok(events)
621}
622
623#[derive(Default)]
624pub struct Params {
625 pub keys: Option<nostr::Keys>,
626 pub fallback_relays: Vec<String>,
627 pub more_fallback_relays: Vec<String>,
628 pub blaster_relays: Vec<String>,
629}
630
631fn get_dedup_events(relay_results: Vec<Result<Vec<nostr::Event>>>) -> Vec<Event> {
632 let mut dedup_events: Vec<Event> = vec![];
633 for events in relay_results.into_iter().flatten() {
634 for event in events {
635 if !dedup_events.iter().any(|e| event.id.eq(&e.id)) {
636 dedup_events.push(event);
637 }
638 }
639 }
640 dedup_events
641}
642
643pub async fn sign_event(event_builder: EventBuilder, signer: &NostrSigner) -> Result<nostr::Event> {
644 if signer.r#type().eq(&nostr_signer::NostrSignerType::NIP46) {
645 let term = console::Term::stderr();
646 term.write_line("signing event with remote signer...")?;
647 let event = signer
648 .sign_event_builder(event_builder)
649 .await
650 .context("failed to sign event")?;
651 term.clear_last_lines(1)?;
652 Ok(event)
653 } else {
654 signer
655 .sign_event_builder(event_builder)
656 .await
657 .context("failed to sign event")
658 }
659}
660
661pub async fn fetch_public_key(signer: &NostrSigner) -> Result<nostr::PublicKey> {
662 let term = console::Term::stderr();
663 term.write_line("fetching npub from remote signer...")?;
664 let public_key = signer
665 .public_key()
666 .await
667 .context("failed to get npub from remote signer")?;
668 term.clear_last_lines(1)?;
669 Ok(public_key)
670}
671
672fn pb_style() -> Result<ProgressStyle> {
673 Ok(
674 ProgressStyle::with_template(" {spinner} {prefix} {msg} {timeout_in}")?.with_key(
675 "timeout_in",
676 |state: &ProgressState, w: &mut dyn Write| {
677 if state.elapsed().as_secs() > 3 && state.elapsed().as_secs() < GET_EVENTS_TIMEOUT {
678 let dim = Style::new().color256(247);
679 write!(
680 w,
681 "{}",
682 dim.apply_to(format!(
683 "timeout in {:.1}s",
684 GET_EVENTS_TIMEOUT - state.elapsed().as_secs()
685 ))
686 )
687 .unwrap();
688 }
689 },
690 ),
691 )
692}
693
694fn pb_after_style(succeed: bool) -> indicatif::ProgressStyle {
695 ProgressStyle::with_template(
696 format!(
697 " {} {}",
698 if succeed {
699 console::style("✔".to_string())
700 .for_stderr()
701 .green()
702 .to_string()
703 } else {
704 console::style("✘".to_string())
705 .for_stderr()
706 .red()
707 .to_string()
708 },
709 "{prefix} {msg}",
710 )
711 .as_str(),
712 )
713 .unwrap()
714}
715
716async fn get_local_cache_database(git_repo_path: &Path) -> Result<SQLiteDatabase> {
717 SQLiteDatabase::open(git_repo_path.join(".git/nostr-cache.sqlite"))
718 .await
719 .context("cannot open or create nostr cache database at .git/nostr-cache.sqlite")
720}
721
722async fn get_global_cache_database(git_repo_path: &Path) -> Result<SQLiteDatabase> {
723 SQLiteDatabase::open(if std::env::var("NGITTEST").is_err() {
724 create_dir_all(get_dirs()?.cache_dir()).context(format!(
725 "cannot create cache directory in: {:?}",
726 get_dirs()?.cache_dir()
727 ))?;
728 get_dirs()?.cache_dir().join("nostr-cache.sqlite")
729 } else {
730 git_repo_path.join(".git/test-global-cache.sqlite")
731 })
732 .await
733 .context("cannot open ngit global nostr cache database")
734}
735
736pub async fn get_events_from_cache(
737 git_repo_path: &Path,
738 filters: Vec<nostr::Filter>,
739) -> Result<Vec<nostr::Event>> {
740 get_local_cache_database(git_repo_path)
741 .await?
742 .query(filters.clone(), Order::Asc)
743 .await
744 .context(
745 "cannot execute query on opened git repo nostr cache database .git/nostr-cache.sqlite",
746 )
747}
748
749pub async fn get_event_from_global_cache(
750 git_repo_path: &Path,
751 filters: Vec<nostr::Filter>,
752) -> Result<Vec<nostr::Event>> {
753 get_global_cache_database(git_repo_path)
754 .await?
755 .query(filters.clone(), Order::Asc)
756 .await
757 .context("cannot execute query on opened ngit nostr cache database")
758}
759
760pub async fn save_event_in_cache(git_repo_path: &Path, event: &nostr::Event) -> Result<bool> {
761 get_local_cache_database(git_repo_path)
762 .await?
763 .save_event(event)
764 .await
765 .context("cannot save event in local cache")
766}
767
768pub async fn save_event_in_global_cache(
769 git_repo_path: &Path,
770 event: &nostr::Event,
771) -> Result<bool> {
772 get_global_cache_database(git_repo_path)
773 .await?
774 .save_event(event)
775 .await
776 .context("cannot save event in local cache")
777}
778
779pub async fn get_repo_ref_from_cache(
780 git_repo_path: &Path,
781 repo_coordinates: &HashSet<Coordinate>,
782) -> Result<RepoRef> {
783 let mut maintainers = HashSet::new();
784 let mut new_coordinate: bool;
785
786 for c in repo_coordinates {
787 maintainers.insert(c.public_key);
788 }
789 let mut repo_events = vec![];
790 loop {
791 new_coordinate = false;
792 let repo_events_filter = get_filter_repo_events(repo_coordinates);
793
794 let events = [
795 get_event_from_global_cache(git_repo_path, vec![repo_events_filter.clone()]).await?,
796 get_events_from_cache(git_repo_path, vec![repo_events_filter]).await?,
797 ]
798 .concat();
799 for e in events {
800 if let Ok(repo_ref) = RepoRef::try_from(e.clone()) {
801 for m in repo_ref.maintainers {
802 if maintainers.insert(m) {
803 new_coordinate = true;
804 }
805 }
806 repo_events.push(e);
807 }
808 }
809 if !new_coordinate {
810 break;
811 }
812 }
813 repo_events.sort_by_key(|e| e.created_at);
814 let repo_ref = RepoRef::try_from(
815 repo_events
816 .first()
817 .context("no repo events at specified coordinates")?
818 .clone(),
819 )?;
820
821 let mut events: HashMap<Coordinate, nostr::Event> = HashMap::new();
822 for m in &maintainers {
823 if let Some(e) = repo_events.iter().find(|e| e.author().eq(m)) {
824 events.insert(
825 Coordinate {
826 kind: e.kind,
827 identifier: e.identifier().unwrap().to_string(),
828 public_key: e.author(),
829 relays: vec![],
830 },
831 e.clone(),
832 );
833 }
834 }
835
836 Ok(RepoRef {
837 // use all maintainers from all events found, not just maintainers in the most
838 // recent event
839 maintainers: maintainers.iter().copied().collect::<Vec<PublicKey>>(),
840 events,
841 ..repo_ref
842 })
843}
844
845pub async fn get_state_from_cache(git_repo_path: &Path, repo_ref: &RepoRef) -> Result<RepoState> {
846 RepoState::try_from(
847 get_events_from_cache(
848 git_repo_path,
849 vec![get_filter_state_events(&repo_ref.coordinates())],
850 )
851 .await?,
852 )
853}
854
855#[allow(clippy::too_many_lines)]
856async fn create_relays_request(
857 git_repo_path: &Path,
858 repo_coordinates: &HashSet<Coordinate>,
859 user_profiles: &HashSet<PublicKey>,
860 fallback_relays: HashSet<Url>,
861) -> Result<FetchRequest> {
862 let repo_ref = get_repo_ref_from_cache(git_repo_path, repo_coordinates).await;
863
864 let repo_coordinates = {
865 // add coordinates of users listed in maintainers to explicitly specified
866 // coodinates
867 let mut repo_coordinates = repo_coordinates.clone();
868 if let Ok(repo_ref) = &repo_ref {
869 for c in repo_ref.coordinates() {
870 if !repo_coordinates
871 .iter()
872 .any(|e| e.identifier.eq(&c.identifier) && e.public_key.eq(&c.public_key))
873 {
874 repo_coordinates.insert(c);
875 }
876 }
877 }
878 repo_coordinates
879 };
880
881 let repo_coordinates_without_relays = {
882 let mut set = HashSet::new();
883 for c in &repo_coordinates {
884 set.insert(Coordinate {
885 kind: c.kind,
886 identifier: c.identifier.clone(),
887 public_key: c.public_key,
888 relays: vec![],
889 });
890 }
891 set
892 };
893
894 let mut proposals: HashSet<EventId> = HashSet::new();
895 let mut missing_contributor_profiles: HashSet<PublicKey> = HashSet::new();
896 let mut contributors: HashSet<PublicKey> = HashSet::new();
897
898 if !repo_coordinates_without_relays.is_empty() {
899 if let Ok(repo_ref) = &repo_ref {
900 for m in &repo_ref.maintainers {
901 contributors.insert(m.to_owned());
902 }
903 }
904
905 for event in &get_events_from_cache(
906 git_repo_path,
907 vec![
908 nostr::Filter::default()
909 .kinds(vec![Kind::GitPatch])
910 .custom_tag(
911 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
912 repo_coordinates_without_relays
913 .iter()
914 .map(std::string::ToString::to_string)
915 .collect::<Vec<String>>(),
916 ),
917 ],
918 )
919 .await?
920 {
921 if event_is_patch_set_root(event) || event_is_revision_root(event) {
922 proposals.insert(event.id());
923 contributors.insert(event.author());
924 }
925 }
926
927 let profile_events = get_event_from_global_cache(
928 git_repo_path,
929 vec![get_filter_contributor_profiles(contributors.clone())],
930 )
931 .await?;
932 for c in &contributors {
933 if let Some(event) = profile_events
934 .iter()
935 .find(|e| e.kind() == Kind::Metadata && e.author().eq(c))
936 {
937 save_event_in_cache(git_repo_path, event).await?;
938 } else {
939 missing_contributor_profiles.insert(c.to_owned());
940 }
941 }
942 }
943
944 let profiles_to_fetch_from_user_relays = {
945 let mut user_profiles = user_profiles.clone();
946 if let Ok(Some(current_user)) = get_logged_in_user(git_repo_path).await {
947 user_profiles.insert(current_user);
948 }
949 let mut map: HashMap<PublicKey, (Timestamp, Timestamp)> = HashMap::new();
950 for public_key in &user_profiles {
951 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
952 map.insert(
953 public_key.to_owned(),
954 (user_ref.metadata.created_at, user_ref.relays.created_at),
955 );
956 } else {
957 map.insert(
958 public_key.to_owned(),
959 (Timestamp::from(0), Timestamp::from(0)),
960 );
961 }
962 }
963 map
964 };
965
966 let user_relays_for_profiles = {
967 let mut set = HashSet::new();
968 for user in &profiles_to_fetch_from_user_relays
969 .clone()
970 .into_keys()
971 .collect::<Vec<PublicKey>>()
972 {
973 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, user).await {
974 for r in user_ref.relays.write() {
975 if let Ok(url) = Url::parse(&r) {
976 set.insert(url);
977 }
978 }
979 } else {
980 missing_contributor_profiles.insert(user.to_owned());
981 }
982 }
983 set
984 };
985
986 let existing_events: HashSet<EventId> = {
987 let mut existing_events: HashSet<EventId> = HashSet::new();
988 for filter in get_fetch_filters(
989 &repo_coordinates_without_relays,
990 &proposals,
991 &missing_contributor_profiles
992 .union(
993 &profiles_to_fetch_from_user_relays
994 .clone()
995 .into_keys()
996 .collect(),
997 )
998 .copied()
999 .collect(),
1000 ) {
1001 for (id, _) in get_local_cache_database(git_repo_path)
1002 .await?
1003 .negentropy_items(filter)
1004 .await?
1005 {
1006 existing_events.insert(id);
1007 }
1008 }
1009 existing_events
1010 };
1011
1012 let relays = {
1013 let mut relays = fallback_relays;
1014 if let Ok(repo_ref) = &repo_ref {
1015 for r in &repo_ref.relays {
1016 if let Ok(url) = Url::parse(r) {
1017 relays.insert(url);
1018 }
1019 }
1020 }
1021 for c in repo_coordinates {
1022 for r in &c.relays {
1023 if let Ok(url) = Url::parse(r) {
1024 relays.insert(url);
1025 }
1026 }
1027 }
1028 relays
1029 };
1030
1031 let relay_column_width = relays
1032 .union(&user_relays_for_profiles)
1033 .reduce(|a, r| {
1034 if r.to_string()
1035 .chars()
1036 .count()
1037 .gt(&a.to_string().chars().count())
1038 {
1039 r
1040 } else {
1041 a
1042 }
1043 })
1044 .unwrap()
1045 .to_string()
1046 .chars()
1047 .count()
1048 + 2;
1049
1050 Ok(FetchRequest {
1051 selected_relay: None,
1052 repo_relays: relays,
1053 relay_column_width,
1054 repo_coordinates_without_relays: if let Ok(repo_ref) = &repo_ref {
1055 repo_ref.coordinates_with_timestamps()
1056 } else {
1057 repo_coordinates_without_relays
1058 .iter()
1059 .map(|c| (c.clone(), None))
1060 .collect()
1061 },
1062 state: if let Ok(repo_ref) = &repo_ref {
1063 if let Ok(existing_state) = get_state_from_cache(git_repo_path, repo_ref).await {
1064 Some((existing_state.event.created_at, existing_state.event.id))
1065 } else {
1066 None
1067 }
1068 } else {
1069 None
1070 },
1071 proposals,
1072 contributors,
1073 missing_contributor_profiles,
1074 existing_events,
1075 profiles_to_fetch_from_user_relays,
1076 user_relays_for_profiles,
1077 })
1078}
1079
1080#[allow(clippy::too_many_lines)]
1081async fn process_fetched_events(
1082 events: Vec<nostr::Event>,
1083 request: &FetchRequest,
1084 git_repo_path: &Path,
1085 fresh_coordinates: &mut HashSet<Coordinate>,
1086 fresh_proposal_roots: &mut HashSet<EventId>,
1087 fresh_profiles: &mut HashSet<PublicKey>,
1088 report: &mut FetchReport,
1089) -> Result<()> {
1090 for event in &events {
1091 if !request.existing_events.contains(&event.id) {
1092 save_event_in_cache(git_repo_path, event).await?;
1093 if event.kind().eq(&Kind::GitRepoAnnouncement) {
1094 save_event_in_global_cache(git_repo_path, event).await?;
1095 let new_coordinate = !request
1096 .repo_coordinates_without_relays
1097 .iter()
1098 .map(|(c, _)| c.clone())
1099 .any(|c| {
1100 c.identifier.eq(event.identifier().unwrap())
1101 && c.public_key.eq(&event.pubkey)
1102 });
1103 let update_to_existing = !new_coordinate
1104 && request
1105 .repo_coordinates_without_relays
1106 .iter()
1107 .any(|(c, t)| {
1108 c.identifier.eq(event.identifier().unwrap())
1109 && c.public_key.eq(&event.pubkey)
1110 && if let Some(t) = t {
1111 event.created_at.gt(t)
1112 } else {
1113 true
1114 }
1115 });
1116 if update_to_existing {
1117 report.updated_repo_announcements.push((
1118 Coordinate {
1119 kind: event.kind(),
1120 public_key: event.author(),
1121 identifier: event.identifier().unwrap().to_owned(),
1122 relays: vec![],
1123 },
1124 event.created_at,
1125 ));
1126 }
1127 // if contains new maintainer
1128 if let Ok(repo_ref) = &RepoRef::try_from(event.clone()) {
1129 for m in &repo_ref.maintainers {
1130 if !request
1131 .repo_coordinates_without_relays // prexisting maintainers
1132 .iter()
1133 .map(|(c, _)| c.clone())
1134 .collect::<HashSet<Coordinate>>()
1135 .union(&report.repo_coordinates_without_relays) // already added maintainers
1136 .any(|c| c.identifier.eq(&repo_ref.identifier) && m.eq(&c.public_key))
1137 {
1138 let c = Coordinate {
1139 kind: event.kind(),
1140 public_key: *m,
1141 identifier: repo_ref.identifier.clone(),
1142 relays: vec![],
1143 };
1144 fresh_coordinates.insert(c.clone());
1145 report.repo_coordinates_without_relays.insert(c);
1146
1147 if !request.contributors.contains(m)
1148 && !request
1149 .profiles_to_fetch_from_user_relays
1150 .clone()
1151 .into_keys()
1152 .collect::<HashSet<PublicKey>>()
1153 .contains(m)
1154 && !fresh_profiles.contains(m)
1155 {
1156 fresh_profiles.insert(m.to_owned());
1157 }
1158 }
1159 }
1160 }
1161 } else if event.kind().eq(&STATE_KIND) {
1162 let existing_state = if report.updated_state.is_some() {
1163 report.updated_state
1164 } else {
1165 request.state
1166 };
1167 if let Some((timestamp, id)) = existing_state {
1168 if event.created_at.gt(&timestamp)
1169 || (event.created_at.eq(&timestamp) && event.id.gt(&id))
1170 {
1171 report.updated_state = Some((event.created_at, event.id));
1172 }
1173 }
1174 } else if event_is_patch_set_root(event) {
1175 fresh_proposal_roots.insert(event.id);
1176 report.proposals.insert(event.id);
1177 if !request.contributors.contains(&event.author())
1178 && !fresh_profiles.contains(&event.author())
1179 {
1180 fresh_profiles.insert(event.author());
1181 }
1182 } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind()) {
1183 if request
1184 .missing_contributor_profiles
1185 .contains(&event.author())
1186 {
1187 report.contributor_profiles.insert(event.author());
1188 } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request
1189 .profiles_to_fetch_from_user_relays
1190 .get_key_value(&event.author())
1191 {
1192 if (Kind::Metadata.eq(&event.kind())
1193 && event.created_at().gt(metadata_timestamp))
1194 || (Kind::RelayList.eq(&event.kind())
1195 && event.created_at().gt(relay_list_timestamp))
1196 {
1197 report.profile_updates.insert(event.author());
1198 }
1199 }
1200 save_event_in_global_cache(git_repo_path, event).await?;
1201 }
1202 }
1203 }
1204 for event in &events {
1205 if !request.existing_events.contains(&event.id)
1206 && !event.event_ids().any(|id| report.proposals.contains(id))
1207 {
1208 if event.kind().eq(&Kind::GitPatch) && !event_is_patch_set_root(event) {
1209 report.commits.insert(event.id);
1210 } else if status_kinds().contains(&event.kind()) {
1211 report.statuses.insert(event.id);
1212 }
1213 }
1214 }
1215 Ok(())
1216}
1217
1218pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchReport {
1219 let mut report = FetchReport::default();
1220 for relay_report in reports.into_iter().flatten() {
1221 for c in relay_report.repo_coordinates_without_relays {
1222 if !report
1223 .repo_coordinates_without_relays
1224 .iter()
1225 .any(|e| e.eq(&c))
1226 {
1227 report.repo_coordinates_without_relays.insert(c);
1228 }
1229 }
1230 for (r, t) in relay_report.updated_repo_announcements {
1231 if let Some(i) = report
1232 .updated_repo_announcements
1233 .iter()
1234 .position(|(e, _)| e.eq(&r))
1235 {
1236 let (_, existing_t) = &report.updated_repo_announcements[i];
1237 if t.gt(existing_t) {
1238 report.updated_repo_announcements[i] = (r, t);
1239 }
1240 } else {
1241 report.updated_repo_announcements.push((r, t));
1242 }
1243 }
1244 if let Some((timestamp, id)) = relay_report.updated_state {
1245 if let Some((existing_timestamp, existing_id)) = report.updated_state {
1246 if timestamp.gt(&existing_timestamp)
1247 || (timestamp.eq(&existing_timestamp) && id.gt(&existing_id))
1248 {
1249 report.updated_state = Some((timestamp, id));
1250 }
1251 } else {
1252 report.updated_state = Some((timestamp, id));
1253 }
1254 }
1255 for c in relay_report.proposals {
1256 report.proposals.insert(c);
1257 }
1258 for c in relay_report.commits {
1259 report.commits.insert(c);
1260 }
1261 for c in relay_report.statuses {
1262 report.statuses.insert(c);
1263 }
1264 for c in relay_report.contributor_profiles {
1265 report.contributor_profiles.insert(c);
1266 }
1267 for c in relay_report.profile_updates {
1268 report.profile_updates.insert(c);
1269 }
1270 }
1271 report
1272}
1273pub fn get_fetch_filters(
1274 repo_coordinates: &HashSet<Coordinate>,
1275 proposal_ids: &HashSet<EventId>,
1276 required_profiles: &HashSet<PublicKey>,
1277) -> Vec<nostr::Filter> {
1278 [
1279 if repo_coordinates.is_empty() {
1280 vec![]
1281 } else {
1282 vec![
1283 get_filter_state_events(repo_coordinates),
1284 get_filter_repo_events(repo_coordinates),
1285 nostr::Filter::default()
1286 .kinds(vec![Kind::GitPatch, Kind::EventDeletion])
1287 .custom_tag(
1288 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
1289 repo_coordinates
1290 .iter()
1291 .map(std::string::ToString::to_string)
1292 .collect::<Vec<String>>(),
1293 ),
1294 ]
1295 },
1296 if proposal_ids.is_empty() {
1297 vec![]
1298 } else {
1299 vec![
1300 nostr::Filter::default()
1301 .events(proposal_ids.clone())
1302 .kinds([vec![Kind::GitPatch, Kind::EventDeletion], status_kinds()].concat()),
1303 ]
1304 },
1305 if required_profiles.is_empty() {
1306 vec![]
1307 } else {
1308 vec![get_filter_contributor_profiles(required_profiles.clone())]
1309 },
1310 ]
1311 .concat()
1312}
1313
1314pub fn get_filter_repo_events(repo_coordinates: &HashSet<Coordinate>) -> nostr::Filter {
1315 nostr::Filter::default()
1316 .kind(Kind::GitRepoAnnouncement)
1317 .identifiers(
1318 repo_coordinates
1319 .iter()
1320 .map(|c| c.identifier.clone())
1321 .collect::<Vec<String>>(),
1322 )
1323 .authors(
1324 repo_coordinates
1325 .iter()
1326 .map(|c| c.public_key)
1327 .collect::<Vec<PublicKey>>(),
1328 )
1329}
1330
1331pub static STATE_KIND: nostr::Kind = Kind::Custom(30618);
1332pub fn get_filter_state_events(repo_coordinates: &HashSet<Coordinate>) -> nostr::Filter {
1333 nostr::Filter::default()
1334 .kind(STATE_KIND)
1335 .identifiers(
1336 repo_coordinates
1337 .iter()
1338 .map(|c| c.identifier.clone())
1339 .collect::<Vec<String>>(),
1340 )
1341 .authors(
1342 repo_coordinates
1343 .iter()
1344 .map(|c| c.public_key)
1345 .collect::<Vec<PublicKey>>(),
1346 )
1347}
1348
1349pub fn get_filter_contributor_profiles(contributors: HashSet<PublicKey>) -> nostr::Filter {
1350 nostr::Filter::default()
1351 .kinds(vec![Kind::Metadata, Kind::RelayList])
1352 .authors(contributors)
1353}
1354
1355#[derive(Default)]
1356pub struct FetchReport {
1357 repo_coordinates_without_relays: HashSet<Coordinate>,
1358 updated_repo_announcements: Vec<(Coordinate, Timestamp)>,
1359 updated_state: Option<(Timestamp, EventId)>,
1360 proposals: HashSet<EventId>,
1361 /// commits against existing propoals
1362 commits: HashSet<EventId>,
1363 statuses: HashSet<EventId>,
1364 contributor_profiles: HashSet<PublicKey>,
1365 profile_updates: HashSet<PublicKey>,
1366}
1367
1368impl Display for FetchReport {
1369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1370 // report: "1 new maintainer, 1 announcement, 1 proposal, 3 commits, 2 statuses"
1371 let mut display_items: Vec<String> = vec![];
1372 if !self.repo_coordinates_without_relays.is_empty() {
1373 display_items.push(format!(
1374 "{} new maintainer{}",
1375 self.repo_coordinates_without_relays.len(),
1376 if self.repo_coordinates_without_relays.len() > 1 {
1377 "s"
1378 } else {
1379 ""
1380 },
1381 ));
1382 }
1383 if !self.updated_repo_announcements.is_empty() {
1384 display_items.push(format!(
1385 "{} announcement update{}",
1386 self.updated_repo_announcements.len(),
1387 if self.updated_repo_announcements.len() > 1 {
1388 "s"
1389 } else {
1390 ""
1391 },
1392 ));
1393 }
1394 if self.updated_state.is_some() {
1395 display_items.push("new state".to_string());
1396 }
1397 if !self.proposals.is_empty() {
1398 display_items.push(format!(
1399 "{} proposal{}",
1400 self.proposals.len(),
1401 if self.proposals.len() > 1 { "s" } else { "" },
1402 ));
1403 }
1404 if !self.commits.is_empty() {
1405 display_items.push(format!(
1406 "{} commit{}",
1407 self.commits.len(),
1408 if self.commits.len() > 1 { "s" } else { "" },
1409 ));
1410 }
1411 if !self.statuses.is_empty() {
1412 display_items.push(format!(
1413 "{} status{}",
1414 self.statuses.len(),
1415 if self.statuses.len() > 1 { "es" } else { "" },
1416 ));
1417 }
1418 if !self.contributor_profiles.is_empty() {
1419 display_items.push(format!(
1420 "{} user profile{}",
1421 self.contributor_profiles.len(),
1422 if self.contributor_profiles.len() > 1 {
1423 "s"
1424 } else {
1425 ""
1426 },
1427 ));
1428 }
1429 if !self.profile_updates.is_empty() {
1430 display_items.push(format!(
1431 "{} profile update{}",
1432 self.profile_updates.len(),
1433 if self.profile_updates.len() > 1 {
1434 "s"
1435 } else {
1436 ""
1437 },
1438 ));
1439 }
1440 write!(f, "{}", display_items.join(", "))
1441 }
1442}
1443
1444#[derive(Default, Clone)]
1445pub struct FetchRequest {
1446 repo_relays: HashSet<Url>,
1447 selected_relay: Option<Url>,
1448 relay_column_width: usize,
1449 repo_coordinates_without_relays: Vec<(Coordinate, Option<Timestamp>)>,
1450 state: Option<(Timestamp, EventId)>,
1451 proposals: HashSet<EventId>,
1452 contributors: HashSet<PublicKey>,
1453 missing_contributor_profiles: HashSet<PublicKey>,
1454 existing_events: HashSet<EventId>,
1455 profiles_to_fetch_from_user_relays: HashMap<PublicKey, (Timestamp, Timestamp)>,
1456 user_relays_for_profiles: HashSet<Url>,
1457}
1458
1459pub async fn fetching_with_report(
1460 git_repo_path: &Path,
1461 #[cfg(test)] client: &crate::client::MockConnect,
1462 #[cfg(not(test))] client: &Client,
1463 repo_coordinates: &HashSet<Coordinate>,
1464) -> Result<FetchReport> {
1465 let term = console::Term::stderr();
1466 term.write_line("fetching updates...")?;
1467 let (relay_reports, progress_reporter) = client
1468 .fetch_all(git_repo_path, repo_coordinates, &HashSet::new())
1469 .await?;
1470 if !relay_reports.iter().any(std::result::Result::is_err) {
1471 let _ = progress_reporter.clear();
1472 }
1473 let report = consolidate_fetch_reports(relay_reports);
1474 if report.to_string().is_empty() {
1475 println!("no updates");
1476 } else {
1477 println!("updates: {report}");
1478 }
1479 Ok(report)
1480}
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs
new file mode 100644
index 0000000..5919667
--- /dev/null
+++ b/src/lib/git/mod.rs
@@ -0,0 +1,2566 @@
1use std::{
2 collections::HashSet,
3 env::current_dir,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{bail, Context, Result};
8use git2::{DiffOptions, Oid, Revwalk};
9use nostr::nips::nip01::Coordinate;
10use nostr_sdk::{
11 hashes::{sha1::Hash as Sha1Hash, Hash},
12 PublicKey, Url,
13};
14
15use crate::sub_commands::list::{get_commit_id_from_patch, tag_value};
16
17pub struct Repo {
18 pub git_repo: git2::Repository,
19}
20
21impl Repo {
22 pub fn discover() -> Result<Self> {
23 Ok(Self {
24 git_repo: git2::Repository::discover(current_dir()?)?,
25 })
26 }
27 pub fn from_path(path: &PathBuf) -> Result<Self> {
28 Ok(Self {
29 git_repo: git2::Repository::open(path)?,
30 })
31 }
32}
33
34// pub type CommitId = [u8; 7];
35// pub type Sha1 = [u8; 20];
36
37pub trait RepoActions {
38 fn get_path(&self) -> Result<&Path>;
39 fn get_origin_url(&self) -> Result<String>;
40 fn get_remote_branch_names(&self) -> Result<Vec<String>>;
41 fn get_local_branch_names(&self) -> Result<Vec<String>>;
42 fn get_origin_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>;
43 fn get_local_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>;
44 fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>;
45 fn get_checked_out_branch_name(&self) -> Result<String>;
46 fn get_tip_of_branch(&self, branch_name: &str) -> Result<Sha1Hash>;
47 fn get_commit_or_tip_of_reference(&self, reference: &str) -> Result<Sha1Hash>;
48 fn get_root_commit(&self) -> Result<Sha1Hash>;
49 fn does_commit_exist(&self, commit: &str) -> Result<bool>;
50 fn get_head_commit(&self) -> Result<Sha1Hash>;
51 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash>;
52 fn get_commit_message(&self, commit: &Sha1Hash) -> Result<String>;
53 fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result<String>;
54 #[allow(clippy::doc_link_with_quotes)]
55 /// returns vector ["name", "email", "unixtime", "offset"]
56 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"]
57 fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
58 #[allow(clippy::doc_link_with_quotes)]
59 /// returns vector ["name", "email", "unixtime", "offset"]
60 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"]
61 fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
62 fn get_commits_ahead_behind(
63 &self,
64 base_commit: &Sha1Hash,
65 latest_commit: &Sha1Hash,
66 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)>;
67 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
68 // including (un)staged changes and (un)tracked files
69 fn has_outstanding_changes(&self) -> Result<bool>;
70 fn make_patch_from_commit(
71 &self,
72 commit: &Sha1Hash,
73 series_count: &Option<(u64, u64)>,
74 ) -> Result<String>;
75 fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String>;
76 fn checkout(&self, ref_name: &str) -> Result<Sha1Hash>;
77 fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>;
78 fn apply_patch_chain(
79 &self,
80 branch_name: &str,
81 patch_and_ancestors: Vec<nostr::Event>,
82 ) -> Result<Vec<nostr::Event>>;
83 fn create_commit_from_patch(&self, patch: &nostr::Event) -> Result<Oid>;
84 fn parse_starting_commits(&self, starting_commits: &str) -> Result<Vec<Sha1Hash>>;
85 fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result<bool>;
86 fn get_git_config_item(&self, item: &str, global: Option<bool>) -> Result<Option<String>>;
87 fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>;
88 fn remove_git_config_item(&self, item: &str, global: bool) -> Result<bool>;
89}
90
91impl RepoActions for Repo {
92 fn get_path(&self) -> Result<&Path> {
93 self.git_repo
94 .path()
95 .parent()
96 .context("cannot find repositiory path as .git has no parent")
97 }
98
99 fn get_origin_url(&self) -> Result<String> {
100 Ok(self
101 .git_repo
102 .find_remote("origin")
103 .context("cannot find origin")?
104 .url()
105 .context("cannot find origin url")?
106 .to_string())
107 }
108
109 fn get_origin_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> {
110 let main_branch_name = {
111 let remote_branches = self
112 .get_remote_branch_names()
113 .context("cannot find any local branches")?;
114 if remote_branches.contains(&"origin/main".to_string()) {
115 "origin/main"
116 } else if remote_branches.contains(&"origin/master".to_string()) {
117 "origin/master"
118 } else {
119 bail!("no main or master branch locally in this git repository to initiate from",)
120 }
121 };
122
123 let tip = self
124 .get_tip_of_branch(main_branch_name)
125 .context(format!(
126 "branch {main_branch_name} was listed as a remote branch but cannot get its tip commit id",
127 ))?;
128
129 Ok((main_branch_name, tip))
130 }
131
132 fn get_local_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> {
133 let main_branch_name = {
134 let local_branches = self
135 .get_local_branch_names()
136 .context("cannot find any local branches")?;
137 if local_branches.contains(&"main".to_string()) {
138 "main"
139 } else if local_branches.contains(&"master".to_string()) {
140 "master"
141 } else {
142 bail!("no main or master branch locally in this git repository to initiate from",)
143 }
144 };
145
146 let tip = self
147 .get_tip_of_branch(main_branch_name)
148 .context(format!(
149 "branch {main_branch_name} was listed as a local branch but cannot get its tip commit id",
150 ))?;
151
152 Ok((main_branch_name, tip))
153 }
154
155 fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> {
156 if let Ok(main_tuple) = self
157 .get_origin_main_or_master_branch()
158 .context("the default branches (main or master) do not exist")
159 {
160 Ok(main_tuple)
161 } else {
162 self.get_local_main_or_master_branch()
163 .context("the default branches (main or master) do not exist")
164 }
165 }
166
167 fn get_local_branch_names(&self) -> Result<Vec<String>> {
168 let local_branches = self
169 .git_repo
170 .branches(Some(git2::BranchType::Local))
171 .context("getting GitRepo branches should not error even for a blank repository")?;
172
173 let mut branch_names = vec![];
174
175 for iter in local_branches {
176 let branch = iter?.0;
177 if let Some(name) = branch.name()? {
178 branch_names.push(name.to_string());
179 }
180 }
181 Ok(branch_names)
182 }
183
184 fn get_remote_branch_names(&self) -> Result<Vec<String>> {
185 let remote_branches = self
186 .git_repo
187 .branches(Some(git2::BranchType::Remote))
188 .context("getting GitRepo branches should not error even for a blank repository")?;
189
190 let mut branch_names = vec![];
191
192 for iter in remote_branches {
193 let branch = iter?.0;
194 if let Some(name) = branch.name()? {
195 branch_names.push(name.to_string());
196 }
197 }
198 Ok(branch_names)
199 }
200
201 fn get_checked_out_branch_name(&self) -> Result<String> {
202 Ok(self
203 .git_repo
204 .head()?
205 .shorthand()
206 .context("an object without a shorthand is checked out")?
207 .to_string())
208 }
209
210 fn get_tip_of_branch(&self, branch_name: &str) -> Result<Sha1Hash> {
211 let branch = if let Ok(branch) = self
212 .git_repo
213 .find_branch(branch_name, git2::BranchType::Local)
214 .context(format!("cannot find local branch {branch_name}"))
215 {
216 branch
217 } else {
218 self.git_repo
219 .find_branch(branch_name, git2::BranchType::Remote)
220 .context(format!("cannot find local or remote branch {branch_name}"))?
221 };
222 Ok(oid_to_sha1(&branch.into_reference().peel_to_commit()?.id()))
223 }
224
225 fn get_commit_or_tip_of_reference(&self, sha1_or_reference: &str) -> Result<Sha1Hash> {
226 let oid = {
227 if let Ok(oid) = Oid::from_str(sha1_or_reference) {
228 self.git_repo.find_commit(oid)?;
229 oid
230 } else {
231 self.git_repo
232 .find_reference(sha1_or_reference)?
233 .peel_to_commit()?
234 .id()
235 }
236 };
237 Ok(oid_to_sha1(&oid))
238 }
239
240 fn get_root_commit(&self) -> Result<Sha1Hash> {
241 let mut revwalk = self
242 .git_repo
243 .revwalk()
244 .context("revwalk should be created from git repo")?;
245 revwalk
246 .push(sha1_to_oid(&self.get_head_commit()?)?)
247 .context("revwalk should accept tip oid")?;
248 Ok(oid_to_sha1(
249 &revwalk
250 .last()
251 .context("revwalk from tip should be at least contain the tip oid")?
252 .context("revwalk iter from branch tip should not result in an error")?,
253 ))
254 }
255
256 fn does_commit_exist(&self, commit: &str) -> Result<bool> {
257 if self.git_repo.find_commit(Oid::from_str(commit)?).is_ok() {
258 Ok(true)
259 } else {
260 Ok(false)
261 }
262 }
263
264 fn get_head_commit(&self) -> Result<Sha1Hash> {
265 let head = self
266 .git_repo
267 .head()
268 .context("failed to get git repo head")?;
269 let oid = head.peel_to_commit()?.id();
270 Ok(oid_to_sha1(&oid))
271 }
272
273 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash> {
274 let parent_oid = self
275 .git_repo
276 .find_commit(sha1_to_oid(commit)?)
277 .context(format!("could not find commit {commit}"))?
278 .parent_id(0)
279 .context(format!("could not find parent of commit {commit}"))?;
280 Ok(oid_to_sha1(&parent_oid))
281 }
282
283 fn get_commit_message(&self, commit: &Sha1Hash) -> Result<String> {
284 Ok(self
285 .git_repo
286 .find_commit(sha1_to_oid(commit)?)
287 .context(format!("could not find commit {commit}"))?
288 .message_raw()
289 .context("commit message has unusual characters in (not valid utf-8)")?
290 .to_string())
291 }
292
293 fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result<String> {
294 Ok(self
295 .git_repo
296 .find_commit(sha1_to_oid(commit)?)
297 .context(format!("could not find commit {commit}"))?
298 .message_raw()
299 .context("commit message has unusual characters in (not valid utf-8)")?
300 .split('\r')
301 .collect::<Vec<&str>>()[0]
302 .split('\n')
303 .collect::<Vec<&str>>()[0]
304 .to_string()
305 .trim()
306 .to_string())
307 }
308
309 fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
310 let commit = self
311 .git_repo
312 .find_commit(sha1_to_oid(commit)?)
313 .context(format!("could not find commit {commit}"))?;
314 let sig = commit.author();
315 Ok(git_sig_to_tag_vec(&sig))
316 }
317
318 fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
319 let commit = self
320 .git_repo
321 .find_commit(sha1_to_oid(commit)?)
322 .context(format!("could not find commit {commit}"))?;
323 let sig = commit.committer();
324 Ok(git_sig_to_tag_vec(&sig))
325 }
326
327 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
328 Ok(self
329 .git_repo
330 .references()?
331 .filter(|r| {
332 if let Ok(r) = r {
333 if let Ok(ref_tip) = r.peel_to_commit() {
334 ref_tip.id().to_string().eq(&commit.to_string())
335 } else {
336 false
337 }
338 } else {
339 false
340 }
341 })
342 .map(|r| r.unwrap().shorthand().unwrap().to_string())
343 .collect::<Vec<String>>())
344 }
345
346 fn make_patch_from_commit(
347 &self,
348 commit: &Sha1Hash,
349 series_count: &Option<(u64, u64)>,
350 ) -> Result<String> {
351 let c = self
352 .git_repo
353 .find_commit(Oid::from_bytes(commit.as_byte_array()).context(format!(
354 "failed to convert commit_id format for {}",
355 &commit
356 ))?)
357 .context(format!("failed to find commit {}", &commit))?;
358 let mut options = git2::EmailCreateOptions::default();
359 if let Some((n, total)) = series_count {
360 options.subject_prefix(format!("PATCH {n}/{total}"));
361 }
362 let patch = git2::Email::from_commit(&c, &mut options)
363 .context(format!("failed to create patch from commit {}", &commit))?;
364
365 Ok(std::str::from_utf8(patch.as_slice())
366 .context("patch content could not be converted to a utf8 string")?
367 .to_owned())
368 }
369
370 fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String> {
371 let oid = Oid::from_bytes(commit.as_byte_array()).context(format!(
372 "failed to convert commit_id format for {}",
373 &commit
374 ))?;
375
376 let (sign, _data) = self
377 .git_repo
378 .extract_signature(&oid, None)
379 .context("failed to extract signature - perhaps there is no signature?")?;
380
381 Ok(std::str::from_utf8(&sign)
382 .context("commit signature cannot be converted to a utf8 string")?
383 .to_owned())
384 }
385
386 // including (un)staged changes and (un)tracked files
387 fn has_outstanding_changes(&self) -> Result<bool> {
388 let diff = self.git_repo.diff_tree_to_workdir_with_index(
389 Some(&self.git_repo.head()?.peel_to_tree()?),
390 Some(DiffOptions::new().include_untracked(true)),
391 )?;
392
393 Ok(diff.deltas().len().gt(&0))
394 }
395
396 fn get_commits_ahead_behind(
397 &self,
398 base_commit: &Sha1Hash,
399 latest_commit: &Sha1Hash,
400 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> {
401 let mut ahead: Vec<Sha1Hash> = vec![];
402 let mut behind: Vec<Sha1Hash> = vec![];
403
404 let get_revwalk = |commit: &Sha1Hash| -> Result<Revwalk> {
405 let mut revwalk = self
406 .git_repo
407 .revwalk()
408 .context("revwalk should be created from git repo")?;
409 revwalk
410 .push(sha1_to_oid(commit)?)
411 .context("revwalk should accept commit oid")?;
412 Ok(revwalk)
413 };
414
415 // scan through the base commit ancestory until a common ancestor is found
416 let most_recent_shared_commit = match get_revwalk(base_commit)
417 .context("failed to get revwalk for base_commit")?
418 .find(|base_res| {
419 let base_oid = base_res.as_ref().unwrap();
420
421 if get_revwalk(latest_commit)
422 .unwrap()
423 .any(|latest_res| base_oid.eq(latest_res.as_ref().unwrap()))
424 {
425 true
426 } else {
427 // add commits not found in latest ancestory to 'behind' vector
428 behind.push(oid_to_sha1(base_oid));
429 false
430 }
431 }) {
432 None => {
433 bail!(format!(
434 "{} is not an ancestor of {}",
435 latest_commit, base_commit
436 ));
437 }
438 Some(res) => res.context("revwalk failed to reveal commit")?,
439 };
440
441 // scan through the latest commits until shared commit is reached
442 get_revwalk(latest_commit)
443 .context("failed to get revwalk for latest_commit")?
444 .any(|latest_res| {
445 let latest_oid = latest_res.as_ref().unwrap();
446 if latest_oid.eq(&most_recent_shared_commit) {
447 true
448 } else {
449 // add commits not found in base to 'ahead' vector
450 ahead.push(oid_to_sha1(latest_oid));
451 false
452 }
453 });
454 Ok((ahead, behind))
455 }
456
457 fn checkout(&self, ref_name: &str) -> Result<Sha1Hash> {
458 let (object, reference) = self.git_repo.revparse_ext(ref_name)?;
459
460 self.git_repo.checkout_tree(&object, None)?;
461
462 match reference {
463 // gref is an actual reference like branches or tags
464 Some(gref) => self.git_repo.set_head(gref.name().unwrap()),
465 // this is a commit, not a reference
466 None => self.git_repo.set_head_detached(object.id()),
467 }?;
468 let oid = self.git_repo.head()?.peel_to_commit()?.id();
469
470 Ok(oid_to_sha1(&oid))
471 }
472
473 fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()> {
474 let branch_checkedout = self.get_checked_out_branch_name()?.eq(branch_name);
475 if branch_checkedout {
476 let (name, _) = self.get_main_or_master_branch()?;
477 self.checkout(name)?;
478 }
479
480 self.git_repo
481 .branch(
482 branch_name,
483 &self.git_repo.find_commit(Oid::from_str(commit)?)?,
484 true,
485 )
486 .context("branch could not be created")?;
487
488 if branch_checkedout {
489 self.checkout(branch_name)?;
490 }
491 Ok(())
492 }
493 /* returns patches applied */
494 fn apply_patch_chain(
495 &self,
496 branch_name: &str,
497 patch_and_ancestors: Vec<nostr::Event>,
498 ) -> Result<Vec<nostr::Event>> {
499 let branch_tip_result = self.get_tip_of_branch(branch_name);
500
501 // filter out existing ancestors in branch
502 let mut patches_to_apply: Vec<nostr::Event> = patch_and_ancestors
503 .into_iter()
504 .filter(|e| {
505 let commit_id = get_commit_id_from_patch(e).unwrap();
506 if let Ok(branch_tip) = branch_tip_result {
507 !branch_tip.to_string().eq(&commit_id)
508 && !self
509 .ancestor_of(&branch_tip, &str_to_sha1(&commit_id).unwrap())
510 .unwrap()
511 } else {
512 true
513 }
514 })
515 .collect();
516
517 let parent_commit_id = tag_value(
518 if let Ok(last_patch) = patches_to_apply.last().context("no patches") {
519 last_patch
520 } else {
521 self.checkout(branch_name)
522 .context("no patches and so cannot create a proposal branch")?;
523 return Ok(vec![]);
524 },
525 "parent-commit",
526 )?;
527
528 // check patches can be applied
529 if !self.does_commit_exist(&parent_commit_id)? {
530 bail!("cannot find parent commit ({parent_commit_id}). run git pull and try again.")
531 }
532
533 // checkout branch
534 self.create_branch_at_commit(branch_name, &parent_commit_id)?;
535 self.checkout(branch_name)?;
536
537 // apply commits
538 patches_to_apply.reverse();
539
540 for patch in &patches_to_apply {
541 let commit_id = get_commit_id_from_patch(patch)?;
542 // only create new commits - otherwise make them the tip
543 if !self.does_commit_exist(&commit_id)? {
544 self.create_commit_from_patch(patch)?;
545 }
546 self.create_branch_at_commit(branch_name, &commit_id)?;
547 self.checkout(branch_name)?;
548 }
549 Ok(patches_to_apply)
550 }
551 fn create_commit_from_patch(&self, patch: &nostr::Event) -> Result<Oid> {
552 let commit_id = get_commit_id_from_patch(patch)?;
553 if self.does_commit_exist(&commit_id)? {
554 return Ok(Oid::from_str(&commit_id)?);
555 }
556 let parent_commit_id = tag_value(patch, "parent-commit")?;
557
558 let parent_commit = self
559 .git_repo
560 .find_commit(Oid::from_str(&parent_commit_id)?)
561 .context("parrent commit doesnt exist")?;
562 let parent_tree = parent_commit.tree()?;
563
564 // let mut apply_opts = git2::ApplyOptions::new();
565 // apply_opts.check(false);
566 let mut existing_index = self.git_repo.index()?;
567 let mut index = self.git_repo.apply_to_tree(
568 &parent_tree,
569 &git2::Diff::from_buffer(patch.content.as_bytes())?,
570 // Some(&mut apply_opts),
571 None,
572 )?;
573 let tree = self
574 .git_repo
575 .find_tree(index.write_tree_to(&self.git_repo)?)?;
576
577 let pgp_sig = if let Ok(pgp_sig) = tag_value(patch, "commit-pgp-sig") {
578 if pgp_sig.is_empty() {
579 None
580 } else {
581 Some(pgp_sig)
582 }
583 } else {
584 None
585 };
586
587 let commit_buff = self.git_repo.commit_create_buffer(
588 &extract_sig_from_patch_tags(&patch.tags, "author")?,
589 &extract_sig_from_patch_tags(&patch.tags, "committer")?,
590 tag_value(patch, "description")?.as_str(),
591 &tree,
592 &[&parent_commit],
593 )?;
594
595 let mut applied_oid = self
596 .git_repo
597 .commit_signed(
598 commit_buff.as_str().unwrap(),
599 pgp_sig.unwrap_or(String::new()).as_str(),
600 None,
601 )
602 .context("failed to create signed commit")?;
603
604 // I beleive this was added to address a bug where commit author / committer
605 // were identical when in a scenario when they should be different but I dont
606 // think we have a test case for it. surely we should be using the
607 // extract_sig_from_patch_tags outputs to address this?
608 if !applied_oid.to_string().eq(&commit_id) {
609 let commit = self.git_repo.find_commit(applied_oid)?;
610 applied_oid = commit
611 .amend(
612 None,
613 Some(&commit.author()),
614 Some(&commit.committer()),
615 None,
616 None,
617 None,
618 )
619 .context("cannot amend commit to produce new oid")?;
620 }
621 if !applied_oid.to_string().eq(&commit_id) {
622 bail!(
623 "when applied the patch commit id ({}) doesn't match the one specified in the event tag ({})",
624 applied_oid.to_string(),
625 get_commit_id_from_patch(patch)?,
626 );
627 }
628 self.git_repo.set_index(&mut existing_index)?;
629 Ok(applied_oid)
630 }
631 fn parse_starting_commits(&self, starting_commits: &str) -> Result<Vec<Sha1Hash>> {
632 let revspec = self
633 .git_repo
634 .revparse(starting_commits)
635 .context("specified value not in a valid format")?;
636 if revspec.mode().is_no_single() {
637 let (ahead, _) = self
638 .get_commits_ahead_behind(
639 &oid_to_sha1(
640 &revspec
641 .from()
642 .context("cannot get starting commit from specified value")?
643 .id(),
644 ),
645 &self
646 .get_head_commit()
647 .context("cannot get head commit with gitlib2")?,
648 )
649 .context("specified commit is not an ancestor of current head")?;
650 Ok(ahead)
651 } else if revspec.mode().is_range() {
652 let (ahead, _) = self
653 .get_commits_ahead_behind(
654 &oid_to_sha1(
655 &revspec
656 .from()
657 .context("cannot get starting commit of range from specified value")?
658 .id(),
659 ),
660 &oid_to_sha1(
661 &revspec
662 .to()
663 .context("cannot get end of range commit from specified value")?
664 .id(),
665 ),
666 )
667 .context("specified commit is not an ancestor of current head")?;
668 Ok(ahead)
669 } else {
670 bail!("specified value not in a supported format")
671 }
672 }
673
674 fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result<bool> {
675 if let Ok(res) = self
676 .git_repo
677 .graph_descendant_of(sha1_to_oid(decendant)?, sha1_to_oid(ancestor)?)
678 .context("could not run graph_descendant_of in gitlib2")
679 {
680 Ok(res)
681 } else {
682 Ok(false)
683 }
684 }
685
686 /// setting global to None will suppliment local config with global items
687 /// not in local
688 fn get_git_config_item(&self, item: &str, global: Option<bool>) -> Result<Option<String>> {
689 let just_global = if let Some(just_global) = global {
690 just_global
691 } else {
692 false
693 };
694 match if just_global {
695 self.git_repo
696 .config()
697 .context("cannot open git config")?
698 .open_global()
699 .context("cannot open global git config")?
700 } else {
701 self.git_repo.config().context("cannot open git config")?
702 }
703 .get_entry(item)
704 {
705 Ok(item) => {
706 if let Some(global) = global {
707 if item.level().eq(&git2::ConfigLevel::Local) {
708 if global {
709 bail!("only local repository login available")
710 }
711 } else if !global {
712 bail!("only global repository login available")
713 }
714 }
715 Ok(Some(
716 item.value()
717 .context("cannot find git config item")?
718 .to_string(),
719 ))
720 }
721 Err(_) => Ok(None),
722 }
723 }
724
725 fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()> {
726 if global {
727 self.git_repo
728 .config()
729 .context("cannot open git config")?
730 .open_global()
731 .context("cannot open global git config")?
732 } else {
733 self.git_repo.config().context("cannot open git config")?
734 }
735 .set_str(item, value)
736 .context(format!(
737 "cannot set {} git config item {}",
738 if global { "global" } else { "local" },
739 item
740 ))?;
741 Ok(())
742 }
743
744 /// returns false if item doesn't exist
745 fn remove_git_config_item(&self, item: &str, global: bool) -> Result<bool> {
746 if self.get_git_config_item(item, Some(global))?.is_none() {
747 Ok(false)
748 } else {
749 if global {
750 self.git_repo
751 .config()
752 .context("cannot open git config")?
753 .open_global()
754 .context("cannot open global git config")?
755 } else {
756 self.git_repo.config().context("cannot open git config")?
757 }
758 .remove(item)
759 .context("cannot remove existing git config item")?;
760 Ok(true)
761 }
762 }
763}
764
765fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] {
766 let b = oid.as_bytes();
767 [
768 b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13],
769 b[14], b[15], b[16], b[17], b[18], b[19],
770 ]
771}
772
773// fn oid_to_shorthand_string(oid: Oid) -> Result<String> {
774// let binding = oid.to_string();
775// let b = binding.as_bytes();
776// String::from_utf8(vec![b[0], b[1], b[2], b[3], b[4], b[5], b[6]])
777// .context("oid should always start with 7 u8 btyes of utf8")
778// }
779
780// fn oid_to_sha1_string(oid: Oid) -> Result<String> {
781// let b = oid.as_bytes();
782// String::from_utf8(vec![
783// b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10],
784// b[11], b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19],
785// ])
786// .context("oid should contain 20 u8 btyes of utf8")
787// }
788
789// git2 Oid object to Sha1Hash
790pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash {
791 Sha1Hash::from_byte_array(oid_to_u8_20_bytes(oid))
792}
793
794/// `Sha1Hash` to git2 `Oid` object
795pub fn sha1_to_oid(hash: &Sha1Hash) -> Result<Oid> {
796 Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid")
797}
798
799pub fn str_to_sha1(s: &str) -> Result<Sha1Hash> {
800 Ok(oid_to_sha1(
801 &Oid::from_str(s).context("string is not a sha1 hash")?,
802 ))
803}
804
805fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec<String> {
806 vec![
807 sig.name().unwrap_or("").to_string(),
808 sig.email().unwrap_or("").to_string(),
809 format!("{}", sig.when().seconds()),
810 format!("{}", sig.when().offset_minutes()),
811 ]
812}
813
814fn extract_sig_from_patch_tags<'a>(
815 tags: &'a [nostr::Tag],
816 tag_name: &str,
817) -> Result<git2::Signature<'a>> {
818 let v = tags
819 .iter()
820 .find(|t| t.as_vec()[0].eq(tag_name))
821 .context(format!("tag '{tag_name}' not present in patch"))?
822 .as_vec();
823 if v.len() != 5 {
824 bail!("tag '{tag_name}' is incorrectly formatted")
825 }
826 git2::Signature::new(
827 v[1].as_str(),
828 v[2].as_str(),
829 &git2::Time::new(
830 v[3].parse().context("tag time is incorrectly formatted")?,
831 v[4].parse()
832 .context("tag time offset is incorrectly formatted")?,
833 ),
834 )
835 .context("failed to create git signature")
836}
837
838#[derive(Debug, PartialEq)]
839pub enum ServerProtocol {
840 Ssh,
841 Https,
842 Http,
843 Git,
844}
845
846#[derive(Debug, PartialEq)]
847pub struct NostrUrlDecoded {
848 pub coordinates: HashSet<Coordinate>,
849 pub protocol: Option<ServerProtocol>,
850 pub user: Option<String>,
851}
852
853static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo";
854
855impl NostrUrlDecoded {
856 pub fn from_str(url: &str) -> Result<Self> {
857 let mut coordinates = HashSet::new();
858 let mut protocol = None;
859 let mut user = None;
860 let mut relays = vec![];
861
862 if !url.starts_with("nostr://") {
863 bail!("nostr git url must start with nostr://");
864 }
865 // process get url parameters if present
866 for (name, value) in Url::parse(url)?.query_pairs() {
867 if name.contains("relay") {
868 let mut decoded = urlencoding::decode(&value)
869 .context("could not parse relays in nostr git url")?
870 .to_string();
871 if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") {
872 decoded = format!("wss://{decoded}");
873 }
874 let url =
875 Url::parse(&decoded).context("could not parse relays in nostr git url")?;
876 relays.push(url.to_string());
877 } else if name == "protocol" {
878 protocol = match value.as_ref() {
879 "ssh" => Some(ServerProtocol::Ssh),
880 "https" => Some(ServerProtocol::Https),
881 "http" => Some(ServerProtocol::Http),
882 "git" => Some(ServerProtocol::Git),
883 _ => None,
884 };
885 } else if name == "user" {
886 user = Some(value.to_string());
887 }
888 }
889
890 let mut parts: Vec<&str> = url[8..]
891 .split('?')
892 .next()
893 .unwrap_or("")
894 .split('/')
895 .collect();
896
897 // extract optional protocol
898 if protocol.is_none() {
899 let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?;
900 let protocol_str = if let Some(at_index) = part.find('@') {
901 user = Some(part[..at_index].to_string());
902 &part[at_index + 1..]
903 } else {
904 part
905 };
906 protocol = match protocol_str {
907 "ssh" => Some(ServerProtocol::Ssh),
908 "https" => Some(ServerProtocol::Https),
909 "http" => Some(ServerProtocol::Http),
910 "git" => Some(ServerProtocol::Git),
911 _ => protocol,
912 };
913 if protocol.is_some() {
914 parts.remove(0);
915 }
916 }
917 // extract naddr npub/<optional-relays>/identifer
918 let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?;
919 // naddr used
920 if let Ok(coordinate) = Coordinate::parse(part) {
921 if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) {
922 coordinates.insert(coordinate);
923 } else {
924 bail!("naddr doesnt point to a git repository announcement");
925 }
926 // npub/<optional-relays>/identifer used
927 } else if let Ok(public_key) = PublicKey::parse(part) {
928 parts.remove(0);
929 let identifier = parts
930 .pop()
931 .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")?
932 .to_string();
933 for relay in parts {
934 let mut decoded = urlencoding::decode(relay)
935 .context("could not parse relays in nostr git url")?
936 .to_string();
937 if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") {
938 decoded = format!("wss://{decoded}");
939 }
940 let url =
941 Url::parse(&decoded).context("could not parse relays in nostr git url")?;
942 relays.push(url.to_string());
943 }
944 coordinates.insert(Coordinate {
945 identifier,
946 public_key,
947 kind: nostr_sdk::Kind::GitRepoAnnouncement,
948 relays,
949 });
950 } else {
951 bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR);
952 }
953
954 Ok(Self {
955 coordinates,
956 protocol,
957 user,
958 })
959 }
960}
961
962/** produce error when using local repo or custom protocols */
963pub fn convert_clone_url_to_https(url: &str) -> Result<String> {
964 // Strip credentials if present
965 let stripped_url = strip_credentials(url);
966
967 // Check if the URL is already in HTTPS format
968 if stripped_url.starts_with("https://") {
969 return Ok(stripped_url);
970 }
971 // Convert http:// to https://
972 else if stripped_url.starts_with("http://") {
973 return Ok(stripped_url.replace("http://", "https://"));
974 }
975 // Check if the URL starts with SSH
976 else if stripped_url.starts_with("ssh://") {
977 // Convert SSH to HTTPS
978 let parts: Vec<&str> = stripped_url
979 .trim_start_matches("ssh://")
980 .split('/')
981 .collect();
982 if parts.len() >= 2 {
983 // Construct the HTTPS URL
984 return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/")));
985 }
986 bail!("Invalid SSH URL format: {}", url);
987 }
988 // Convert ftp:// to https://
989 else if stripped_url.starts_with("ftp://") {
990 return Ok(stripped_url.replace("ftp://", "https://"));
991 }
992 // Convert git:// to https://
993 else if stripped_url.starts_with("git://") {
994 return Ok(stripped_url.replace("git://", "https://"));
995 }
996
997 // If the URL is neither HTTPS, SSH, nor git@, return an error
998 bail!("Unsupported URL protocol: {}", url);
999}
1000
1001// Function to strip username and password from the URL
1002fn strip_credentials(url: &str) -> String {
1003 if let Some(pos) = url.find("://") {
1004 let (protocol, rest) = url.split_at(pos + 3); // Split at "://"
1005 let rest_parts: Vec<&str> = rest.split('@').collect();
1006 if rest_parts.len() > 1 {
1007 // If there are credentials, return the URL without them
1008 return format!("{}{}", protocol, rest_parts[1]);
1009 }
1010 } else if let Some(at_pos) = url.find('@') {
1011 // Handle user@host:path format
1012 let (_, rest) = url.split_at(at_pos);
1013 // This is a git@ syntax
1014 let host_and_repo = &rest[1..]; // Skip the ':'
1015 return format!("ssh://{}", host_and_repo.replace(':', "/"));
1016 }
1017 url.to_string() // Return the original URL if no credentials are found
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use std::fs;
1023
1024 use test_utils::{generate_repo_ref_event, git::GitTestRepo};
1025
1026 use super::*;
1027
1028 mod git_config_item_local {
1029 use super::*;
1030
1031 #[test]
1032 fn save_git_config_item_returns_ok() -> Result<()> {
1033 let test_repo = GitTestRepo::default();
1034 let git_repo = Repo::from_path(&test_repo.dir)?;
1035 git_repo.save_git_config_item("test.item", "testvalue", false)?;
1036 Ok(())
1037 }
1038
1039 #[test]
1040 fn get_git_config_item_returns_item_just_saved() -> Result<()> {
1041 let test_repo = GitTestRepo::default();
1042 let git_repo = Repo::from_path(&test_repo.dir)?;
1043 git_repo.save_git_config_item("test.item", "testvalue", false)?;
1044 assert_eq!(
1045 git_repo
1046 .get_git_config_item("test.item", Some(false))?
1047 .unwrap(),
1048 "testvalue",
1049 );
1050 Ok(())
1051 }
1052
1053 #[test]
1054 fn get_git_config_item_returns_none_if_not_present() -> Result<()> {
1055 let test_repo = GitTestRepo::default();
1056 let git_repo = Repo::from_path(&test_repo.dir)?;
1057 assert_eq!(
1058 git_repo.get_git_config_item("test.item", Some(false))?,
1059 None
1060 );
1061 Ok(())
1062 }
1063
1064 #[test]
1065 fn get_git_config_item_empty_string_returns_empty_string_instead_of_none() -> Result<()> {
1066 let test_repo = GitTestRepo::default();
1067 let git_repo = Repo::from_path(&test_repo.dir)?;
1068 git_repo.save_git_config_item("test.item", "", false)?;
1069 assert_eq!(
1070 git_repo.get_git_config_item("test.item", Some(false))?,
1071 Some("".to_string()),
1072 );
1073 Ok(())
1074 }
1075
1076 #[test]
1077 fn remove_local_git_config_item() -> Result<()> {
1078 let test_repo = GitTestRepo::default();
1079 let git_repo = Repo::from_path(&test_repo.dir)?;
1080 git_repo.save_git_config_item("test.item", "testvalue", false)?;
1081 assert!(git_repo.remove_git_config_item("test.item", false)?);
1082 assert_eq!(
1083 git_repo.get_git_config_item("test.item", Some(false))?,
1084 None,
1085 );
1086 Ok(())
1087 }
1088
1089 #[test]
1090 fn remove_git_config_item_returns_false_if_item_wasnt_set() -> Result<()> {
1091 let test_repo = GitTestRepo::default();
1092 let git_repo = Repo::from_path(&test_repo.dir)?;
1093 assert!(!(git_repo.remove_git_config_item("test.item", false)?));
1094 Ok(())
1095 }
1096 }
1097
1098 #[test]
1099 fn get_commit_parent() -> Result<()> {
1100 let test_repo = GitTestRepo::default();
1101 let parent_oid = test_repo.populate()?;
1102 std::fs::write(test_repo.dir.join("t100.md"), "some content")?;
1103 let child_oid = test_repo.stage_and_commit("add t100.md")?;
1104
1105 let git_repo = Repo::from_path(&test_repo.dir)?;
1106
1107 assert_eq!(
1108 // Sha1Hash::from_byte_array("bla".to_string().as_bytes()),
1109 oid_to_sha1(&parent_oid),
1110 git_repo.get_commit_parent(&oid_to_sha1(&child_oid))?,
1111 );
1112 Ok(())
1113 }
1114
1115 mod get_commit_message {
1116 use super::*;
1117 fn run(message: &str) -> Result<()> {
1118 let test_repo = GitTestRepo::default();
1119 test_repo.populate()?;
1120 std::fs::write(test_repo.dir.join("t100.md"), "some content")?;
1121 let oid = test_repo.stage_and_commit(message)?;
1122
1123 let git_repo = Repo::from_path(&test_repo.dir)?;
1124
1125 assert_eq!(message, git_repo.get_commit_message(&oid_to_sha1(&oid))?,);
1126 Ok(())
1127 }
1128 #[test]
1129 fn one_liner() -> Result<()> {
1130 run("add t100.md")
1131 }
1132
1133 #[test]
1134 fn multiline() -> Result<()> {
1135 run("add t100.md\r\nanother line\r\nthird line")
1136 }
1137
1138 #[test]
1139 fn trailing_newlines() -> Result<()> {
1140 run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n")
1141 }
1142
1143 #[test]
1144 fn unicode_characters() -> Result<()> {
1145 run("add t100.md ❤️")
1146 }
1147 }
1148
1149 mod get_commit_message_summary {
1150 use super::*;
1151 fn run(message: &str, summary: &str) -> Result<()> {
1152 let test_repo = GitTestRepo::default();
1153 test_repo.populate()?;
1154 std::fs::write(test_repo.dir.join("t100.md"), "some content")?;
1155 let oid = test_repo.stage_and_commit(message)?;
1156
1157 let git_repo = Repo::from_path(&test_repo.dir)?;
1158
1159 assert_eq!(
1160 summary,
1161 git_repo.get_commit_message_summary(&oid_to_sha1(&oid))?,
1162 );
1163 Ok(())
1164 }
1165 #[test]
1166 fn one_liner() -> Result<()> {
1167 run("add t100.md", "add t100.md")
1168 }
1169
1170 #[test]
1171 fn multiline() -> Result<()> {
1172 run("add t100.md\r\nanother line\r\nthird line", "add t100.md")
1173 }
1174
1175 #[test]
1176 fn trailing_newlines() -> Result<()> {
1177 run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n", "add t100.md")
1178 }
1179
1180 #[test]
1181 fn unicode_characters() -> Result<()> {
1182 run("add t100.md ❤️", "add t100.md ❤️")
1183 }
1184 }
1185
1186 mod get_commit_author {
1187 use super::*;
1188
1189 static NAME: &str = "carole";
1190 static EMAIL: &str = "carole@pm.me";
1191
1192 fn prep(time: &git2::Time) -> Result<Vec<String>> {
1193 let test_repo = GitTestRepo::default();
1194 test_repo.populate()?;
1195 fs::write(test_repo.dir.join("x1.md"), "some content")?;
1196 let oid = test_repo.stage_and_commit_custom_signature(
1197 "add x1.md",
1198 Some(&git2::Signature::new(NAME, EMAIL, time)?),
1199 None,
1200 )?;
1201
1202 let git_repo = Repo::from_path(&test_repo.dir)?;
1203 git_repo.get_commit_author(&oid_to_sha1(&oid))
1204 }
1205
1206 #[test]
1207 fn name() -> Result<()> {
1208 let res = prep(&git2::Time::new(5000, 0))?;
1209 assert_eq!(NAME, res[0]);
1210 Ok(())
1211 }
1212
1213 #[test]
1214 fn email() -> Result<()> {
1215 let res = prep(&git2::Time::new(5000, 0))?;
1216 assert_eq!(EMAIL, res[1]);
1217 Ok(())
1218 }
1219
1220 mod time {
1221 use super::*;
1222
1223 #[test]
1224 fn no_offset() -> Result<()> {
1225 let res = prep(&git2::Time::new(5000, 0))?;
1226 assert_eq!("5000", res[2]);
1227 assert_eq!("0", res[3]);
1228 Ok(())
1229 }
1230 #[test]
1231 fn positive_offset() -> Result<()> {
1232 let res = prep(&git2::Time::new(5000, 300))?;
1233 assert_eq!("5000", res[2]);
1234 assert_eq!("300", res[3]);
1235 Ok(())
1236 }
1237 #[test]
1238 fn negative_offset() -> Result<()> {
1239 let res = prep(&git2::Time::new(5000, -300))?;
1240 assert_eq!("5000", res[2]);
1241 assert_eq!("-300", res[3]);
1242 Ok(())
1243 }
1244 }
1245
1246 mod extract_sig_from_patch_tags {
1247 use super::*;
1248
1249 fn test(time: git2::Time) -> Result<()> {
1250 assert_eq!(
1251 extract_sig_from_patch_tags(
1252 &[nostr::Tag::custom(
1253 nostr::TagKind::Custom("author".to_string().into()),
1254 prep(&time)?,
1255 )],
1256 "author",
1257 )?
1258 .to_string(),
1259 git2::Signature::new(NAME, EMAIL, &time)?.to_string(),
1260 );
1261 Ok(())
1262 }
1263
1264 #[test]
1265 fn no_offset() -> Result<()> {
1266 test(git2::Time::new(5000, 0))
1267 }
1268
1269 #[test]
1270 fn positive_offset() -> Result<()> {
1271 test(git2::Time::new(5000, 300))
1272 }
1273
1274 #[test]
1275 fn negative_offset() -> Result<()> {
1276 test(git2::Time::new(5000, -300))
1277 }
1278 }
1279 }
1280
1281 mod get_commit_comitter {
1282 use super::*;
1283
1284 static NAME: &str = "carole";
1285 static EMAIL: &str = "carole@pm.me";
1286
1287 fn prep(time: &git2::Time) -> Result<Vec<String>> {
1288 let test_repo = GitTestRepo::default();
1289 test_repo.populate()?;
1290 fs::write(test_repo.dir.join("x1.md"), "some content")?;
1291 let oid = test_repo.stage_and_commit_custom_signature(
1292 "add x1.md",
1293 None,
1294 Some(&git2::Signature::new(NAME, EMAIL, time)?),
1295 )?;
1296
1297 let git_repo = Repo::from_path(&test_repo.dir)?;
1298 git_repo.get_commit_comitter(&oid_to_sha1(&oid))
1299 }
1300
1301 #[test]
1302 fn name() -> Result<()> {
1303 let res = prep(&git2::Time::new(5000, 0))?;
1304 assert_eq!(NAME, res[0]);
1305 Ok(())
1306 }
1307
1308 #[test]
1309 fn email() -> Result<()> {
1310 let res = prep(&git2::Time::new(5000, 0))?;
1311 assert_eq!(EMAIL, res[1]);
1312 Ok(())
1313 }
1314 }
1315
1316 mod does_commit_exist {
1317 use super::*;
1318
1319 #[test]
1320 fn existing_commits_results_in_true() -> Result<()> {
1321 let test_repo = GitTestRepo::default();
1322 test_repo.populate()?;
1323 let git_repo = Repo::from_path(&test_repo.dir)?;
1324
1325 assert!(git_repo.does_commit_exist("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?);
1326 Ok(())
1327 }
1328
1329 #[test]
1330 fn correctly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_false()
1331 -> Result<()> {
1332 let test_repo = GitTestRepo::default();
1333 test_repo.populate()?;
1334 let git_repo = Repo::from_path(&test_repo.dir)?;
1335
1336 assert!(!git_repo.does_commit_exist("000004edc0d2fa118d63faa3c2db9c73d630a5ae")?);
1337 Ok(())
1338 }
1339
1340 #[test]
1341 fn incorrectly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_error()
1342 -> Result<()> {
1343 let test_repo = GitTestRepo::default();
1344 test_repo.populate()?;
1345 let git_repo = Repo::from_path(&test_repo.dir)?;
1346
1347 assert!(git_repo.does_commit_exist("00").is_ok());
1348 Ok(())
1349 }
1350 }
1351
1352 mod make_patch_from_commit {
1353 use super::*;
1354 #[test]
1355 fn simple_patch_matches_string() -> Result<()> {
1356 let test_repo = GitTestRepo::default();
1357 let oid = test_repo.populate()?;
1358
1359 let git_repo = Repo::from_path(&test_repo.dir)?;
1360
1361 assert_eq!(
1362 "\
1363 From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\
1364 From: Joe Bloggs <joe.bloggs@pm.me>\n\
1365 Date: Thu, 1 Jan 1970 00:00:00 +0000\n\
1366 Subject: [PATCH] add t2.md\n\
1367 \n\
1368 ---\n \
1369 t2.md | 1 +\n \
1370 1 file changed, 1 insertion(+)\n \
1371 create mode 100644 t2.md\n\
1372 \n\
1373 diff --git a/t2.md b/t2.md\n\
1374 new file mode 100644\n\
1375 index 0000000..a66525d\n\
1376 --- /dev/null\n\
1377 +++ b/t2.md\n\
1378 @@ -0,0 +1 @@\n\
1379 +some content1\n\\ \
1380 No newline at end of file\n\
1381 --\n\
1382 libgit2 1.7.2\n\
1383 \n\
1384 ",
1385 git_repo.make_patch_from_commit(&oid_to_sha1(&oid), &None)?,
1386 );
1387 Ok(())
1388 }
1389
1390 #[test]
1391 fn series_count() -> Result<()> {
1392 let test_repo = GitTestRepo::default();
1393 let oid = test_repo.populate()?;
1394
1395 let git_repo = Repo::from_path(&test_repo.dir)?;
1396
1397 assert_eq!(
1398 "\
1399 From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\
1400 From: Joe Bloggs <joe.bloggs@pm.me>\n\
1401 Date: Thu, 1 Jan 1970 00:00:00 +0000\n\
1402 Subject: [PATCH 3/5] add t2.md\n\
1403 \n\
1404 ---\n \
1405 t2.md | 1 +\n \
1406 1 file changed, 1 insertion(+)\n \
1407 create mode 100644 t2.md\n\
1408 \n\
1409 diff --git a/t2.md b/t2.md\n\
1410 new file mode 100644\n\
1411 index 0000000..a66525d\n\
1412 --- /dev/null\n\
1413 +++ b/t2.md\n\
1414 @@ -0,0 +1 @@\n\
1415 +some content1\n\\ \
1416 No newline at end of file\n\
1417 --\n\
1418 libgit2 1.7.2\n\
1419 \n\
1420 ",
1421 git_repo.make_patch_from_commit(&oid_to_sha1(&oid), &Some((3, 5)))?,
1422 );
1423 Ok(())
1424 }
1425 }
1426
1427 mod get_main_or_master_branch {
1428
1429 use super::*;
1430
1431 #[test]
1432 fn return_origin_main_if_exists() -> Result<()> {
1433 let test_origin_repo = GitTestRepo::new("main")?;
1434 let main_origin_oid = test_origin_repo.populate()?;
1435
1436 let test_repo = GitTestRepo::new("main")?;
1437 test_repo.populate()?;
1438 test_repo.add_remote("origin", test_origin_repo.dir.to_str().unwrap())?;
1439 test_repo
1440 .git_repo
1441 .find_remote("origin")?
1442 .fetch(&["main"], None, None)?;
1443
1444 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1445 test_repo.stage_and_commit("add t3.md")?;
1446
1447 let git_repo = Repo::from_path(&test_repo.dir)?;
1448 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
1449 assert_eq!(name, "origin/main");
1450 assert_eq!(commit_hash, oid_to_sha1(&main_origin_oid));
1451 Ok(())
1452 }
1453
1454 mod returns_main {
1455 use super::*;
1456 #[test]
1457 fn when_it_exists() -> Result<()> {
1458 let test_repo = GitTestRepo::new("main")?;
1459 let main_oid = test_repo.populate()?;
1460 let git_repo = Repo::from_path(&test_repo.dir)?;
1461 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
1462 assert_eq!(name, "main");
1463 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
1464 Ok(())
1465 }
1466
1467 #[test]
1468 fn when_it_exists_and_other_branch_checkedout() -> Result<()> {
1469 let test_repo = GitTestRepo::new("main")?;
1470 let main_oid = test_repo.populate()?;
1471 test_repo.create_branch("feature")?;
1472 test_repo.checkout("feature")?;
1473 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1474 let feature_oid = test_repo.stage_and_commit("add t3.md")?;
1475
1476 let git_repo = Repo::from_path(&test_repo.dir)?;
1477 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
1478 assert_eq!(name, "main");
1479 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
1480 assert_ne!(commit_hash, oid_to_sha1(&feature_oid));
1481 Ok(())
1482 }
1483
1484 #[test]
1485 fn when_exists_even_if_master_is_checkedout() -> Result<()> {
1486 let test_repo = GitTestRepo::new("main")?;
1487 let main_oid = test_repo.populate()?;
1488 test_repo.create_branch("master")?;
1489 test_repo.checkout("master")?;
1490 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1491 let master_oid = test_repo.stage_and_commit("add t3.md")?;
1492
1493 let git_repo = Repo::from_path(&test_repo.dir)?;
1494 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
1495 assert_eq!(name, "main");
1496 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
1497 assert_ne!(commit_hash, oid_to_sha1(&master_oid));
1498 Ok(())
1499 }
1500 }
1501
1502 #[test]
1503 fn returns_master_if_exists_and_main_doesnt() -> Result<()> {
1504 let test_repo = GitTestRepo::new("master")?;
1505 let master_oid = test_repo.populate()?;
1506 test_repo.create_branch("feature")?;
1507 test_repo.checkout("feature")?;
1508 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1509 let feature_oid = test_repo.stage_and_commit("add t3.md")?;
1510
1511 let git_repo = Repo::from_path(&test_repo.dir)?;
1512 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
1513 assert_eq!(name, "master");
1514 assert_eq!(commit_hash, oid_to_sha1(&master_oid));
1515 assert_ne!(commit_hash, oid_to_sha1(&feature_oid));
1516 Ok(())
1517 }
1518 #[test]
1519 fn returns_error_if_no_main_or_master() -> Result<()> {
1520 let test_repo = GitTestRepo::new("feature")?;
1521 test_repo.populate()?;
1522 let git_repo = Repo::from_path(&test_repo.dir)?;
1523 assert!(git_repo.get_main_or_master_branch().is_err());
1524 Ok(())
1525 }
1526 }
1527
1528 mod get_origin_url {
1529 use super::*;
1530
1531 #[test]
1532 fn returns_origin_url() -> Result<()> {
1533 let test_repo = GitTestRepo::default();
1534 test_repo.add_remote("origin", "https://localhost:1000")?;
1535 let git_repo = Repo::from_path(&test_repo.dir)?;
1536 assert_eq!(git_repo.get_origin_url()?, "https://localhost:1000");
1537 Ok(())
1538 }
1539 }
1540 mod get_checked_out_branch_name {
1541 use super::*;
1542
1543 #[test]
1544 fn returns_checked_out_branch_name() -> Result<()> {
1545 let test_repo = GitTestRepo::default();
1546 let _ = test_repo.populate()?;
1547 // create feature branch
1548 test_repo.create_branch("example-feature")?;
1549 test_repo.checkout("example-feature")?;
1550
1551 let git_repo = Repo::from_path(&test_repo.dir)?;
1552
1553 assert_eq!(
1554 git_repo.get_checked_out_branch_name()?,
1555 "example-feature".to_string()
1556 );
1557 Ok(())
1558 }
1559 }
1560
1561 mod get_commits_ahead_behind {
1562 use super::*;
1563 mod returns_main {
1564 use super::*;
1565
1566 #[test]
1567 fn when_on_same_commit_return_empty() -> Result<()> {
1568 let test_repo = GitTestRepo::default();
1569 let oid = test_repo.populate()?;
1570 // create feature branch
1571 test_repo.create_branch("feature")?;
1572 test_repo.checkout("feature")?;
1573
1574 let git_repo = Repo::from_path(&test_repo.dir)?;
1575
1576 let (ahead, behind) =
1577 git_repo.get_commits_ahead_behind(&oid_to_sha1(&oid), &oid_to_sha1(&oid))?;
1578 assert_eq!(ahead, vec![]);
1579 assert_eq!(behind, vec![]);
1580 Ok(())
1581 }
1582
1583 #[test]
1584 fn when_2_commit_behind() -> Result<()> {
1585 let test_repo = GitTestRepo::default();
1586 test_repo.populate()?;
1587 // create feature branch
1588 test_repo.create_branch("feature")?;
1589 let feature_oid = test_repo.checkout("feature")?;
1590 // checkout main and add 2 commits
1591 test_repo.checkout("main")?;
1592 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
1593 let behind_1_oid = test_repo.stage_and_commit("add t5.md")?;
1594 std::fs::write(test_repo.dir.join("t6.md"), "some content")?;
1595 let behind_2_oid = test_repo.stage_and_commit("add t6.md")?;
1596
1597 let git_repo = Repo::from_path(&test_repo.dir)?;
1598
1599 let (ahead, behind) = git_repo.get_commits_ahead_behind(
1600 &oid_to_sha1(&behind_2_oid),
1601 &oid_to_sha1(&feature_oid),
1602 )?;
1603 assert_eq!(ahead, vec![]);
1604 assert_eq!(
1605 behind,
1606 vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid),],
1607 );
1608 Ok(())
1609 }
1610
1611 #[test]
1612 fn when_2_commit_ahead() -> Result<()> {
1613 let test_repo = GitTestRepo::default();
1614 let main_oid = test_repo.populate()?;
1615 // create feature branch and add 2 commits
1616 test_repo.create_branch("feature")?;
1617 test_repo.checkout("feature")?;
1618 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1619 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1620 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1621 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
1622
1623 let git_repo = Repo::from_path(&test_repo.dir)?;
1624
1625 let (ahead, behind) = git_repo.get_commits_ahead_behind(
1626 &oid_to_sha1(&main_oid),
1627 &oid_to_sha1(&ahead_2_oid),
1628 )?;
1629 assert_eq!(
1630 ahead,
1631 vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid),],
1632 );
1633 assert_eq!(behind, vec![]);
1634 Ok(())
1635 }
1636
1637 #[test]
1638 fn when_2_commit_ahead_and_2_commits_behind() -> Result<()> {
1639 let test_repo = GitTestRepo::default();
1640 test_repo.populate()?;
1641 // create feature branch and add 2 commits
1642 test_repo.create_branch("feature")?;
1643 test_repo.checkout("feature")?;
1644 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1645 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1646 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1647 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
1648 // checkout main and add 2 commits
1649 test_repo.checkout("main")?;
1650 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
1651 let behind_1_oid = test_repo.stage_and_commit("add t5.md")?;
1652 std::fs::write(test_repo.dir.join("t6.md"), "some content")?;
1653 let behind_2_oid = test_repo.stage_and_commit("add t6.md")?;
1654
1655 let git_repo = Repo::from_path(&test_repo.dir)?;
1656
1657 let (ahead, behind) = git_repo.get_commits_ahead_behind(
1658 &oid_to_sha1(&behind_2_oid),
1659 &oid_to_sha1(&ahead_2_oid),
1660 )?;
1661 assert_eq!(
1662 ahead,
1663 vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid)],
1664 );
1665 assert_eq!(
1666 behind,
1667 vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid)],
1668 );
1669 Ok(())
1670 }
1671 }
1672 }
1673
1674 mod create_branch_at_commit {
1675 use super::*;
1676 #[test]
1677 fn doesnt_error() -> Result<()> {
1678 let test_repo = GitTestRepo::default();
1679 test_repo.populate()?;
1680 // create feature branch and add 2 commits
1681 test_repo.create_branch("feature")?;
1682 test_repo.checkout("feature")?;
1683 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1684 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1685 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1686 test_repo.stage_and_commit("add t4.md")?;
1687
1688 let git_repo = Repo::from_path(&test_repo.dir)?;
1689
1690 let branch_name = "test-name-1";
1691 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1692
1693 Ok(())
1694 }
1695
1696 #[test]
1697 fn branch_gets_created() -> Result<()> {
1698 let test_repo = GitTestRepo::default();
1699 test_repo.populate()?;
1700 // create feature branch and add 2 commits
1701 test_repo.create_branch("feature")?;
1702 test_repo.checkout("feature")?;
1703 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1704 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1705 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1706 test_repo.stage_and_commit("add t4.md")?;
1707
1708 let git_repo = Repo::from_path(&test_repo.dir)?;
1709
1710 let branch_name = "test-name-1";
1711 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1712
1713 assert!(test_repo.checkout(branch_name).is_ok());
1714 Ok(())
1715 }
1716
1717 #[test]
1718 fn branch_created_with_correct_commit() -> Result<()> {
1719 let test_repo = GitTestRepo::default();
1720 test_repo.populate()?;
1721 // create feature branch and add 2 commits
1722 test_repo.create_branch("feature")?;
1723 test_repo.checkout("feature")?;
1724 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1725 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1726 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1727 test_repo.stage_and_commit("add t4.md")?;
1728
1729 let git_repo = Repo::from_path(&test_repo.dir)?;
1730
1731 let branch_name = "test-name-1";
1732 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1733
1734 assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid);
1735 Ok(())
1736 }
1737
1738 mod when_branch_already_exists {
1739 use super::*;
1740
1741 #[test]
1742 fn when_new_tip_specified_it_is_updated() -> Result<()> {
1743 let test_repo = GitTestRepo::default();
1744 test_repo.populate()?;
1745 // create feature branch and add 2 commits
1746 test_repo.create_branch("feature")?;
1747 test_repo.checkout("feature")?;
1748 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1749 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1750 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1751 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
1752
1753 let git_repo = Repo::from_path(&test_repo.dir)?;
1754
1755 let branch_name = "test-name-1";
1756 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1757
1758 git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?;
1759 assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid);
1760 Ok(())
1761 }
1762
1763 #[test]
1764 fn when_same_tip_is_specified_it_doesnt_error() -> Result<()> {
1765 let test_repo = GitTestRepo::default();
1766 test_repo.populate()?;
1767 // create feature branch and add 2 commits
1768 test_repo.create_branch("feature")?;
1769 test_repo.checkout("feature")?;
1770 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1771 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1772 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1773 test_repo.stage_and_commit("add t4.md")?;
1774
1775 let git_repo = Repo::from_path(&test_repo.dir)?;
1776
1777 let branch_name = "test-name-1";
1778 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1779
1780 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1781 assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid);
1782 Ok(())
1783 }
1784
1785 #[test]
1786 fn when_branch_is_checkedout_new_tip_specified_it_is_updated() -> Result<()> {
1787 let test_repo = GitTestRepo::default();
1788 test_repo.populate()?;
1789 // create feature branch and add 2 commits
1790 test_repo.create_branch("feature")?;
1791 test_repo.checkout("feature")?;
1792 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1793 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
1794 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1795 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
1796
1797 let git_repo = Repo::from_path(&test_repo.dir)?;
1798
1799 let branch_name = "test-name-1";
1800 git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?;
1801 test_repo.checkout(branch_name)?;
1802 git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?;
1803 test_repo.checkout("main")?;
1804
1805 assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid);
1806 Ok(())
1807 }
1808 }
1809 }
1810
1811 mod create_commit_from_patch {
1812
1813 use test_utils::TEST_KEY_1_SIGNER;
1814
1815 use super::*;
1816 use crate::{repo_ref::RepoRef, sub_commands::send::generate_patch_event};
1817
1818 async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result<nostr::Event> {
1819 let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id();
1820 let git_repo = Repo::from_path(&test_repo.dir)?;
1821 generate_patch_event(
1822 &git_repo,
1823 &git_repo.get_root_commit()?,
1824 &oid_to_sha1(&original_oid),
1825 Some(nostr::EventId::all_zeros()),
1826 &TEST_KEY_1_SIGNER,
1827 &RepoRef::try_from(generate_repo_ref_event()).unwrap(),
1828 None,
1829 None,
1830 None,
1831 &None,
1832 &[],
1833 )
1834 .await
1835 }
1836 fn test_patch_applies_to_repository(patch_event: nostr::Event) -> Result<()> {
1837 let test_repo = GitTestRepo::default();
1838 test_repo.populate()?;
1839 let git_repo = Repo::from_path(&test_repo.dir)?;
1840 println!("{:?}", &patch_event);
1841 git_repo.create_commit_from_patch(&patch_event)?;
1842 let commit_id = tag_value(&patch_event, "commit")?;
1843 // does commit with id exist?
1844 assert!(git_repo.does_commit_exist(&commit_id)?);
1845 Ok(())
1846 }
1847
1848 mod patch_created_as_commit_with_matching_id {
1849 use test_utils::git::joe_signature;
1850
1851 use super::*;
1852
1853 #[tokio::test]
1854 async fn simple_signature_author_committer_same_as_git_user_0_unixtime_no_pgp_signature()
1855 -> Result<()> {
1856 let source_repo = GitTestRepo::default();
1857 source_repo.populate()?;
1858 fs::write(source_repo.dir.join("x1.md"), "some content")?;
1859 source_repo.stage_and_commit("add x1.md")?;
1860
1861 test_patch_applies_to_repository(
1862 generate_patch_from_head_commit(&source_repo).await?,
1863 )
1864 }
1865
1866 #[tokio::test]
1867 async fn signature_with_specific_author_time() -> Result<()> {
1868 let source_repo = GitTestRepo::default();
1869 source_repo.populate()?;
1870 fs::write(source_repo.dir.join("x1.md"), "some content")?;
1871 source_repo.stage_and_commit_custom_signature(
1872 "add x1.md",
1873 Some(&git2::Signature::new(
1874 joe_signature().name().unwrap(),
1875 joe_signature().email().unwrap(),
1876 &git2::Time::new(5000, 0),
1877 )?),
1878 None,
1879 )?;
1880
1881 test_patch_applies_to_repository(
1882 generate_patch_from_head_commit(&source_repo).await?,
1883 )
1884 }
1885
1886 #[tokio::test]
1887 async fn author_name_and_email_not_current_git_user() -> Result<()> {
1888 let source_repo = GitTestRepo::default();
1889 source_repo.populate()?;
1890 fs::write(source_repo.dir.join("x1.md"), "some content")?;
1891 source_repo.stage_and_commit_custom_signature(
1892 "add x1.md",
1893 Some(&git2::Signature::new(
1894 "carole",
1895 "carole@pm.me",
1896 &git2::Time::new(0, 0),
1897 )?),
1898 None,
1899 )?;
1900
1901 test_patch_applies_to_repository(
1902 generate_patch_from_head_commit(&source_repo).await?,
1903 )
1904 }
1905
1906 #[tokio::test]
1907 async fn comiiter_name_and_email_not_current_git_user_or_author() -> Result<()> {
1908 let source_repo = GitTestRepo::default();
1909 source_repo.populate()?;
1910 fs::write(source_repo.dir.join("x1.md"), "some content")?;
1911 source_repo.stage_and_commit_custom_signature(
1912 "add x1.md",
1913 Some(&git2::Signature::new(
1914 "carole",
1915 "carole@pm.me",
1916 &git2::Time::new(0, 0),
1917 )?),
1918 Some(&git2::Signature::new(
1919 "bob",
1920 "bob@pm.me",
1921 &git2::Time::new(0, 0),
1922 )?),
1923 )?;
1924
1925 test_patch_applies_to_repository(
1926 generate_patch_from_head_commit(&source_repo).await?,
1927 )
1928 }
1929
1930 // TODO: pgp signature
1931
1932 #[tokio::test]
1933 async fn unique_author_and_commiter_details() -> Result<()> {
1934 let source_repo = GitTestRepo::default();
1935 source_repo.populate()?;
1936 fs::write(source_repo.dir.join("x1.md"), "some content")?;
1937 source_repo.stage_and_commit_custom_signature(
1938 "add x1.md",
1939 Some(&git2::Signature::new(
1940 "carole",
1941 "carole@pm.me",
1942 &git2::Time::new(5000, 0),
1943 )?),
1944 Some(&git2::Signature::new(
1945 "bob",
1946 "bob@pm.me",
1947 &git2::Time::new(1000, 0),
1948 )?),
1949 )?;
1950
1951 test_patch_applies_to_repository(
1952 generate_patch_from_head_commit(&source_repo).await?,
1953 )
1954 }
1955 }
1956 }
1957
1958 mod apply_patch_chain {
1959 use test_utils::TEST_KEY_1_SIGNER;
1960
1961 use super::*;
1962 use crate::{
1963 repo_ref::RepoRef, sub_commands::send::generate_cover_letter_and_patch_events,
1964 };
1965
1966 static BRANCH_NAME: &str = "add-example-feature";
1967 // returns original_repo, cover_letter_event, patch_events
1968 async fn generate_test_repo_and_events()
1969 -> Result<(GitTestRepo, nostr::Event, Vec<nostr::Event>)> {
1970 let original_repo = GitTestRepo::default();
1971 let oid3 = original_repo.populate_with_test_branch()?;
1972 let oid2 = original_repo.git_repo.find_commit(oid3)?.parent_id(0)?;
1973 let oid1 = original_repo.git_repo.find_commit(oid2)?.parent_id(0)?;
1974 // TODO: generate cover_letter and patch events
1975 let git_repo = Repo::from_path(&original_repo.dir)?;
1976
1977 let mut events = generate_cover_letter_and_patch_events(
1978 Some(("test".to_string(), "test".to_string())),
1979 &git_repo,
1980 &[oid_to_sha1(&oid1), oid_to_sha1(&oid2), oid_to_sha1(&oid3)],
1981 &TEST_KEY_1_SIGNER,
1982 &RepoRef::try_from(generate_repo_ref_event()).unwrap(),
1983 &None,
1984 &[],
1985 )
1986 .await?;
1987
1988 events.reverse();
1989
1990 Ok((original_repo, events.pop().unwrap(), events))
1991 }
1992
1993 mod when_branch_and_commits_dont_exist {
1994 use super::*;
1995
1996 mod when_branch_root_is_tip_of_main {
1997 use super::*;
1998
1999 #[tokio::test]
2000 async fn branch_gets_created_with_name_specified_in_proposal() -> Result<()> {
2001 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2002 let test_repo = GitTestRepo::default();
2003 test_repo.populate()?;
2004 let git_repo = Repo::from_path(&test_repo.dir)?;
2005 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2006 assert!(
2007 git_repo
2008 .get_local_branch_names()?
2009 .contains(&BRANCH_NAME.to_string())
2010 );
2011 Ok(())
2012 }
2013
2014 #[tokio::test]
2015 async fn branch_checked_out() -> Result<()> {
2016 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2017 let test_repo = GitTestRepo::default();
2018 test_repo.populate()?;
2019 let git_repo = Repo::from_path(&test_repo.dir)?;
2020 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2021 assert_eq!(
2022 git_repo.get_checked_out_branch_name()?,
2023 BRANCH_NAME.to_string(),
2024 );
2025 Ok(())
2026 }
2027
2028 #[tokio::test]
2029 async fn patches_get_created_as_commits() -> Result<()> {
2030 let (original_repo, _, patch_events) = generate_test_repo_and_events().await?;
2031 let test_repo = GitTestRepo::default();
2032 test_repo.populate()?;
2033 let git_repo = Repo::from_path(&test_repo.dir)?;
2034 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2035 assert_eq!(
2036 test_repo.git_repo.head()?.peel_to_commit()?.id(),
2037 original_repo.git_repo.head()?.peel_to_commit()?.id(),
2038 );
2039 Ok(())
2040 }
2041
2042 #[tokio::test]
2043 async fn branch_tip_is_most_recent_patch() -> Result<()> {
2044 let (original_repo, _, patch_events) = generate_test_repo_and_events().await?;
2045 let test_repo = GitTestRepo::default();
2046 test_repo.populate()?;
2047 let git_repo = Repo::from_path(&test_repo.dir)?;
2048 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2049 assert_eq!(
2050 git_repo.get_tip_of_branch(BRANCH_NAME)?,
2051 oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),),
2052 );
2053 Ok(())
2054 }
2055
2056 #[tokio::test]
2057 async fn previously_checked_out_branch_tip_does_not_change() -> Result<()> {
2058 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2059 let test_repo = GitTestRepo::default();
2060 test_repo.populate()?;
2061 let existing_branch = test_repo.get_checked_out_branch_name()?;
2062 let git_repo = Repo::from_path(&test_repo.dir)?;
2063 let previous_tip_of_existing_branch =
2064 git_repo.get_tip_of_branch(existing_branch.as_str())?;
2065 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2066 assert_eq!(
2067 previous_tip_of_existing_branch,
2068 git_repo.get_tip_of_branch(existing_branch.as_str())?,
2069 );
2070 Ok(())
2071 }
2072
2073 #[tokio::test]
2074 async fn returns_all_patches_applied() -> Result<()> {
2075 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2076 let test_repo = GitTestRepo::default();
2077 test_repo.populate()?;
2078 let git_repo = Repo::from_path(&test_repo.dir)?;
2079 let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2080 assert_eq!(res.len(), 3);
2081 Ok(())
2082 }
2083 }
2084
2085 mod when_branch_root_is_tip_behind_main {
2086 use super::*;
2087
2088 #[tokio::test]
2089 async fn branch_gets_created_with_name_specified_in_proposal() -> Result<()> {
2090 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2091 let test_repo = GitTestRepo::default();
2092 test_repo.populate()?;
2093 std::fs::write(test_repo.dir.join("m3.md"), "some content")?;
2094 test_repo.stage_and_commit("add m3.md")?;
2095 let git_repo = Repo::from_path(&test_repo.dir)?;
2096 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2097 assert!(
2098 git_repo
2099 .get_local_branch_names()?
2100 .contains(&BRANCH_NAME.to_string())
2101 );
2102 Ok(())
2103 }
2104
2105 #[tokio::test]
2106 async fn branch_checked_out() -> Result<()> {
2107 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2108 let test_repo = GitTestRepo::default();
2109 test_repo.populate()?;
2110 std::fs::write(test_repo.dir.join("m3.md"), "some content")?;
2111 test_repo.stage_and_commit("add m3.md")?;
2112 let git_repo = Repo::from_path(&test_repo.dir)?;
2113 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2114 assert_eq!(
2115 git_repo.get_checked_out_branch_name()?,
2116 BRANCH_NAME.to_string(),
2117 );
2118 Ok(())
2119 }
2120
2121 #[tokio::test]
2122 async fn branch_tip_is_most_recent_patch() -> Result<()> {
2123 let (original_repo, _, patch_events) = generate_test_repo_and_events().await?;
2124 let test_repo = GitTestRepo::default();
2125 test_repo.populate()?;
2126 std::fs::write(test_repo.dir.join("m3.md"), "some content")?;
2127 test_repo.stage_and_commit("add m3.md")?;
2128 let git_repo = Repo::from_path(&test_repo.dir)?;
2129 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2130 assert_eq!(
2131 git_repo.get_tip_of_branch(BRANCH_NAME)?,
2132 oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),),
2133 );
2134 Ok(())
2135 }
2136
2137 #[tokio::test]
2138 async fn previously_checked_out_branch_tip_does_not_change() -> Result<()> {
2139 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2140 let test_repo = GitTestRepo::default();
2141 test_repo.populate()?;
2142 std::fs::write(test_repo.dir.join("m3.md"), "some content")?;
2143 test_repo.stage_and_commit("add m3.md")?;
2144 let existing_branch = test_repo.get_checked_out_branch_name()?;
2145 let git_repo = Repo::from_path(&test_repo.dir)?;
2146 let previous_tip_of_existing_branch =
2147 git_repo.get_tip_of_branch(existing_branch.as_str())?;
2148 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2149 assert_eq!(
2150 previous_tip_of_existing_branch,
2151 git_repo.get_tip_of_branch(existing_branch.as_str())?,
2152 );
2153 Ok(())
2154 }
2155
2156 #[tokio::test]
2157 async fn returns_all_patches_applied() -> Result<()> {
2158 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2159 let test_repo = GitTestRepo::default();
2160 test_repo.populate()?;
2161 let git_repo = Repo::from_path(&test_repo.dir)?;
2162 let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2163 assert_eq!(res.len(), 3);
2164 Ok(())
2165 }
2166 }
2167
2168 // TODO when_proposal_root_is_tip_ahead_of_main_and_doesnt_exist
2169 }
2170
2171 mod when_branch_and_first_commits_exists {
2172 use super::*;
2173
2174 mod when_branch_already_checked_out {
2175 use super::*;
2176
2177 #[tokio::test]
2178 async fn branch_tip_is_most_recent_patch() -> Result<()> {
2179 let (original_repo, _, mut patch_events) =
2180 generate_test_repo_and_events().await?;
2181 let test_repo = GitTestRepo::default();
2182 test_repo.populate()?;
2183 let git_repo = Repo::from_path(&test_repo.dir)?;
2184 git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?;
2185 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2186
2187 assert_eq!(
2188 git_repo.get_tip_of_branch(BRANCH_NAME)?,
2189 oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),),
2190 );
2191 Ok(())
2192 }
2193
2194 #[tokio::test]
2195 async fn returns_all_patches_applied() -> Result<()> {
2196 let (_, _, mut patch_events) = generate_test_repo_and_events().await?;
2197 let test_repo = GitTestRepo::default();
2198 test_repo.populate()?;
2199 let git_repo = Repo::from_path(&test_repo.dir)?;
2200 git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?;
2201 let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2202 assert_eq!(res.len(), 2);
2203 Ok(())
2204 }
2205 }
2206 mod when_branch_not_checked_out {
2207 use super::*;
2208
2209 #[tokio::test]
2210 async fn branch_tip_is_most_recent_patch() -> Result<()> {
2211 let (original_repo, _, mut patch_events) =
2212 generate_test_repo_and_events().await?;
2213 let test_repo = GitTestRepo::default();
2214 test_repo.populate()?;
2215 let git_repo = Repo::from_path(&test_repo.dir)?;
2216 git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?;
2217 git_repo.checkout("main")?;
2218 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2219
2220 assert_eq!(
2221 git_repo.get_tip_of_branch(BRANCH_NAME)?,
2222 oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),),
2223 );
2224 Ok(())
2225 }
2226
2227 #[tokio::test]
2228 async fn branch_checked_out() -> Result<()> {
2229 let (_, _, mut patch_events) = generate_test_repo_and_events().await?;
2230 let test_repo = GitTestRepo::default();
2231 test_repo.populate()?;
2232 let git_repo = Repo::from_path(&test_repo.dir)?;
2233 git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?;
2234 git_repo.checkout("main")?;
2235 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2236
2237 assert_eq!(
2238 git_repo.get_checked_out_branch_name()?,
2239 BRANCH_NAME.to_string(),
2240 );
2241 Ok(())
2242 }
2243
2244 #[tokio::test]
2245 async fn returns_all_patches_applied() -> Result<()> {
2246 let (_, _, mut patch_events) = generate_test_repo_and_events().await?;
2247 let test_repo = GitTestRepo::default();
2248 test_repo.populate()?;
2249 let git_repo = Repo::from_path(&test_repo.dir)?;
2250 git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?;
2251 git_repo.checkout("main")?;
2252 let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2253 assert_eq!(res.len(), 2);
2254 Ok(())
2255 }
2256 }
2257 // TODO when branch ahead (rebased or user commits)
2258 }
2259 mod when_branch_exists_and_is_up_to_date {
2260 use super::*;
2261
2262 mod when_branch_already_checked_out {
2263 use super::*;
2264
2265 #[tokio::test]
2266 async fn returns_all_patches_applied_0() -> Result<()> {
2267 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2268 let test_repo = GitTestRepo::default();
2269 test_repo.populate()?;
2270 let git_repo = Repo::from_path(&test_repo.dir)?;
2271 git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?;
2272 let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2273 assert_eq!(res.len(), 0);
2274 Ok(())
2275 }
2276 }
2277 mod when_branch_not_checked_out {
2278 use super::*;
2279
2280 #[tokio::test]
2281 async fn branch_checked_out() -> Result<()> {
2282 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2283 let test_repo = GitTestRepo::default();
2284 test_repo.populate()?;
2285 let git_repo = Repo::from_path(&test_repo.dir)?;
2286 git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?;
2287 git_repo.checkout("main")?;
2288 git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2289
2290 assert_eq!(
2291 git_repo.get_checked_out_branch_name()?,
2292 BRANCH_NAME.to_string(),
2293 );
2294 Ok(())
2295 }
2296
2297 #[tokio::test]
2298 async fn returns_all_patches_applied_0() -> Result<()> {
2299 let (_, _, patch_events) = generate_test_repo_and_events().await?;
2300 let test_repo = GitTestRepo::default();
2301 test_repo.populate()?;
2302 let git_repo = Repo::from_path(&test_repo.dir)?;
2303 git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?;
2304 git_repo.checkout("main")?;
2305 let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?;
2306 assert_eq!(res.len(), 0);
2307 Ok(())
2308 }
2309 }
2310 }
2311 }
2312 mod parse_starting_commits {
2313 use super::*;
2314
2315 mod head_1_returns_latest_commit {
2316 use super::*;
2317
2318 #[test]
2319 fn when_on_main_and_other_commits_are_more_recent_on_feature_branch() -> Result<()> {
2320 let test_repo = GitTestRepo::default();
2321 let git_repo = Repo::from_path(&test_repo.dir)?;
2322 test_repo.populate_with_test_branch()?;
2323 test_repo.checkout("main")?;
2324
2325 assert_eq!(
2326 git_repo.parse_starting_commits("HEAD~1")?,
2327 vec![str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?],
2328 );
2329 Ok(())
2330 }
2331
2332 #[test]
2333 fn when_checked_out_branch_ahead_of_main() -> Result<()> {
2334 let test_repo = GitTestRepo::default();
2335 let git_repo = Repo::from_path(&test_repo.dir)?;
2336 test_repo.populate_with_test_branch()?;
2337
2338 assert_eq!(
2339 git_repo.parse_starting_commits("HEAD~1")?,
2340 vec![str_to_sha1("82ff2bcc9aa94d1bd8faee723d4c8cc190d6061c")?],
2341 );
2342 Ok(())
2343 }
2344 }
2345 mod head_2_returns_latest_2_commits_youngest_first {
2346 use super::*;
2347
2348 #[test]
2349 fn when_on_main_and_other_commits_are_more_recent_on_feature_branch() -> Result<()> {
2350 let test_repo = GitTestRepo::default();
2351 let git_repo = Repo::from_path(&test_repo.dir)?;
2352 test_repo.populate_with_test_branch()?;
2353 test_repo.checkout("main")?;
2354
2355 assert_eq!(
2356 git_repo.parse_starting_commits("HEAD~2")?,
2357 vec![
2358 str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?,
2359 str_to_sha1("af474d8d271490e5c635aad337abdc050034b16a")?,
2360 ],
2361 );
2362 Ok(())
2363 }
2364 }
2365 mod head_3_returns_latest_3_commits_youngest_first {
2366 use super::*;
2367
2368 #[test]
2369 fn when_checked_out_branch_ahead_of_main() -> Result<()> {
2370 let test_repo = GitTestRepo::default();
2371 let git_repo = Repo::from_path(&test_repo.dir)?;
2372 test_repo.populate_with_test_branch()?;
2373
2374 assert_eq!(
2375 git_repo.parse_starting_commits("HEAD~3")?,
2376 vec![
2377 str_to_sha1("82ff2bcc9aa94d1bd8faee723d4c8cc190d6061c")?,
2378 str_to_sha1("a23e6b05aaeb7d1471b4a838b51f337d5644eeb0")?,
2379 str_to_sha1("7ab82116068982671a8111f27dc10599172334b2")?,
2380 ],
2381 );
2382 Ok(())
2383 }
2384 }
2385 mod range_of_3_commits_not_in_branch_history_returns_3_commits_youngest_first {
2386 use super::*;
2387
2388 #[test]
2389 fn when_checked_out_branch_ahead_of_main() -> Result<()> {
2390 let test_repo = GitTestRepo::default();
2391 let git_repo = Repo::from_path(&test_repo.dir)?;
2392 test_repo.populate_with_test_branch()?;
2393 test_repo.checkout("main")?;
2394
2395 assert_eq!(
2396 git_repo.parse_starting_commits("af474d8..a23e6b0")?,
2397 vec![
2398 str_to_sha1("a23e6b05aaeb7d1471b4a838b51f337d5644eeb0")?,
2399 str_to_sha1("7ab82116068982671a8111f27dc10599172334b2")?,
2400 str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?,
2401 ],
2402 );
2403 Ok(())
2404 }
2405 }
2406 }
2407 mod ancestor_of {
2408 use super::*;
2409
2410 #[test]
2411 fn deep_ancestor_returns_true() -> Result<()> {
2412 let test_repo = GitTestRepo::default();
2413 let from_main_in_feature_history = test_repo.populate()?;
2414
2415 // create feature branch and add 2 commits
2416 test_repo.create_branch("feature")?;
2417
2418 test_repo.checkout("feature")?;
2419 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
2420 test_repo.stage_and_commit("add t3.md")?;
2421 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
2422 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
2423
2424 let git_repo = Repo::from_path(&test_repo.dir)?;
2425
2426 assert!(git_repo.ancestor_of(
2427 &oid_to_sha1(&ahead_2_oid),
2428 &oid_to_sha1(&from_main_in_feature_history)
2429 )?);
2430 Ok(())
2431 }
2432
2433 #[test]
2434 fn commit_parent_returns_true() -> Result<()> {
2435 let test_repo = GitTestRepo::default();
2436 test_repo.populate()?;
2437
2438 // create feature branch and add 2 commits
2439 test_repo.create_branch("feature")?;
2440
2441 test_repo.checkout("feature")?;
2442 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
2443 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
2444 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
2445 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
2446
2447 let git_repo = Repo::from_path(&test_repo.dir)?;
2448
2449 assert!(git_repo.ancestor_of(&oid_to_sha1(&ahead_2_oid), &oid_to_sha1(&ahead_1_oid))?);
2450 Ok(())
2451 }
2452
2453 #[test]
2454 fn same_commit_returns_false() -> Result<()> {
2455 let test_repo = GitTestRepo::default();
2456 test_repo.populate()?;
2457
2458 // create feature branch and add 2 commits
2459 test_repo.create_branch("feature")?;
2460
2461 test_repo.checkout("feature")?;
2462 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
2463 test_repo.stage_and_commit("add t3.md")?;
2464 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
2465 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
2466
2467 let git_repo = Repo::from_path(&test_repo.dir)?;
2468
2469 assert!(!git_repo.ancestor_of(&oid_to_sha1(&ahead_2_oid), &oid_to_sha1(&ahead_2_oid))?);
2470 Ok(())
2471 }
2472
2473 #[test]
2474 fn commit_not_in_history_returns_false() -> Result<()> {
2475 let test_repo = GitTestRepo::default();
2476 test_repo.populate()?;
2477
2478 // create feature branch and add 2 commits
2479 test_repo.create_branch("feature")?;
2480
2481 // create commit not in feature history
2482 std::fs::write(test_repo.dir.join("notfeature.md"), "some content")?;
2483 let on_main_after_feature = test_repo.stage_and_commit("add notfeature.md")?;
2484
2485 test_repo.checkout("feature")?;
2486 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
2487 test_repo.stage_and_commit("add t3.md")?;
2488 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
2489 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
2490
2491 let git_repo = Repo::from_path(&test_repo.dir)?;
2492
2493 assert!(!git_repo.ancestor_of(
2494 &oid_to_sha1(&ahead_2_oid),
2495 &oid_to_sha1(&on_main_after_feature)
2496 )?);
2497 Ok(())
2498 }
2499 }
2500 mod convert_clone_url_to_https {
2501 use super::*;
2502
2503 #[test]
2504 fn test_https_url() {
2505 let url = "https://github.com/user/repo.git";
2506 let result = convert_clone_url_to_https(url).unwrap();
2507 assert_eq!(result, "https://github.com/user/repo.git");
2508 }
2509
2510 #[test]
2511 fn test_http_url() {
2512 let url = "http://github.com/user/repo.git";
2513 let result = convert_clone_url_to_https(url).unwrap();
2514 assert_eq!(result, "https://github.com/user/repo.git");
2515 }
2516
2517 #[test]
2518 fn test_http_url_with_credentials() {
2519 let url = "http://username:password@github.com/user/repo.git";
2520 let result = convert_clone_url_to_https(url).unwrap();
2521 assert_eq!(result, "https://github.com/user/repo.git");
2522 }
2523
2524 #[test]
2525 fn test_git_at_url() {
2526 let url = "git@github.com:user/repo.git";
2527 let result = convert_clone_url_to_https(url).unwrap();
2528 assert_eq!(result, "https://github.com/user/repo.git");
2529 }
2530
2531 #[test]
2532 fn test_user_at_url() {
2533 let url = "user1@github.com:user/repo.git";
2534 let result = convert_clone_url_to_https(url).unwrap();
2535 assert_eq!(result, "https://github.com/user/repo.git");
2536 }
2537
2538 #[test]
2539 fn test_ssh_url() {
2540 let url = "ssh://github.com/user/repo.git";
2541 let result = convert_clone_url_to_https(url).unwrap();
2542 assert_eq!(result, "https://github.com/user/repo.git");
2543 }
2544
2545 #[test]
2546 fn test_ftp_url() {
2547 let url = "ftp://example.com/repo.git";
2548 let result = convert_clone_url_to_https(url).unwrap();
2549 assert_eq!(result, "https://example.com/repo.git");
2550 }
2551
2552 #[test]
2553 fn test_git_protocol_url() {
2554 let url = "git://example.com/repo.git";
2555 let result = convert_clone_url_to_https(url).unwrap();
2556 assert_eq!(result, "https://example.com/repo.git");
2557 }
2558
2559 #[test]
2560 fn test_invalid_url() {
2561 let url = "unsupported://example.com/repo.git";
2562 let result = convert_clone_url_to_https(url);
2563 assert!(result.is_err());
2564 }
2565 }
2566}
diff --git a/src/lib/login/key_encryption.rs b/src/lib/login/key_encryption.rs
new file mode 100644
index 0000000..3841d50
--- /dev/null
+++ b/src/lib/login/key_encryption.rs
@@ -0,0 +1,105 @@
1use anyhow::Result;
2use nostr::{prelude::*, Keys};
3
4pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> {
5 let log2_rounds: u8 = if password.len() > 20 {
6 // we have enough of entropy - no need to spend CPU time adding much more
7 1
8 } else {
9 println!("this may take a few seconds...");
10 // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait
11 15
12 };
13 Ok(nostr::nips::nip49::EncryptedSecretKey::new(
14 keys.secret_key()?,
15 password,
16 log2_rounds,
17 KeySecurity::Medium,
18 )?
19 .to_bech32()?)
20}
21
22pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> {
23 let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?;
24 // to request that log_n gets exposed
25 if encrypted_key.log_n() > 14 {
26 println!("this may take a few seconds...");
27 }
28 Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?))
29}
30
31#[cfg(test)]
32mod tests {
33 use test_utils::*;
34
35 use super::*;
36
37 #[test]
38 fn encrypt_key_produces_string_prefixed_with() -> Result<()> {
39 let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?;
40 assert!(s.starts_with("ncryptsec"));
41 Ok(())
42 }
43
44 #[test]
45 // ensures password encryption hasn't changed
46 fn decrypts_with_strong_password_from_reference_string() -> Result<()> {
47 let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?;
48
49 assert_eq!(
50 format!(
51 "{}",
52 TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap()
53 ),
54 format!(
55 "{}",
56 decrypted_key.secret_key().unwrap().to_bech32().unwrap()
57 ),
58 );
59 Ok(())
60 }
61
62 #[test]
63 // ensures password encryption hasn't changed
64 fn decrypts_with_weak_password_from_reference_string() -> Result<()> {
65 let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?;
66
67 assert_eq!(
68 format!(
69 "{}",
70 TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap()
71 ),
72 format!(
73 "{}",
74 decrypted_key.secret_key().unwrap().to_bech32().unwrap()
75 ),
76 );
77 Ok(())
78 }
79
80 #[test]
81 fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> {
82 let key = nostr::Keys::generate();
83 let s = encrypt_key(&key, TEST_PASSWORD)?;
84 let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?;
85
86 assert_eq!(
87 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()),
88 format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()),
89 );
90 Ok(())
91 }
92
93 #[test]
94 fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> {
95 let key = nostr::Keys::generate();
96 let s = encrypt_key(&key, TEST_PASSWORD)?;
97 let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?;
98
99 assert_eq!(
100 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()),
101 format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()),
102 );
103 Ok(())
104 }
105}
diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs
new file mode 100644
index 0000000..19bb97c
--- /dev/null
+++ b/src/lib/login/mod.rs
@@ -0,0 +1,695 @@
1use std::{collections::HashSet, path::Path, str::FromStr, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use nostr::{
5 nips::{nip05, nip46::NostrConnectURI},
6 PublicKey,
7};
8use nostr_sdk::{
9 Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32,
10};
11use nostr_signer::Nip46Signer;
12
13#[cfg(not(test))]
14use crate::client::Client;
15#[cfg(test)]
16use crate::client::MockConnect;
17use crate::{
18 cli_interactor::{
19 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms,
20 },
21 client::{fetch_public_key, get_event_from_global_cache, Connect},
22 config::{UserMetadata, UserRef, UserRelayRef, UserRelays},
23 git::{Repo, RepoActions},
24 key_handling::encryption::{decrypt_key, encrypt_key},
25};
26
27/// handles the encrpytion and storage of key material
28#[allow(clippy::too_many_arguments)]
29pub async fn launch(
30 git_repo: &Repo,
31 bunker_uri: &Option<String>,
32 bunker_app_key: &Option<String>,
33 nsec: &Option<String>,
34 password: &Option<String>,
35 #[cfg(test)] client: Option<&MockConnect>,
36 #[cfg(not(test))] client: Option<&Client>,
37 change_user: bool,
38 silent: bool,
39) -> Result<(NostrSigner, UserRef)> {
40 if let Ok(signer) = match get_signer_without_prompts(
41 git_repo,
42 bunker_uri,
43 bunker_app_key,
44 nsec,
45 password,
46 change_user,
47 )
48 .await
49 {
50 Ok(signer) => Ok(signer),
51 Err(error) => {
52 if error
53 .to_string()
54 .eq("git config item nostr.nsec is an ncryptsec")
55 {
56 println!(
57 "login as {}",
58 if let Ok(public_key) = PublicKey::from_bech32(
59 get_config_item(git_repo, "nostr.npub")
60 .unwrap_or("unknown ncryptsec".to_string()),
61 ) {
62 if let Ok(user_ref) =
63 get_user_details(&public_key, client, git_repo.get_path()?, silent)
64 .await
65 {
66 user_ref.metadata.name
67 } else {
68 "unknown ncryptsec".to_string()
69 }
70 } else {
71 "unknown ncryptsec".to_string()
72 }
73 );
74 loop {
75 // prompt for password
76 let password = Interactor::default()
77 .password(PromptPasswordParms::default().with_prompt("password"))
78 .context("failed to get password input from interactor.password")?;
79 if let Ok(keys) = get_keys_with_password(git_repo, &password) {
80 break Ok(NostrSigner::Keys(keys));
81 }
82 println!("incorrect password");
83 }
84 } else {
85 if nsec.is_some() {
86 bail!(error);
87 }
88 Err(error)
89 }
90 }
91 } {
92 // get user ref
93 let user_ref = get_user_details(
94 &signer
95 .public_key()
96 .await
97 .context("cannot get public key from signer")?,
98 client,
99 git_repo.get_path()?,
100 silent,
101 )
102 .await?;
103 if !silent {
104 print_logged_in_as(&user_ref, client.is_none())?;
105 }
106 Ok((signer, user_ref))
107 } else if silent {
108 bail!("TODO: enable interactive login in nostr git remote helper");
109 } else {
110 fresh_login(git_repo, client, change_user).await
111 }
112}
113
114fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> {
115 if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) {
116 println!("cannot find profile...");
117 } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) {
118 println!("cannot extract account name from account metadata...");
119 } else if !offline_mode && user_ref.relays.created_at.eq(&Timestamp::from(0)) {
120 println!(
121 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
122 );
123 }
124 println!("logged in as {}", user_ref.metadata.name);
125 Ok(())
126}
127
128async fn get_signer_without_prompts(
129 git_repo: &Repo,
130 bunker_uri: &Option<String>,
131 bunker_app_key: &Option<String>,
132 nsec: &Option<String>,
133 password: &Option<String>,
134 save_local: bool,
135) -> Result<NostrSigner> {
136 if let Some(nsec) = nsec {
137 Ok(NostrSigner::Keys(get_keys_from_nsec(
138 git_repo, nsec, password, save_local,
139 )?))
140 } else if let Some(password) = password {
141 Ok(NostrSigner::Keys(get_keys_with_password(
142 git_repo, password,
143 )?))
144 } else if let Some(bunker_uri) = bunker_uri {
145 if let Some(bunker_app_key) = bunker_app_key {
146 let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key)
147 .await
148 .context("failed to connect with remote signer")?;
149 if save_local {
150 save_to_git_config(
151 git_repo,
152 &signer.public_key().await?.to_bech32()?,
153 &None,
154 &Some((bunker_uri.to_string(),bunker_app_key.to_string())),
155 false,
156 )
157 .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?;
158 }
159 Ok(signer)
160 } else {
161 bail!(
162 "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively."
163 )
164 }
165 } else if !save_local {
166 get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await
167 } else {
168 bail!("user wants prompts to specify new keys")
169 }
170}
171
172fn get_keys_from_nsec(
173 git_repo: &Repo,
174 nsec: &String,
175 password: &Option<String>,
176 save_local: bool,
177) -> Result<nostr::Keys> {
178 #[allow(unused_assignments)]
179 let mut s = String::new();
180 let keys = if nsec.contains("ncryptsec") {
181 s = nsec.to_string();
182 decrypt_key(
183 nsec,
184 password
185 .clone()
186 .context("password must be supplied when using ncryptsec as nsec parameter")?
187 .as_str(),
188 )
189 .context("failed to decrypt key with provided password")
190 .context("failed to decrypt ncryptsec supplied as nsec with password")?
191 } else {
192 s = nsec.to_string();
193 nostr::Keys::from_str(nsec).context("invalid nsec parameter")?
194 };
195 if save_local {
196 if let Some(password) = password {
197 s = encrypt_key(&keys, password)?;
198 }
199 save_to_git_config(
200 git_repo,
201 &keys.public_key().to_bech32()?,
202 &Some(s),
203 &None,
204 false,
205 )
206 .context("failed to save encrypted nsec in local git config nostr.nsec")?;
207 }
208 Ok(keys)
209}
210
211fn save_to_git_config(
212 git_repo: &Repo,
213 npub: &str,
214 nsec: &Option<String>,
215 bunker: &Option<(String, String)>,
216 global: bool,
217) -> Result<()> {
218 if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) {
219 println!(
220 "failed to save login details to {} git config",
221 if global { "global" } else { "local" }
222 );
223 if let Some(nsec) = nsec {
224 if nsec.contains("ncryptsec") {
225 println!("manually set git config nostr.nsec to: {nsec}");
226 } else {
227 println!("manually set git config nostr.nsec");
228 }
229 }
230 if let Some(bunker) = bunker {
231 println!("manually set git config as follows:");
232 println!("nostr.bunker-uri: {}", bunker.0);
233 println!("nostr.bunker-app-key: {}", bunker.1);
234 }
235 Err(error)
236 } else {
237 println!(
238 "saved login details to {} git config",
239 if global { "global" } else { "local" }
240 );
241 Ok(())
242 }
243}
244fn silently_save_to_git_config(
245 git_repo: &Repo,
246 npub: &str,
247 nsec: &Option<String>,
248 bunker: &Option<(String, String)>,
249 global: bool,
250) -> Result<()> {
251 // must do this first otherwise it might remove the global items just added
252 if global {
253 git_repo.remove_git_config_item("nostr.npub", false)?;
254 git_repo.remove_git_config_item("nostr.nsec", false)?;
255 git_repo.remove_git_config_item("nostr.bunker-uri", false)?;
256 git_repo.remove_git_config_item("nostr.bunker-app-key", false)?;
257 }
258 if let Some(bunker) = bunker {
259 git_repo.remove_git_config_item("nostr.nsec", global)?;
260 git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?;
261 git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?;
262 }
263 if let Some(nsec) = nsec {
264 git_repo.save_git_config_item("nostr.nsec", nsec, global)?;
265 git_repo.remove_git_config_item("nostr.bunker-uri", global)?;
266 git_repo.remove_git_config_item("nostr.bunker-app-key", global)?;
267 }
268 git_repo.save_git_config_item("nostr.npub", npub, global)
269}
270
271fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> {
272 decrypt_key(
273 &git_repo
274 .get_git_config_item("nostr.nsec", None)
275 .context("failed get git config")?
276 .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?,
277 password,
278 )
279 .context("failed to decrypt stored nsec key with provided password")
280}
281
282async fn get_nip46_signer_from_uri_and_key(uri: &str, app_key: &str) -> Result<NostrSigner> {
283 let term = console::Term::stderr();
284 term.write_line("connecting to remote signer...")?;
285 let uri = NostrConnectURI::parse(uri)?;
286 let signer = NostrSigner::nip46(
287 Nip46Signer::new(
288 uri,
289 nostr::Keys::from_str(app_key).context("invalid app key")?,
290 Duration::from_secs(30),
291 None,
292 )
293 .await?,
294 );
295 term.clear_last_lines(1)?;
296 Ok(signer)
297}
298
299async fn get_signer_with_git_config_nsec_or_bunker_without_prompts(
300 git_repo: &Repo,
301) -> Result<NostrSigner> {
302 if let Ok(local_nsec) = &git_repo
303 .get_git_config_item("nostr.nsec", Some(false))
304 .context("failed get local git config")?
305 .context("git local config item nostr.nsec doesn't exist")
306 {
307 if local_nsec.contains("ncryptsec") {
308 bail!("git global config item nostr.nsec is an ncryptsec")
309 }
310 Ok(NostrSigner::Keys(
311 nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?,
312 ))
313 } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(false))
314 {
315 get_nip46_signer_from_uri_and_key(&uri, &app_key).await
316 } else if let Ok(global_nsec) = &git_repo
317 .get_git_config_item("nostr.nsec", Some(true))
318 .context("failed get global git config")?
319 .context("git global config item nostr.nsec doesn't exist")
320 {
321 if global_nsec.contains("ncryptsec") {
322 bail!("git global config item nostr.nsec is an ncryptsec")
323 }
324 Ok(NostrSigner::Keys(
325 nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?,
326 ))
327 } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) {
328 get_nip46_signer_from_uri_and_key(&uri, &app_key).await
329 } else {
330 bail!("cannot get nsec or bunker from git config")
331 }
332}
333
334fn get_git_config_bunker_uri_and_app_key(
335 git_repo: &Repo,
336 global: Option<bool>,
337) -> Result<(String, String)> {
338 Ok((
339 git_repo
340 .get_git_config_item("nostr.bunker-uri", global)
341 .context("failed get local git config")?
342 .context("git local config item nostr.bunker-uri doesn't exist")?
343 .to_string(),
344 git_repo
345 .get_git_config_item("nostr.bunker-app-key", global)
346 .context("failed get local git config")?
347 .context("git local config item nostr.bunker-app-key doesn't exist")?
348 .to_string(),
349 ))
350}
351
352async fn fresh_login(
353 git_repo: &Repo,
354 #[cfg(test)] client: Option<&MockConnect>,
355 #[cfg(not(test))] client: Option<&Client>,
356 always_save: bool,
357) -> Result<(NostrSigner, UserRef)> {
358 let mut public_key: Option<PublicKey> = None;
359 // prompt for nsec
360 let mut prompt = "login with nostr address / nsec";
361 let signer = loop {
362 let input = Interactor::default()
363 .input(PromptInputParms::default().with_prompt(prompt))
364 .context("failed to get nsec input from interactor")?;
365 if let Ok(keys) = nostr::Keys::from_str(&input) {
366 if let Err(error) = save_keys(git_repo, &keys, always_save) {
367 println!("{error}");
368 }
369 break NostrSigner::Keys(keys);
370 }
371 let uri = if let Ok(uri) = NostrConnectURI::parse(&input) {
372 uri
373 } else if input.contains('@') {
374 if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await {
375 uri
376 } else {
377 prompt = "failed. try again with nostr address / bunker uri / nsec";
378 continue;
379 }
380 } else {
381 prompt = "invalid. try again with nostr address / bunker uri / nsec";
382 continue;
383 };
384 let app_key = Keys::generate().secret_key()?.to_secret_hex();
385 match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key).await {
386 Ok(signer) => {
387 let pub_key = fetch_public_key(&signer).await?;
388 if let Err(error) =
389 save_bunker(git_repo, &pub_key, &uri.to_string(), &app_key, always_save)
390 {
391 println!("{error}");
392 }
393 public_key = Some(pub_key);
394 break signer;
395 }
396 Err(_) => {
397 prompt = "failed. try again with nostr address / bunker uri / nsec";
398 }
399 }
400 };
401 let public_key = if let Some(public_key) = public_key {
402 public_key
403 } else {
404 signer.public_key().await?
405 };
406 // lookup profile
407 let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?;
408 print_logged_in_as(&user_ref, client.is_none())?;
409 Ok((signer, user_ref))
410}
411
412pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> {
413 let term = console::Term::stderr();
414 term.write_line("contacting login service provider...")?;
415 let res = nip05::profile(&nip05, None).await;
416 term.clear_last_lines(1)?;
417 match res {
418 Ok(profile) => {
419 if profile.nip46.is_empty() {
420 println!("nip05 provider isn't configured for remote login");
421 bail!("nip05 provider isn't configured for remote login")
422 }
423 Ok(NostrConnectURI::Bunker {
424 signer_public_key: profile.public_key,
425 relays: profile.nip46,
426 secret: None,
427 })
428 }
429 Err(error) => {
430 println!("error contacting login service provider: {error}");
431 Err(error).context("error contacting login service provider")
432 }
433 }
434}
435
436fn save_bunker(
437 git_repo: &Repo,
438 public_key: &PublicKey,
439 uri: &str,
440 app_key: &str,
441 always_save: bool,
442) -> Result<()> {
443 if always_save
444 || Interactor::default()
445 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?
446 {
447 let global = !Interactor::default().confirm(
448 PromptConfirmParms::default()
449 .with_prompt("just for this repository?")
450 .with_default(false),
451 )?;
452 let npub = public_key.to_bech32()?;
453 if let Err(error) = save_to_git_config(
454 git_repo,
455 &npub,
456 &None,
457 &Some((uri.to_string(), app_key.to_string())),
458 global,
459 ) {
460 if global {
461 if Interactor::default().confirm(
462 PromptConfirmParms::default()
463 .with_prompt("save in repository git config?")
464 .with_default(true),
465 )? {
466 save_to_git_config(
467 git_repo,
468 &npub,
469 &None,
470 &Some((uri.to_string(), app_key.to_string())),
471 false,
472 )?;
473 }
474 } else {
475 Err(error)?;
476 }
477 };
478 }
479 Ok(())
480}
481
482fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> {
483 if always_save
484 || Interactor::default()
485 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?
486 {
487 let global = !Interactor::default().confirm(
488 PromptConfirmParms::default()
489 .with_prompt("just for this repository?")
490 .with_default(false),
491 )?;
492
493 let encrypt = Interactor::default().confirm(
494 PromptConfirmParms::default()
495 .with_prompt("require password?")
496 .with_default(false),
497 )?;
498
499 let npub = keys.public_key().to_bech32()?;
500 let nsec_string = if encrypt {
501 let password = Interactor::default()
502 .password(
503 PromptPasswordParms::default()
504 .with_prompt("encrypt with password")
505 .with_confirm(),
506 )
507 .context("failed to get password input from interactor.password")?;
508 encrypt_key(keys, &password)?
509 } else {
510 keys.secret_key()?.to_bech32()?
511 };
512
513 if let Err(error) =
514 save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global)
515 {
516 if global {
517 if Interactor::default().confirm(
518 PromptConfirmParms::default()
519 .with_prompt("save in repository git config?")
520 .with_default(true),
521 )? {
522 save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?;
523 }
524 } else {
525 Err(error)?;
526 }
527 };
528 };
529 Ok(())
530}
531
532fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> {
533 git_repo
534 .get_git_config_item(name, None)
535 .context("failed get git config")?
536 .context(format!("git config item {name} doesn't exist"))
537}
538
539fn extract_user_metadata(
540 public_key: &nostr::PublicKey,
541 events: &[nostr::Event],
542) -> Result<UserMetadata> {
543 let event = events
544 .iter()
545 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
546 .max_by_key(|e| e.created_at);
547
548 let metadata: Option<nostr::Metadata> = if let Some(event) = event {
549 Some(
550 nostr::Metadata::from_json(event.content.clone())
551 .context("metadata cannot be found in kind 0 event content")?,
552 )
553 } else {
554 None
555 };
556
557 Ok(UserMetadata {
558 name: if let Some(metadata) = metadata {
559 if let Some(n) = metadata.name {
560 n
561 } else if let Some(n) = metadata.custom.get("displayName") {
562 // strip quote marks that custom.get() adds
563 let binding = n.to_string();
564 let mut chars = binding.chars();
565 chars.next();
566 chars.next_back();
567 chars.as_str().to_string()
568 } else if let Some(n) = metadata.display_name {
569 n
570 } else {
571 public_key.to_bech32()?
572 }
573 } else {
574 public_key.to_bech32()?
575 },
576 created_at: if let Some(event) = event {
577 event.created_at
578 } else {
579 Timestamp::from(0)
580 },
581 })
582}
583
584fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays {
585 let event = events
586 .iter()
587 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
588 .max_by_key(|e| e.created_at);
589
590 UserRelays {
591 relays: if let Some(event) = event {
592 event
593 .tags
594 .iter()
595 .filter(|t| {
596 t.kind()
597 .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(
598 Alphabet::R,
599 )))
600 })
601 .map(|t| UserRelayRef {
602 url: t.as_vec()[1].clone(),
603 read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"),
604 write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"),
605 })
606 .collect()
607 } else {
608 vec![]
609 },
610 created_at: if let Some(event) = event {
611 event.created_at
612 } else {
613 Timestamp::from(0)
614 },
615 }
616}
617
618async fn get_user_details(
619 public_key: &PublicKey,
620 #[cfg(test)] client: Option<&crate::client::MockConnect>,
621 #[cfg(not(test))] client: Option<&Client>,
622 git_repo_path: &Path,
623 cache_only: bool,
624) -> Result<UserRef> {
625 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
626 Ok(user_ref)
627 } else {
628 let empty = UserRef {
629 public_key: public_key.to_owned(),
630 metadata: extract_user_metadata(public_key, &[])?,
631 relays: extract_user_relays(public_key, &[]),
632 };
633 if cache_only {
634 Ok(empty)
635 } else if let Some(client) = client {
636 let term = console::Term::stderr();
637 term.write_line("searching for profile...")?;
638 let (_, progress_reporter) = client
639 .fetch_all(
640 git_repo_path,
641 &HashSet::new(),
642 &HashSet::from_iter(vec![*public_key]),
643 )
644 .await?;
645 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
646 progress_reporter.clear()?;
647 // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;}
648 Ok(user_ref)
649 } else {
650 Ok(empty)
651 }
652 } else {
653 Ok(empty)
654 }
655 }
656}
657pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> {
658 let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?;
659 Ok(
660 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
661 if let Ok(pubic_key) = PublicKey::parse(npub) {
662 Some(pubic_key)
663 } else {
664 None
665 }
666 } else {
667 None
668 },
669 )
670}
671
672pub async fn get_user_ref_from_cache(
673 git_repo_path: &Path,
674 public_key: &PublicKey,
675) -> Result<UserRef> {
676 let filters = vec![
677 nostr::Filter::default()
678 .author(*public_key)
679 .kind(Kind::Metadata),
680 nostr::Filter::default()
681 .author(*public_key)
682 .kind(Kind::RelayList),
683 ];
684
685 let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?;
686
687 if events.is_empty() {
688 bail!("no metadata and profile list in cache for selected public key");
689 }
690 Ok(UserRef {
691 public_key: public_key.to_owned(),
692 metadata: extract_user_metadata(public_key, &events)?,
693 relays: extract_user_relays(public_key, &events),
694 })
695}
diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs
new file mode 100644
index 0000000..547fe7e
--- /dev/null
+++ b/src/lib/login/user.rs
@@ -0,0 +1,47 @@
1use anyhow::{anyhow, Result};
2use directories::ProjectDirs;
3use nostr::PublicKey;
4use nostr_sdk::Timestamp;
5use serde::{self, Deserialize, Serialize};
6
7pub fn get_dirs() -> Result<ProjectDirs> {
8 ProjectDirs::from("", "", "ngit").ok_or(anyhow!(
9 "should find operating system home directories with rust-directories crate"
10 ))
11}
12
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
14pub struct UserRef {
15 pub public_key: PublicKey,
16 pub metadata: UserMetadata,
17 pub relays: UserRelays,
18}
19
20#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
21pub struct UserMetadata {
22 pub name: String,
23 pub created_at: Timestamp,
24}
25
26#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
27pub struct UserRelays {
28 pub relays: Vec<UserRelayRef>,
29 pub created_at: Timestamp,
30}
31
32impl UserRelays {
33 pub fn write(&self) -> Vec<String> {
34 self.relays
35 .iter()
36 .filter(|r| r.write)
37 .map(|r| r.url.clone())
38 .collect()
39 }
40}
41
42#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
43pub struct UserRelayRef {
44 pub url: String,
45 pub read: bool,
46 pub write: bool,
47}
diff --git a/src/lib/mod.rs b/src/lib/mod.rs
new file mode 100644
index 0000000..61dfc49
--- /dev/null
+++ b/src/lib/mod.rs
@@ -0,0 +1,16 @@
1mod cli_interactor;
2mod client;
3mod config;
4mod git;
5mod key_handling;
6mod login;
7mod repo_ref;
8mod repo_state;
9
10pub use client;
11pub use config;
12pub use git;
13pub use key_handling;
14pub use login;
15pub use repo_ref;
16pub use repo_state;
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs
new file mode 100644
index 0000000..0e57d96
--- /dev/null
+++ b/src/lib/repo_ref.rs
@@ -0,0 +1,700 @@
1use std::{
2 collections::{HashMap, HashSet},
3 fs::File,
4 io::BufReader,
5 str::FromStr,
6};
7
8use anyhow::{bail, Context, Result};
9use console::Style;
10use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, Tag, TagStandard, ToBech32};
11use nostr_sdk::{Kind, NostrSigner, Timestamp};
12use serde::{Deserialize, Serialize};
13
14#[cfg(not(test))]
15use crate::client::Client;
16use crate::{
17 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
18 client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect},
19 git::{NostrUrlDecoded, Repo, RepoActions},
20};
21
22#[derive(Default)]
23pub struct RepoRef {
24 pub name: String,
25 pub description: String,
26 pub identifier: String,
27 pub root_commit: String,
28 pub git_server: Vec<String>,
29 pub web: Vec<String>,
30 pub relays: Vec<String>,
31 pub maintainers: Vec<PublicKey>,
32 pub events: HashMap<Coordinate, nostr::Event>,
33 // code languages and hashtags
34}
35
36impl TryFrom<nostr::Event> for RepoRef {
37 type Error = anyhow::Error;
38
39 fn try_from(event: nostr::Event) -> Result<Self> {
40 if !event.kind.eq(&Kind::GitRepoAnnouncement) {
41 bail!("incorrect kind");
42 }
43 let mut r = Self::default();
44
45 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("d")) {
46 r.identifier = t.as_vec()[1].clone();
47 }
48
49 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("name")) {
50 r.name = t.as_vec()[1].clone();
51 }
52
53 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("description")) {
54 r.description = t.as_vec()[1].clone();
55 }
56
57 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("clone")) {
58 r.git_server = t.clone().to_vec();
59 r.git_server.remove(0);
60 }
61
62 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("web")) {
63 r.web = t.clone().to_vec();
64 r.web.remove(0);
65 }
66
67 if let Some(t) = event.tags.iter().find(|t| {
68 t.as_vec()[0].eq("r")
69 && t.as_vec()[1].len().eq(&40)
70 && git2::Oid::from_str(t.as_vec()[1].as_str()).is_ok()
71 }) {
72 r.root_commit = t.as_vec()[1].clone();
73 }
74
75 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("relays")) {
76 r.relays = t.clone().to_vec();
77 r.relays.remove(0);
78 }
79
80 if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("maintainers")) {
81 let mut maintainers = t.clone().to_vec();
82 maintainers.remove(0);
83 if !maintainers.contains(&event.pubkey.to_string()) {
84 r.maintainers.push(event.pubkey);
85 }
86 for pk in maintainers {
87 r.maintainers.push(
88 nostr_sdk::prelude::PublicKey::from_str(&pk)
89 .context(format!("cannot convert entry from maintainers tag {pk} into a valid nostr public key. it should be in hex format"))
90 .context("invalid repository event")?,
91 );
92 }
93 } else {
94 r.maintainers = vec![event.pubkey];
95 }
96 r.events = HashMap::new();
97 r.events.insert(
98 Coordinate {
99 kind: event.kind,
100 identifier: event.identifier().unwrap().to_string(),
101 public_key: event.author(),
102 relays: vec![],
103 },
104 event,
105 );
106 Ok(r)
107 }
108}
109
110impl RepoRef {
111 pub async fn to_event(&self, signer: &NostrSigner) -> Result<nostr::Event> {
112 sign_event(
113 nostr_sdk::EventBuilder::new(
114 nostr::event::Kind::GitRepoAnnouncement,
115 "",
116 [
117 vec![
118 Tag::identifier(if self.identifier.to_string().is_empty() {
119 // fiatjaf thought a random string. its not in the draft nip.
120 // thread_rng()
121 // .sample_iter(&Alphanumeric)
122 // .take(15)
123 // .map(char::from)
124 // .collect()
125
126 // an identifier based on first commit is better so that users dont
127 // accidentally create two seperate identifiers for the same repo
128 // there is a hesitancy to use the commit id
129 // in another conversaion with fiatjaf he suggested the first 6
130 // character of the commit id
131 // here we are using 7 which is the standard for shorthand commit id
132 self.root_commit.to_string()[..7].to_string()
133 } else {
134 self.identifier.to_string()
135 }),
136 Tag::custom(
137 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("r")),
138 vec![self.root_commit.to_string(), "euc".to_string()],
139 ),
140 Tag::from_standardized(TagStandard::Name(self.name.clone())),
141 Tag::from_standardized(TagStandard::Description(self.description.clone())),
142 Tag::custom(
143 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")),
144 self.git_server.clone(),
145 ),
146 Tag::custom(
147 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")),
148 self.web.clone(),
149 ),
150 Tag::custom(
151 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")),
152 self.relays.clone(),
153 ),
154 Tag::custom(
155 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")),
156 self.maintainers
157 .iter()
158 .map(std::string::ToString::to_string)
159 .collect::<Vec<String>>(),
160 ),
161 Tag::custom(
162 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
163 vec![format!("git repository: {}", self.name.clone())],
164 ),
165 ],
166 // code languages and hashtags
167 ]
168 .concat(),
169 ),
170 signer,
171 )
172 .await
173 .context("failed to create repository reference event")
174 }
175 /// coordinates without relay hints
176 pub fn coordinates(&self) -> HashSet<Coordinate> {
177 let mut res = HashSet::new();
178 for m in &self.maintainers {
179 res.insert(Coordinate {
180 kind: Kind::GitRepoAnnouncement,
181 public_key: *m,
182 identifier: self.identifier.clone(),
183 relays: vec![],
184 });
185 }
186 res
187 }
188
189 /// coordinates without relay hints
190 pub fn coordinate_with_hint(&self) -> Coordinate {
191 Coordinate {
192 kind: Kind::GitRepoAnnouncement,
193 public_key: *self
194 .maintainers
195 .first()
196 .context("no maintainers in repo ref")
197 .unwrap(),
198 identifier: self.identifier.clone(),
199 relays: if let Some(relay) = self.relays.first() {
200 vec![relay.to_string()]
201 } else {
202 vec![]
203 },
204 }
205 }
206
207 /// coordinates without relay hints
208 pub fn coordinates_with_timestamps(&self) -> Vec<(Coordinate, Option<Timestamp>)> {
209 self.coordinates()
210 .iter()
211 .map(|c| (c.clone(), self.events.get(c).map(|e| e.created_at)))
212 .collect::<Vec<(Coordinate, Option<Timestamp>)>>()
213 }
214}
215
216pub async fn get_repo_coordinates(
217 git_repo: &Repo,
218 #[cfg(test)] client: &crate::client::MockConnect,
219 #[cfg(not(test))] client: &Client,
220) -> Result<HashSet<Coordinate>> {
221 try_and_get_repo_coordinates(git_repo, client, true).await
222}
223
224pub async fn try_and_get_repo_coordinates(
225 git_repo: &Repo,
226 #[cfg(test)] client: &crate::client::MockConnect,
227 #[cfg(not(test))] client: &Client,
228 prompt_user: bool,
229) -> Result<HashSet<Coordinate>> {
230 let mut repo_coordinates = get_repo_coordinates_from_git_config(git_repo)?;
231
232 if repo_coordinates.is_empty() {
233 repo_coordinates = get_repo_coordinates_from_nostr_remotes(git_repo)?;
234 }
235
236 if repo_coordinates.is_empty() {
237 repo_coordinates = get_repo_coordinates_from_maintainers_yaml(git_repo, client).await?;
238 }
239
240 if repo_coordinates.is_empty() {
241 if prompt_user {
242 repo_coordinates = get_repo_coordinates_from_user_prompt(git_repo)?;
243 } else {
244 bail!("couldn't find repo coordinates in git config nostr.repo or in maintainers.yaml");
245 }
246 }
247 Ok(repo_coordinates)
248}
249
250fn get_repo_coordinates_from_git_config(git_repo: &Repo) -> Result<HashSet<Coordinate>> {
251 let mut repo_coordinates = HashSet::new();
252 if let Some(repo_override) = git_repo.get_git_config_item("nostr.repo", Some(false))? {
253 for s in repo_override.split(',') {
254 if let Ok(c) = Coordinate::parse(s) {
255 repo_coordinates.insert(c);
256 }
257 }
258 }
259 Ok(repo_coordinates)
260}
261
262fn get_repo_coordinates_from_nostr_remotes(git_repo: &Repo) -> Result<HashSet<Coordinate>> {
263 let mut repo_coordinates = HashSet::new();
264 for remote_name in git_repo.git_repo.remotes()?.iter().flatten() {
265 if let Some(remote_url) = git_repo.git_repo.find_remote(remote_name)?.url() {
266 if let Ok(nostr_url_decoded) = NostrUrlDecoded::from_str(remote_url) {
267 for c in nostr_url_decoded.coordinates {
268 repo_coordinates.insert(c);
269 }
270 }
271 }
272 }
273 Ok(repo_coordinates)
274}
275
276async fn get_repo_coordinates_from_maintainers_yaml(
277 git_repo: &Repo,
278 #[cfg(test)] client: &crate::client::MockConnect,
279 #[cfg(not(test))] client: &Client,
280) -> Result<HashSet<Coordinate>> {
281 let mut repo_coordinates = HashSet::new();
282 if let Ok(repo_config) = get_repo_config_from_yaml(git_repo) {
283 let maintainers = {
284 let mut maintainers = HashSet::new();
285 for m in &repo_config.maintainers {
286 if let Ok(maintainer) = PublicKey::parse(m) {
287 maintainers.insert(maintainer);
288 }
289 }
290 maintainers
291 };
292 if let Some(identifier) = repo_config.identifier {
293 for public_key in maintainers {
294 repo_coordinates.insert(Coordinate {
295 kind: Kind::GitRepoAnnouncement,
296 public_key,
297 identifier: identifier.clone(),
298 relays: vec![],
299 });
300 }
301 } else {
302 // if repo_config.identifier.is_empty() {
303 // this will only apply for a few repositories created before ngit v1.3
304 // that haven't updated their maintainers.yaml
305 if let Ok(Some(current_user_npub)) = git_repo.get_git_config_item("nostr.npub", None) {
306 if let Ok(current_user) = PublicKey::parse(current_user_npub) {
307 for m in &repo_config.maintainers {
308 if let Ok(maintainer) = PublicKey::parse(m) {
309 if current_user.eq(&maintainer) {
310 println!(
311 "please run `ngit init` to add the repo identifier to maintainers.yaml"
312 );
313 }
314 }
315 }
316 }
317 }
318 // look find all repo refs with root_commit. for identifier
319 let filter = nostr::Filter::default()
320 .kind(nostr::Kind::GitRepoAnnouncement)
321 .reference(git_repo.get_root_commit()?.to_string())
322 .authors(maintainers.clone());
323 let mut events =
324 get_events_from_cache(git_repo.get_path()?, vec![filter.clone()]).await?;
325 if events.is_empty() {
326 events =
327 get_event_from_global_cache(git_repo.get_path()?, vec![filter.clone()]).await?;
328 }
329 if events.is_empty() {
330 println!(
331 "finding repository events for this repository for npubs in maintainers.yaml"
332 );
333 events = client
334 .get_events(client.get_fallback_relays().clone(), vec![filter.clone()])
335 .await?;
336 }
337 if let Some(e) = events.first() {
338 if let Some(identifier) = e.identifier() {
339 for m in &repo_config.maintainers {
340 if let Ok(maintainer) = PublicKey::parse(m) {
341 repo_coordinates.insert(Coordinate {
342 kind: Kind::GitRepoAnnouncement,
343 public_key: maintainer,
344 identifier: identifier.to_string(),
345 relays: vec![],
346 });
347 }
348 }
349 }
350 } else {
351 let c = ask_for_naddr()?;
352 git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?;
353 repo_coordinates.insert(c);
354 }
355 }
356 }
357 Ok(repo_coordinates)
358}
359
360fn get_repo_coordinates_from_user_prompt(git_repo: &Repo) -> Result<HashSet<Coordinate>> {
361 let mut repo_coordinates = HashSet::new();
362 // TODO: present list of events filter by root_commit
363 // TODO: fallback to search based on identifier
364 let c = ask_for_naddr()?;
365 // PROBLEM: we are saving this before checking whether it actually exists, which
366 // means next time the user won't be prompted and may not know how to
367 // change the selected repo
368 git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?;
369 repo_coordinates.insert(c);
370 Ok(repo_coordinates)
371}
372
373fn ask_for_naddr() -> Result<Coordinate> {
374 let dim = Style::new().color256(247);
375 println!(
376 "{}",
377 dim.apply_to("hint: https://gitworkshop.dev/repos lists repositories and their naddr"),
378 );
379
380 Ok(loop {
381 if let Ok(c) = Coordinate::parse(
382 Interactor::default()
383 .input(PromptInputParms::default().with_prompt("repository naddr"))?,
384 ) {
385 break c;
386 }
387 println!("not a valid naddr");
388 })
389}
390
391#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
392pub struct RepoConfigYaml {
393 pub identifier: Option<String>,
394 pub maintainers: Vec<String>,
395 pub relays: Vec<String>,
396}
397
398pub fn get_repo_config_from_yaml(git_repo: &Repo) -> Result<RepoConfigYaml> {
399 let path = git_repo.get_path()?.join("maintainers.yaml");
400 let file = File::open(path)
401 .context("should open maintainers.yaml if it exists")
402 .context("maintainers.yaml doesnt exist")?;
403 let reader = BufReader::new(file);
404 let repo_config_yaml: RepoConfigYaml = serde_yaml::from_reader(reader)
405 .context("should read maintainers.yaml with serde_yaml")
406 .context("maintainers.yaml incorrectly formatted")?;
407 Ok(repo_config_yaml)
408}
409
410pub fn extract_pks(pk_strings: Vec<String>) -> Result<Vec<PublicKey>> {
411 let mut pks: Vec<PublicKey> = vec![];
412 for s in pk_strings {
413 pks.push(
414 nostr_sdk::prelude::PublicKey::from_bech32(s.clone())
415 .context(format!("cannot convert {s} into a valid nostr public key"))?,
416 );
417 }
418 Ok(pks)
419}
420
421pub fn save_repo_config_to_yaml(
422 git_repo: &Repo,
423 identifier: String,
424 maintainers: Vec<PublicKey>,
425 relays: Vec<String>,
426) -> Result<()> {
427 let path = git_repo.get_path()?.join("maintainers.yaml");
428 let file = if path.exists() {
429 std::fs::OpenOptions::new()
430 .create(true)
431 .write(true)
432 .truncate(true)
433 .open(path)
434 .context("cannot open maintainers.yaml file with write and truncate options")?
435 } else {
436 std::fs::File::create(path).context("cannot create maintainers.yaml file")?
437 };
438 let mut maintainers_npubs = vec![];
439 for m in maintainers {
440 maintainers_npubs.push(
441 m.to_bech32()
442 .context("cannot convert public key into npub")?,
443 );
444 }
445 serde_yaml::to_writer(
446 file,
447 &RepoConfigYaml {
448 identifier: Some(identifier),
449 maintainers: maintainers_npubs,
450 relays,
451 },
452 )
453 .context("cannot write maintainers to maintainers.yaml file serde_yaml")
454}
455
456#[cfg(test)]
457mod tests {
458 use test_utils::*;
459
460 use super::*;
461
462 async fn create() -> nostr::Event {
463 RepoRef {
464 identifier: "123412341".to_string(),
465 name: "test name".to_string(),
466 description: "test description".to_string(),
467 root_commit: "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2".to_string(),
468 git_server: vec!["https://localhost:1000".to_string()],
469 web: vec![
470 "https://exampleproject.xyz".to_string(),
471 "https://gitworkshop.dev/123".to_string(),
472 ],
473 relays: vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()],
474 maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()],
475 events: HashMap::new(),
476 }
477 .to_event(&TEST_KEY_1_SIGNER)
478 .await
479 .unwrap()
480 }
481 mod try_from {
482 use super::*;
483
484 #[tokio::test]
485 async fn identifier() {
486 assert_eq!(
487 RepoRef::try_from(create().await).unwrap().identifier,
488 "123412341",
489 )
490 }
491
492 #[tokio::test]
493 async fn name() {
494 assert_eq!(RepoRef::try_from(create().await).unwrap().name, "test name",)
495 }
496
497 #[tokio::test]
498 async fn description() {
499 assert_eq!(
500 RepoRef::try_from(create().await).unwrap().description,
501 "test description",
502 )
503 }
504
505 #[tokio::test]
506 async fn root_commit_is_r_tag() {
507 assert_eq!(
508 RepoRef::try_from(create().await).unwrap().root_commit,
509 "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2",
510 )
511 }
512
513 mod root_commit_is_empty_if_no_r_tag_which_is_sha1_format {
514 use nostr::JsonUtil;
515
516 use super::*;
517 async fn create_with_incorrect_first_commit_ref(s: &str) -> nostr::Event {
518 nostr::Event::from_json(
519 create()
520 .await
521 .as_json()
522 .replace("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", s),
523 )
524 .unwrap()
525 }
526
527 #[tokio::test]
528 async fn less_than_40_characters() {
529 let s = "5e664e5a7845cd1373";
530 assert_eq!(
531 RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await)
532 .unwrap()
533 .root_commit,
534 "",
535 )
536 }
537
538 #[tokio::test]
539 async fn more_than_40_characters() {
540 let s = "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2111111111";
541 assert_eq!(
542 RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await)
543 .unwrap()
544 .root_commit,
545 "",
546 )
547 }
548
549 #[tokio::test]
550 async fn not_hex_characters() {
551 let s = "xxx64e5a7845cd1373c79f580ca4fe29ab5b34d2";
552 assert_eq!(
553 RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await)
554 .unwrap()
555 .root_commit,
556 "",
557 )
558 }
559 }
560
561 #[tokio::test]
562 async fn git_server() {
563 assert_eq!(
564 RepoRef::try_from(create().await).unwrap().git_server,
565 vec!["https://localhost:1000"],
566 )
567 }
568
569 #[tokio::test]
570 async fn web() {
571 assert_eq!(
572 RepoRef::try_from(create().await).unwrap().web,
573 vec![
574 "https://exampleproject.xyz".to_string(),
575 "https://gitworkshop.dev/123".to_string()
576 ],
577 )
578 }
579
580 #[tokio::test]
581 async fn relays() {
582 assert_eq!(
583 RepoRef::try_from(create().await).unwrap().relays,
584 vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()],
585 )
586 }
587
588 #[tokio::test]
589 async fn maintainers() {
590 assert_eq!(
591 RepoRef::try_from(create().await).unwrap().maintainers,
592 vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()],
593 )
594 }
595 }
596
597 mod to_event {
598 use super::*;
599 mod tags {
600 use super::*;
601
602 #[tokio::test]
603 async fn identifier() {
604 assert!(
605 create()
606 .await
607 .tags
608 .iter()
609 .any(|t| t.as_vec()[0].eq("d") && t.as_vec()[1].eq("123412341"))
610 )
611 }
612
613 #[tokio::test]
614 async fn name() {
615 assert!(
616 create()
617 .await
618 .tags
619 .iter()
620 .any(|t| t.as_vec()[0].eq("name") && t.as_vec()[1].eq("test name"))
621 )
622 }
623
624 #[tokio::test]
625 async fn alt() {
626 assert!(
627 create().await.tags.iter().any(|t| t.as_vec()[0].eq("alt")
628 && t.as_vec()[1].eq("git repository: test name"))
629 )
630 }
631
632 #[tokio::test]
633 async fn description() {
634 assert!(create().await.tags.iter().any(
635 |t| t.as_vec()[0].eq("description") && t.as_vec()[1].eq("test description")
636 ))
637 }
638
639 #[tokio::test]
640 async fn root_commit_as_reference() {
641 assert!(create().await.tags.iter().any(|t| t.as_vec()[0].eq("r")
642 && t.as_vec()[1].eq("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2")))
643 }
644
645 #[tokio::test]
646 async fn git_server() {
647 assert!(create().await.tags.iter().any(
648 |t| t.as_vec()[0].eq("clone") && t.as_vec()[1].eq("https://localhost:1000")
649 ))
650 }
651
652 #[tokio::test]
653 async fn relays() {
654 let event = create().await;
655 let relays_tag: &nostr::Tag = event
656 .tags
657 .iter()
658 .find(|t| t.as_vec()[0].eq("relays"))
659 .unwrap();
660 assert_eq!(relays_tag.as_vec().len(), 3);
661 assert_eq!(relays_tag.as_vec()[1], "ws://relay1.io");
662 assert_eq!(relays_tag.as_vec()[2], "ws://relay2.io");
663 }
664
665 #[tokio::test]
666 async fn web() {
667 let event = create().await;
668 let web_tag: &nostr::Tag =
669 event.tags.iter().find(|t| t.as_vec()[0].eq("web")).unwrap();
670 assert_eq!(web_tag.as_vec().len(), 3);
671 assert_eq!(web_tag.as_vec()[1], "https://exampleproject.xyz");
672 assert_eq!(web_tag.as_vec()[2], "https://gitworkshop.dev/123");
673 }
674
675 #[tokio::test]
676 async fn maintainers() {
677 let event = create().await;
678 let maintainers_tag: &nostr::Tag = event
679 .tags
680 .iter()
681 .find(|t| t.as_vec()[0].eq("maintainers"))
682 .unwrap();
683 assert_eq!(maintainers_tag.as_vec().len(), 3);
684 assert_eq!(
685 maintainers_tag.as_vec()[1],
686 TEST_KEY_1_KEYS.public_key().to_string()
687 );
688 assert_eq!(
689 maintainers_tag.as_vec()[2],
690 TEST_KEY_2_KEYS.public_key().to_string()
691 );
692 }
693
694 #[tokio::test]
695 async fn no_other_tags() {
696 assert_eq!(create().await.tags.len(), 9)
697 }
698 }
699 }
700}
diff --git a/src/lib/repo_state.rs b/src/lib/repo_state.rs
new file mode 100644
index 0000000..a5cebab
--- /dev/null
+++ b/src/lib/repo_state.rs
@@ -0,0 +1,40 @@
1use std::collections::HashMap;
2
3use anyhow::{Context, Result};
4use git2::Oid;
5
6pub struct RepoState {
7 pub identifier: String,
8 pub state: HashMap<String, String>,
9 pub event: nostr::Event,
10}
11
12impl RepoState {
13 pub fn try_from(mut state_events: Vec<nostr::Event>) -> Result<Self> {
14 state_events.sort_by_key(|e| e.created_at);
15 let event = state_events.first().context("no state events")?;
16 let mut state = HashMap::new();
17 for tag in &event.tags {
18 if let Some(name) = tag.as_vec().first() {
19 if ["refs/heads/", "refs/tags", "HEAD"]
20 .iter()
21 .any(|s| name.starts_with(*s))
22 {
23 if let Some(value) = tag.as_vec().get(1) {
24 if Oid::from_str(value).is_ok() || value.contains("ref: refs/") {
25 state.insert(name.to_owned(), value.to_owned());
26 }
27 }
28 }
29 }
30 }
31 Ok(RepoState {
32 identifier: event
33 .identifier()
34 .context("existing event must have an identifier")?
35 .to_string(),
36 state,
37 event: event.clone(),
38 })
39 }
40}