upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/ngit/sub_commands/checkout.rs15
-rw-r--r--src/bin/ngit/sub_commands/list.rs19
-rw-r--r--src/lib/git/mod.rs184
-rw-r--r--src/lib/git_events.rs50
-rw-r--r--src/lib/mbox_parser.rs452
-rw-r--r--src/lib/mod.rs1
6 files changed, 667 insertions, 54 deletions
diff --git a/src/bin/ngit/sub_commands/checkout.rs b/src/bin/ngit/sub_commands/checkout.rs
index 19e39d0..87f1ff2 100644
--- a/src/bin/ngit/sub_commands/checkout.rs
+++ b/src/bin/ngit/sub_commands/checkout.rs
@@ -24,7 +24,7 @@ use nostr_sdk::{EventId, FromBech32};
24use crate::{ 24use crate::{
25 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, 25 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache},
26 git::{Repo, RepoActions, str_to_sha1}, 26 git::{Repo, RepoActions, str_to_sha1},
27 git_events::{event_to_cover_letter, patch_supports_commit_ids}, 27 git_events::{event_to_cover_letter, get_parent_commit_from_patch, patch_supports_commit_ids},
28 repo_ref::get_repo_coordinates_when_remote_unknown, 28 repo_ref::get_repo_coordinates_when_remote_unknown,
29}; 29};
30 30
@@ -272,13 +272,12 @@ fn checkout_patch(
272 ); 272 );
273 } 273 }
274 274
275 let proposal_base_commit = str_to_sha1(&tag_value( 275 let last_patch = most_recent_proposal_patch_chain_or_pr_or_pr_update
276 most_recent_proposal_patch_chain_or_pr_or_pr_update 276 .last()
277 .last() 277 .context("there should be at least one patch")?;
278 .context("there should be at least one patch")?, 278
279 "parent-commit", 279 let proposal_base_commit = str_to_sha1(&get_parent_commit_from_patch(last_patch, Some(git_repo))?)
280 )?) 280 .context("failed to get valid parent commit id from patch")?;
281 .context("failed to get valid parent commit id from patch")?;
282 281
283 let (main_branch_name, _master_tip) = git_repo.get_main_or_master_branch()?; 282 let (main_branch_name, _master_tip) = git_repo.get_main_or_master_branch()?;
284 283
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index 80eec21..133ac83 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -35,7 +35,7 @@ use crate::{
35 git::{Repo, RepoActions, str_to_sha1}, 35 git::{Repo, RepoActions, str_to_sha1},
36 git_events::{ 36 git_events::{
37 commit_msg_from_patch_oneliner, event_is_revision_root, event_to_cover_letter, 37 commit_msg_from_patch_oneliner, event_is_revision_root, event_to_cover_letter,
38 patch_supports_commit_ids, 38 get_parent_commit_from_patch, patch_supports_commit_ids,
39 }, 39 },
40 repo_ref::get_repo_coordinates_when_remote_unknown, 40 repo_ref::get_repo_coordinates_when_remote_unknown,
41}; 41};
@@ -703,15 +703,14 @@ async fn launch_interactive() -> Result<()> {
703 .get_checked_out_branch_name()? 703 .get_checked_out_branch_name()?
704 .eq(&cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?); 704 .eq(&cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?);
705 705
706 let proposal_base_commit = str_to_sha1(&tag_value( 706 let last_patch = most_recent_proposal_patch_chain_or_pr_or_pr_update
707 most_recent_proposal_patch_chain_or_pr_or_pr_update 707 .last()
708 .last() 708 .context(
709 .context( 709 "there should be at least one patch as we have already checked for this",
710 "there should be at least one patch as we have already checked for this", 710 )?;
711 )?, 711
712 "parent-commit", 712 let proposal_base_commit = str_to_sha1(&get_parent_commit_from_patch(last_patch, Some(&git_repo))?)
713 )?) 713 .context("failed to get valid parent commit id from patch")?;
714 .context("failed to get valid parent commit id from patch")?;
715 714
716 let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; 715 let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?;
717 716
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs
index 516d9e2..57e8403 100644
--- a/src/lib/git/mod.rs
+++ b/src/lib/git/mod.rs
@@ -62,6 +62,8 @@ pub trait RepoActions {
62 /// returns vector ["name", "email", "unixtime", "offset"] 62 /// returns vector ["name", "email", "unixtime", "offset"]
63 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] 63 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"]
64 fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>>; 64 fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
65 fn get_commit_committer_time(&self, commit: &Sha1Hash) -> Result<i64>;
66 fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result<Option<Sha1Hash>>;
65 fn get_commits_ahead_behind( 67 fn get_commits_ahead_behind(
66 &self, 68 &self,
67 base_commit: &Sha1Hash, 69 base_commit: &Sha1Hash,
@@ -339,6 +341,50 @@ impl RepoActions for Repo {
339 Ok(git_sig_to_tag_vec(&sig)) 341 Ok(git_sig_to_tag_vec(&sig))
340 } 342 }
341 343
344 fn get_commit_committer_time(&self, commit_hash: &Sha1Hash) -> Result<i64> {
345 let commit = self
346 .git_repo
347 .find_commit(sha1_to_oid(commit_hash)?)
348 .context(format!("could not find commit {commit_hash}"))?;
349 let time = commit.committer().when().seconds();
350 Ok(time)
351 }
352
353 fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result<Option<Sha1Hash>> {
354 let (main_branch_name, _) = self
355 .get_main_or_master_branch()
356 .context("failed to get main/master branch")?;
357
358 let mut revwalk = self
359 .git_repo
360 .revwalk()
361 .context("failed to create revwalk")?;
362
363 revwalk
364 .push_ref(&format!("refs/heads/{}", main_branch_name))
365 .context("failed to push main branch to revwalk")?;
366
367 let mut best_commit: Option<(i64, Sha1Hash)> = None;
368
369 for oid_result in revwalk {
370 let oid = oid_result.context("failed to get oid from revwalk")?;
371 let commit = self
372 .git_repo
373 .find_commit(oid)
374 .context("failed to find commit")?;
375
376 let committer_time = commit.committer().when().seconds();
377
378 if committer_time < patch_timestamp
379 && (best_commit.is_none() || committer_time > best_commit.as_ref().unwrap().0)
380 {
381 best_commit = Some((committer_time, oid_to_sha1(&oid)));
382 }
383 }
384
385 Ok(best_commit.map(|(_, sha1)| sha1))
386 }
387
342 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> { 388 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
343 Ok(self 389 Ok(self
344 .git_repo 390 .git_repo
@@ -542,16 +588,16 @@ impl RepoActions for Repo {
542 }) 588 })
543 .collect(); 589 .collect();
544 590
545 let parent_commit_id = tag_value( 591 let parent_commit_id = match patches_to_apply.last() {
546 if let Ok(last_patch) = patches_to_apply.last().context("no patches") { 592 Some(last_patch) => {
547 last_patch 593 crate::git_events::get_parent_commit_from_patch(last_patch, Some(self))?
548 } else { 594 }
595 None => {
549 self.checkout(branch_name) 596 self.checkout(branch_name)
550 .context("no patches and so failed to create a proposal branch")?; 597 .context("no patches and so failed to create a proposal branch")?;
551 return Ok(vec![]); 598 return Ok(vec![]);
552 }, 599 }
553 "parent-commit", 600 };
554 )?;
555 601
556 // check patches can be applied 602 // check patches can be applied
557 if !self.does_commit_exist(&parent_commit_id)? { 603 if !self.does_commit_exist(&parent_commit_id)? {
@@ -590,8 +636,23 @@ impl RepoActions for Repo {
590 636
591 let parent_commit_id = if let Some(commit_id) = parent_commit_id_override.clone() { 637 let parent_commit_id = if let Some(commit_id) = parent_commit_id_override.clone() {
592 commit_id 638 commit_id
639 } else if let Ok(parent) = tag_value(patch, "parent-commit") {
640 parent
593 } else { 641 } else {
594 tag_value(patch, "parent-commit")? 642 let metadata = crate::mbox_parser::parse_mbox_patch(&patch.content)
643 .context("failed to parse patch for timestamp")?;
644 let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp);
645
646 let best_guess = self
647 .find_best_guess_parent_commit(timestamp)
648 .context("failed to find best guess parent commit")?;
649
650 match best_guess {
651 Some(sha1) => sha1.to_string(),
652 None => bail!(
653 "no parent-commit tag and could not determine best guess parent from patch timestamp"
654 ),
655 }
595 }; 656 };
596 657
597 let parent_commit = self 658 let parent_commit = self
@@ -623,10 +684,15 @@ impl RepoActions for Repo {
623 None 684 None
624 }; 685 };
625 686
687 let author_data = extract_signature_data_with_fallback(&patch.tags, "author", &patch.content)?;
688 let committer_data = extract_signature_data_with_fallback(&patch.tags, "committer", &patch.content)?;
689 let author_sig = author_data.to_signature()?;
690 let committer_sig = committer_data.to_signature()?;
691
626 let commit_buff = self.git_repo.commit_create_buffer( 692 let commit_buff = self.git_repo.commit_create_buffer(
627 &extract_sig_from_patch_tags(&patch.tags, "author")?, 693 &author_sig,
628 &extract_sig_from_patch_tags(&patch.tags, "committer")?, 694 &committer_sig,
629 tag_value(patch, "description")?.as_str(), 695 extract_description_from_patch(patch)?.as_str(),
630 &tree, 696 &tree,
631 &[&parent_commit], 697 &[&parent_commit],
632 )?; 698 )?;
@@ -897,7 +963,14 @@ fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec<String> {
897 ] 963 ]
898} 964}
899 965
900fn extract_sig_from_patch_tags<'a>(tags: &'a Tags, tag_name: &str) -> Result<git2::Signature<'a>> { 966struct SignatureData {
967 name: String,
968 email: String,
969 timestamp: i64,
970 offset_minutes: i32,
971}
972
973fn extract_signature_data_from_tags(tags: &Tags, tag_name: &str) -> Result<SignatureData> {
901 let v = tags 974 let v = tags
902 .iter() 975 .iter()
903 .find(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq(tag_name)) 976 .find(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq(tag_name))
@@ -906,16 +979,64 @@ fn extract_sig_from_patch_tags<'a>(tags: &'a Tags, tag_name: &str) -> Result<git
906 if v.len() != 5 { 979 if v.len() != 5 {
907 bail!("tag '{tag_name}' is incorrectly formatted") 980 bail!("tag '{tag_name}' is incorrectly formatted")
908 } 981 }
909 git2::Signature::new( 982 Ok(SignatureData {
910 v[1].as_str(), 983 name: v[1].clone(),
911 v[2].as_str(), 984 email: v[2].clone(),
912 &git2::Time::new( 985 timestamp: v[3].parse().context("tag time is incorrectly formatted")?,
913 v[3].parse().context("tag time is incorrectly formatted")?, 986 offset_minutes: v[4].parse().context("tag time offset is incorrectly formatted")?,
914 v[4].parse() 987 })
915 .context("tag time offset is incorrectly formatted")?, 988}
916 ), 989
917 ) 990fn extract_signature_data_with_fallback(
918 .context("failed to create git signature") 991 tags: &Tags,
992 tag_name: &str,
993 patch_content: &str,
994) -> Result<SignatureData> {
995 if let Ok(data) = extract_signature_data_from_tags(tags, tag_name) {
996 return Ok(data);
997 }
998
999 let metadata = crate::mbox_parser::parse_mbox_patch(patch_content)
1000 .context("failed to parse patch content for fallback metadata")?;
1001
1002 if tag_name == "author" {
1003 Ok(SignatureData {
1004 name: metadata.author_name,
1005 email: metadata.author_email,
1006 timestamp: metadata.author_timestamp,
1007 offset_minutes: metadata.author_offset_minutes,
1008 })
1009 } else if tag_name == "committer" {
1010 let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp);
1011 Ok(SignatureData {
1012 name: metadata.author_name,
1013 email: metadata.author_email,
1014 timestamp,
1015 offset_minutes: metadata.author_offset_minutes,
1016 })
1017 } else {
1018 bail!("unknown tag name for signature extraction: {}", tag_name)
1019 }
1020}
1021
1022impl SignatureData {
1023 fn to_signature(&self) -> Result<git2::Signature<'_>> {
1024 git2::Signature::new(
1025 &self.name,
1026 &self.email,
1027 &git2::Time::new(self.timestamp, self.offset_minutes),
1028 )
1029 .context("failed to create git signature")
1030 }
1031}
1032
1033fn extract_description_from_patch(patch: &nostr::Event) -> Result<String> {
1034 if let Ok(desc) = tag_value(patch, "description") {
1035 return Ok(desc);
1036 }
1037
1038 crate::mbox_parser::extract_description_from_patch(&patch.content)
1039 .context("failed to extract description from patch content")
919} 1040}
920 1041
921pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<Option<String>> { 1042pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<Option<String>> {
@@ -1182,19 +1303,20 @@ mod tests {
1182 } 1303 }
1183 } 1304 }
1184 1305
1185 mod extract_sig_from_patch_tags { 1306 mod extract_signature_data_from_tags {
1186 use super::*; 1307 use super::*;
1187 1308
1188 fn test(time: git2::Time) -> Result<()> { 1309 fn test(time: git2::Time) -> Result<()> {
1310 let data = extract_signature_data_from_tags(
1311 &Tags::from_list(vec![nostr::Tag::custom(
1312 nostr::TagKind::Custom("author".to_string().into()),
1313 prep(&time)?,
1314 )]),
1315 "author",
1316 )?;
1317 let sig = data.to_signature()?;
1189 assert_eq!( 1318 assert_eq!(
1190 extract_sig_from_patch_tags( 1319 sig.to_string(),
1191 &Tags::from_list(vec![nostr::Tag::custom(
1192 nostr::TagKind::Custom("author".to_string().into()),
1193 prep(&time)?,
1194 )]),
1195 "author",
1196 )?
1197 .to_string(),
1198 git2::Signature::new(NAME, EMAIL, &time)?.to_string(), 1320 git2::Signature::new(NAME, EMAIL, &time)?.to_string(),
1199 ); 1321 );
1200 Ok(()) 1322 Ok(())
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index e907a9b..b39e797 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -40,6 +40,30 @@ pub fn get_commit_id_from_patch(event: &Event) -> Result<String> {
40 } 40 }
41} 41}
42 42
43pub fn get_parent_commit_from_patch(
44 event: &Event,
45 git_repo: Option<&Repo>,
46) -> Result<String> {
47 if let Ok(parent) = tag_value(event, "parent-commit") {
48 return Ok(parent);
49 }
50
51 let metadata = crate::mbox_parser::parse_mbox_patch(&event.content)
52 .context("failed to parse patch for timestamp")?;
53 let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp);
54
55 if let Some(repo) = git_repo {
56 if let Some(best_guess) = repo
57 .find_best_guess_parent_commit(timestamp)
58 .context("failed to find best guess parent commit")?
59 {
60 return Ok(best_guess.to_string());
61 }
62 }
63
64 bail!("no parent-commit tag and could not determine best guess parent")
65}
66
43pub fn get_event_root(event: &nostr::Event) -> Result<EventId> { 67pub fn get_event_root(event: &nostr::Event) -> Result<EventId> {
44 Ok(EventId::parse( 68 Ok(EventId::parse(
45 event 69 event
@@ -88,11 +112,27 @@ pub fn event_is_revision_root(event: &Event) -> bool {
88} 112}
89 113
90pub fn patch_supports_commit_ids(event: &Event) -> bool { 114pub fn patch_supports_commit_ids(event: &Event) -> bool {
91 event.kind.eq(&Kind::GitPatch) 115 if !event.kind.eq(&Kind::GitPatch) {
92 && event 116 return false;
93 .tags 117 }
94 .iter() 118
95 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig")) 119 if event
120 .tags
121 .iter()
122 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig"))
123 {
124 return true;
125 }
126
127 if event
128 .tags
129 .iter()
130 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("parent-commit"))
131 {
132 return true;
133 }
134
135 crate::mbox_parser::parse_mbox_patch(&event.content).is_ok()
96} 136}
97 137
98pub fn event_is_valid_pr_or_pr_update(event: &Event) -> bool { 138pub fn event_is_valid_pr_or_pr_update(event: &Event) -> bool {
diff --git a/src/lib/mbox_parser.rs b/src/lib/mbox_parser.rs
new file mode 100644
index 0000000..40603b1
--- /dev/null
+++ b/src/lib/mbox_parser.rs
@@ -0,0 +1,452 @@
1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Datelike};
3
4#[derive(Debug, Clone, PartialEq)]
5pub struct PatchMetadata {
6 pub commit_id: String,
7 pub author_name: String,
8 pub author_email: String,
9 pub author_timestamp: i64,
10 pub author_offset_minutes: i32,
11 pub committer_timestamp: Option<i64>,
12 pub subject: String,
13 pub body: String,
14}
15
16pub fn parse_mbox_patch(content: &str) -> Result<PatchMetadata> {
17 let commit_id = extract_commit_id_from_mbox(content)?;
18 let (author_name, author_email) = extract_author_from_from_header(content)?;
19 let (author_timestamp, author_offset_minutes) = extract_date_from_header(content)?;
20 let committer_timestamp = extract_committer_date_from_mbox(content)?;
21 let subject = extract_subject(content)?;
22 let body = extract_commit_message_body(content)?;
23
24 Ok(PatchMetadata {
25 commit_id,
26 author_name,
27 author_email,
28 author_timestamp,
29 author_offset_minutes,
30 committer_timestamp,
31 subject,
32 body,
33 })
34}
35
36fn extract_commit_id_from_mbox(content: &str) -> Result<String> {
37 if !content.starts_with("From ") {
38 bail!("patch does not start with 'From ' - not a valid mbox format");
39 }
40
41 let first_line = content.lines().next().context("patch content is empty")?;
42
43 let parts: Vec<&str> = first_line.split_whitespace().collect();
44 if parts.len() < 2 {
45 bail!("mbox 'From ' line does not contain a commit id");
46 }
47
48 Ok(parts[1].to_string())
49}
50
51fn extract_author_from_from_header(content: &str) -> Result<(String, String)> {
52 let from_line = content
53 .lines()
54 .find(|line| line.starts_with("From:"))
55 .context("patch does not contain a 'From:' header")?;
56
57 let from_value = from_line
58 .strip_prefix("From:")
59 .context("failed to strip 'From:' prefix")?
60 .trim();
61
62 parse_from_header_value(from_value)
63}
64
65fn parse_from_header_value(value: &str) -> Result<(String, String)> {
66 if let Some(start) = value.find('<') {
67 if let Some(end) = value.find('>') {
68 let email = value[start + 1..end].to_string();
69 let name_part = value[..start].trim();
70 let name = name_part.trim_matches('"').trim().to_string();
71 return Ok((name, email));
72 }
73 }
74
75 if value.contains('@') {
76 let email = value.trim().to_string();
77 let name = email.split('@').next().unwrap_or("unknown").to_string();
78 return Ok((name, email));
79 }
80
81 bail!("could not parse From header: {}", value)
82}
83
84fn extract_date_from_header(content: &str) -> Result<(i64, i32)> {
85 let date_line = content
86 .lines()
87 .find(|line| line.starts_with("Date:"))
88 .context("patch does not contain a 'Date:' header")?;
89
90 let date_value = date_line
91 .strip_prefix("Date:")
92 .context("failed to strip 'Date:' prefix")?
93 .trim();
94
95 parse_rfc2822_date(date_value)
96}
97
98fn parse_rfc2822_date(value: &str) -> Result<(i64, i32)> {
99 let parsed = DateTime::parse_from_rfc2822(value)
100 .context(format!("failed to parse RFC2822 date: {}", value))?;
101
102 let timestamp = parsed.timestamp();
103 let offset_minutes = parsed.offset().local_minus_utc() / 60;
104
105 Ok((timestamp, offset_minutes))
106}
107
108fn extract_committer_date_from_mbox(content: &str) -> Result<Option<i64>> {
109 let first_line = content.lines().next().context("patch content is empty")?;
110
111 let parts: Vec<&str> = first_line.split_whitespace().collect();
112
113 if parts.len() >= 6 {
114 let date_str = parts[3..6].join(" ");
115 if let Ok(dt) = DateTime::parse_from_rfc2822(&date_str) {
116 return Ok(Some(dt.timestamp()));
117 }
118 }
119
120 if parts.len() >= 7 {
121 let date_str = format!("{} {} {}", parts[3], parts[4], parts[5]);
122 if let Ok(dt) = chrono::DateTime::parse_from_str(&date_str, "%a %b %d") {
123 if let Ok(year) = parts[6].parse::<i32>() {
124 let with_year = dt.with_year(year);
125 if let Some(dt_with_year) = with_year {
126 return Ok(Some(dt_with_year.timestamp()));
127 }
128 }
129 }
130 }
131
132 Ok(None)
133}
134
135fn extract_subject(content: &str) -> Result<String> {
136 let subject_line = content
137 .lines()
138 .find(|line| line.starts_with("Subject:"))
139 .context("patch does not contain a 'Subject:' header")?;
140
141 let subject_value = subject_line
142 .strip_prefix("Subject:")
143 .context("failed to strip 'Subject:' prefix")?
144 .trim();
145
146 Ok(cleanup_subject(subject_value))
147}
148
149fn cleanup_subject(subject: &str) -> String {
150 let mut result = subject.to_string();
151
152 loop {
153 let trimmed = result.trim();
154
155 if trimmed.starts_with("Re:") || trimmed.starts_with("re:") {
156 result = trimmed[3..].trim().to_string();
157 continue;
158 }
159
160 if let Some(stripped) = trimmed.strip_prefix(':') {
161 result = stripped.trim().to_string();
162 continue;
163 }
164
165 if trimmed.starts_with('[') {
166 if let Some(end) = trimmed.find(']') {
167 result = trimmed[end + 1..].trim().to_string();
168 continue;
169 }
170 }
171
172 break;
173 }
174
175 result
176}
177
178fn extract_commit_message_body(content: &str) -> Result<String> {
179 let mut in_body = false;
180 let mut body_lines: Vec<String> = Vec::new();
181 let mut found_first_content = false;
182
183 for line in content.lines() {
184 if !in_body {
185 if line.is_empty() {
186 in_body = true;
187 }
188 continue;
189 }
190
191 if line.starts_with("diff --git ")
192 || line.starts_with("Index: ")
193 || line.starts_with("--- ")
194 || line.starts_with("From ")
195 {
196 break;
197 }
198
199 if line.starts_with("---") && line.trim().eq("---") {
200 break;
201 }
202
203 if line.starts_with("-- ") || line.starts_with("--\n") {
204 break;
205 }
206
207 if !found_first_content && line.trim().is_empty() {
208 continue;
209 }
210
211 found_first_content = true;
212 body_lines.push(line.to_string());
213 }
214
215 while body_lines.last().is_some_and(|l| l.trim().is_empty()) {
216 body_lines.pop();
217 }
218
219 Ok(body_lines.join("\n").trim().to_string())
220}
221
222pub fn extract_description_from_patch(content: &str) -> Result<String> {
223 let subject = extract_subject(content)?;
224 let body = extract_commit_message_body(content)?;
225
226 if body.is_empty() {
227 Ok(subject)
228 } else {
229 Ok(format!("{}\n\n{}", subject, body))
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn sample_patch() -> String {
238 "\
239From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001
240From: Joe Bloggs <joe.bloggs@pm.me>
241Date: Thu, 1 Jan 1970 00:00:00 +0000
242Subject: [PATCH] add t2.md
243
244This is the commit message body.
245
246It can have multiple lines.
247
248---
249 t2.md | 1 +
250 1 file changed, 1 insertion(+)
251 create mode 100644 t2.md
252
253diff --git a/t2.md b/t2.md
254new file mode 100644
255index 0000000..a66525d
256--- /dev/null
257+++ b/t2.md
258@@ -0,0 +1 @@
259+some content1
260\\ No newline at end of file
261--
262libgit2 1.9.1
263
264"
265 .to_string()
266 }
267
268 #[test]
269 fn parse_commit_id() {
270 let patch = sample_patch();
271 let result = extract_commit_id_from_mbox(&patch).unwrap();
272 assert_eq!(result, "431b84edc0d2fa118d63faa3c2db9c73d630a5ae");
273 }
274
275 #[test]
276 fn parse_author() {
277 let patch = sample_patch();
278 let (name, email) = extract_author_from_from_header(&patch).unwrap();
279 assert_eq!(name, "Joe Bloggs");
280 assert_eq!(email, "joe.bloggs@pm.me");
281 }
282
283 #[test]
284 fn parse_author_with_quoted_name() {
285 let patch = "\
286From abc123 Mon Sep 17 00:00:00 2001
287From: \"John (nickname) Doe\" <john.doe@example.com>
288Date: Thu, 1 Jan 1970 00:00:00 +0000
289Subject: test
290
291Body
292";
293 let (name, email) = extract_author_from_from_header(patch).unwrap();
294 assert_eq!(name, "John (nickname) Doe");
295 assert_eq!(email, "john.doe@example.com");
296 }
297
298 #[test]
299 fn parse_author_email_only() {
300 let patch = "\
301From abc123 Mon Sep 17 00:00:00 2001
302From: john.doe@example.com
303Date: Thu, 1 Jan 1970 00:00:00 +0000
304Subject: test
305
306Body
307";
308 let (name, email) = extract_author_from_from_header(patch).unwrap();
309 assert_eq!(name, "john.doe");
310 assert_eq!(email, "john.doe@example.com");
311 }
312
313 #[test]
314 fn parse_date() {
315 let patch = sample_patch();
316 let (timestamp, offset) = extract_date_from_header(&patch).unwrap();
317 assert_eq!(timestamp, 0);
318 assert_eq!(offset, 0);
319 }
320
321 #[test]
322 fn parse_date_with_timezone() {
323 let patch = "\
324From abc123 Mon Sep 17 00:00:00 2001
325From: Joe <joe@example.com>
326Date: Thu, 1 Jan 1970 00:00:00 +0500
327Subject: test
328
329Body
330";
331 let (timestamp, offset) = extract_date_from_header(patch).unwrap();
332 assert_eq!(timestamp, -18000);
333 assert_eq!(offset, 300);
334 }
335
336 #[test]
337 fn parse_subject() {
338 let patch = sample_patch();
339 let subject = extract_subject(&patch).unwrap();
340 assert_eq!(subject, "add t2.md");
341 }
342
343 #[test]
344 fn parse_subject_with_patch_prefix() {
345 let patch = "\
346From abc123 Mon Sep 17 00:00:00 2001
347From: Joe <joe@example.com>
348Date: Thu, 1 Jan 1970 00:00:00 +0000
349Subject: [PATCH v2 3/5] fix: important bug
350
351Body
352";
353 let subject = extract_subject(patch).unwrap();
354 assert_eq!(subject, "fix: important bug");
355 }
356
357 #[test]
358 fn parse_subject_with_re_prefix() {
359 let patch = "\
360From abc123 Mon Sep 17 00:00:00 2001
361From: Joe <joe@example.com>
362Date: Thu, 1 Jan 1970 00:00:00 +0000
363Subject: Re: [PATCH] fix: important bug
364
365Body
366";
367 let subject = extract_subject(patch).unwrap();
368 assert_eq!(subject, "fix: important bug");
369 }
370
371 #[test]
372 fn parse_body() {
373 let patch = sample_patch();
374 let body = extract_commit_message_body(&patch).unwrap();
375 assert_eq!(
376 body,
377 "This is the commit message body.\n\nIt can have multiple lines."
378 );
379 }
380
381 #[test]
382 fn parse_body_empty() {
383 let patch = "\
384From abc123 Mon Sep 17 00:00:00 2001
385From: Joe <joe@example.com>
386Date: Thu, 1 Jan 1970 00:00:00 +0000
387Subject: test
388
389---
390 file.txt | 1 +
391diff --git a/file.txt b/file.txt
392";
393 let body = extract_commit_message_body(patch).unwrap();
394 assert_eq!(body, "");
395 }
396
397 #[test]
398 fn parse_full_metadata() {
399 let patch = sample_patch();
400 let metadata = parse_mbox_patch(&patch).unwrap();
401
402 assert_eq!(
403 metadata.commit_id,
404 "431b84edc0d2fa118d63faa3c2db9c73d630a5ae"
405 );
406 assert_eq!(metadata.author_name, "Joe Bloggs");
407 assert_eq!(metadata.author_email, "joe.bloggs@pm.me");
408 assert_eq!(metadata.author_timestamp, 0);
409 assert_eq!(metadata.author_offset_minutes, 0);
410 assert_eq!(metadata.subject, "add t2.md");
411 assert_eq!(
412 metadata.body,
413 "This is the commit message body.\n\nIt can have multiple lines."
414 );
415 }
416
417 #[test]
418 fn extract_description_combines_subject_and_body() {
419 let patch = sample_patch();
420 let description = extract_description_from_patch(&patch).unwrap();
421 assert_eq!(
422 description,
423 "add t2.md\n\nThis is the commit message body.\n\nIt can have multiple lines."
424 );
425 }
426
427 #[test]
428 fn extract_description_subject_only() {
429 let patch = "\
430From abc123 Mon Sep 17 00:00:00 2001
431From: Joe <joe@example.com>
432Date: Thu, 1 Jan 1970 00:00:00 +0000
433Subject: [PATCH] simple fix
434
435---
436 file.txt | 1 +
437";
438 let description = extract_description_from_patch(patch).unwrap();
439 assert_eq!(description, "simple fix");
440 }
441
442 #[test]
443 fn cleanup_subject_strips_patch_prefixes() {
444 assert_eq!(cleanup_subject("[PATCH] test"), "test");
445 assert_eq!(cleanup_subject("[PATCH v2] test"), "test");
446 assert_eq!(cleanup_subject("[PATCH 1/3] test"), "test");
447 assert_eq!(cleanup_subject("[PATCH v2 1/3] test"), "test");
448 assert_eq!(cleanup_subject("Re: [PATCH] test"), "test");
449 assert_eq!(cleanup_subject("re: test"), "test");
450 assert_eq!(cleanup_subject(":test"), "test");
451 }
452}
diff --git a/src/lib/mod.rs b/src/lib/mod.rs
index a09f866..b388b23 100644
--- a/src/lib/mod.rs
+++ b/src/lib/mod.rs
@@ -5,6 +5,7 @@ pub mod git;
5pub mod git_events; 5pub mod git_events;
6pub mod list; 6pub mod list;
7pub mod login; 7pub mod login;
8pub mod mbox_parser;
8pub mod push; 9pub mod push;
9pub mod repo_ref; 10pub mod repo_ref;
10pub mod repo_state; 11pub mod repo_state;