From fcff4541e1f36b6575596c353637b25aeae9bdcf Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 14:48:20 +0000 Subject: feat: handle missing optional patch tags for pr/ flow - Add mbox_parser module to extract metadata from patch content - Extract author/committer from From: and Date: headers when tags missing - Extract commit message body as fallback for description tag - Implement best-guess parent commit logic using committer timestamps - Update patch_supports_commit_ids to accept mbox-parseable patches - Enable patches without optional tags to appear as pr/ branches --- Cargo.lock | 119 ++++++++- Cargo.toml | 1 + src/bin/ngit/sub_commands/checkout.rs | 15 +- src/bin/ngit/sub_commands/list.rs | 19 +- src/lib/git/mod.rs | 184 +++++++++++--- src/lib/git_events.rs | 50 +++- src/lib/mbox_parser.rs | 452 ++++++++++++++++++++++++++++++++++ src/lib/mod.rs | 1 + 8 files changed, 785 insertions(+), 56 deletions(-) create mode 100644 src/lib/mbox_parser.rs diff --git a/Cargo.lock b/Cargo.lock index 4fa46d9..256443e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -175,6 +184,12 @@ dependencies = [ "terminal-prompt", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -344,6 +359,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1077,6 +1105,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1467,6 +1519,7 @@ dependencies = [ "async-trait", "auth-git2", "chacha20poly1305", + "chrono", "clap", "console", "dialoguer", @@ -1615,6 +1668,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3281,6 +3343,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -3300,8 +3397,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -3313,6 +3410,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -3322,6 +3428,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index eefba1a..71de413 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1.0.98" async-trait = "0.1.88" auth-git2 = "0.5.8" chacha20poly1305 = "0.10.1" +chrono = "0.4" clap = { version = "4.5.41", features = ["derive"] } console = "0.16.0" dialoguer = "0.12.0" 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}; use crate::{ client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, git::{Repo, RepoActions, str_to_sha1}, - git_events::{event_to_cover_letter, patch_supports_commit_ids}, + git_events::{event_to_cover_letter, get_parent_commit_from_patch, patch_supports_commit_ids}, repo_ref::get_repo_coordinates_when_remote_unknown, }; @@ -272,13 +272,12 @@ fn checkout_patch( ); } - let proposal_base_commit = str_to_sha1(&tag_value( - most_recent_proposal_patch_chain_or_pr_or_pr_update - .last() - .context("there should be at least one patch")?, - "parent-commit", - )?) - .context("failed to get valid parent commit id from patch")?; + let last_patch = most_recent_proposal_patch_chain_or_pr_or_pr_update + .last() + .context("there should be at least one patch")?; + + let proposal_base_commit = str_to_sha1(&get_parent_commit_from_patch(last_patch, Some(git_repo))?) + .context("failed to get valid parent commit id from patch")?; let (main_branch_name, _master_tip) = git_repo.get_main_or_master_branch()?; 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::{ git::{Repo, RepoActions, str_to_sha1}, git_events::{ commit_msg_from_patch_oneliner, event_is_revision_root, event_to_cover_letter, - patch_supports_commit_ids, + get_parent_commit_from_patch, patch_supports_commit_ids, }, repo_ref::get_repo_coordinates_when_remote_unknown, }; @@ -703,15 +703,14 @@ async fn launch_interactive() -> Result<()> { .get_checked_out_branch_name()? .eq(&cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?); - let proposal_base_commit = str_to_sha1(&tag_value( - most_recent_proposal_patch_chain_or_pr_or_pr_update - .last() - .context( - "there should be at least one patch as we have already checked for this", - )?, - "parent-commit", - )?) - .context("failed to get valid parent commit id from patch")?; + let last_patch = most_recent_proposal_patch_chain_or_pr_or_pr_update + .last() + .context( + "there should be at least one patch as we have already checked for this", + )?; + + let proposal_base_commit = str_to_sha1(&get_parent_commit_from_patch(last_patch, Some(&git_repo))?) + .context("failed to get valid parent commit id from patch")?; let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; 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 { /// returns vector ["name", "email", "unixtime", "offset"] /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result>; + fn get_commit_committer_time(&self, commit: &Sha1Hash) -> Result; + fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result>; fn get_commits_ahead_behind( &self, base_commit: &Sha1Hash, @@ -339,6 +341,50 @@ impl RepoActions for Repo { Ok(git_sig_to_tag_vec(&sig)) } + fn get_commit_committer_time(&self, commit_hash: &Sha1Hash) -> Result { + let commit = self + .git_repo + .find_commit(sha1_to_oid(commit_hash)?) + .context(format!("could not find commit {commit_hash}"))?; + let time = commit.committer().when().seconds(); + Ok(time) + } + + fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result> { + let (main_branch_name, _) = self + .get_main_or_master_branch() + .context("failed to get main/master branch")?; + + let mut revwalk = self + .git_repo + .revwalk() + .context("failed to create revwalk")?; + + revwalk + .push_ref(&format!("refs/heads/{}", main_branch_name)) + .context("failed to push main branch to revwalk")?; + + let mut best_commit: Option<(i64, Sha1Hash)> = None; + + for oid_result in revwalk { + let oid = oid_result.context("failed to get oid from revwalk")?; + let commit = self + .git_repo + .find_commit(oid) + .context("failed to find commit")?; + + let committer_time = commit.committer().when().seconds(); + + if committer_time < patch_timestamp + && (best_commit.is_none() || committer_time > best_commit.as_ref().unwrap().0) + { + best_commit = Some((committer_time, oid_to_sha1(&oid))); + } + } + + Ok(best_commit.map(|(_, sha1)| sha1)) + } + fn get_refs(&self, commit: &Sha1Hash) -> Result> { Ok(self .git_repo @@ -542,16 +588,16 @@ impl RepoActions for Repo { }) .collect(); - let parent_commit_id = tag_value( - if let Ok(last_patch) = patches_to_apply.last().context("no patches") { - last_patch - } else { + let parent_commit_id = match patches_to_apply.last() { + Some(last_patch) => { + crate::git_events::get_parent_commit_from_patch(last_patch, Some(self))? + } + None => { self.checkout(branch_name) .context("no patches and so failed to create a proposal branch")?; return Ok(vec![]); - }, - "parent-commit", - )?; + } + }; // check patches can be applied if !self.does_commit_exist(&parent_commit_id)? { @@ -590,8 +636,23 @@ impl RepoActions for Repo { let parent_commit_id = if let Some(commit_id) = parent_commit_id_override.clone() { commit_id + } else if let Ok(parent) = tag_value(patch, "parent-commit") { + parent } else { - tag_value(patch, "parent-commit")? + let metadata = crate::mbox_parser::parse_mbox_patch(&patch.content) + .context("failed to parse patch for timestamp")?; + let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp); + + let best_guess = self + .find_best_guess_parent_commit(timestamp) + .context("failed to find best guess parent commit")?; + + match best_guess { + Some(sha1) => sha1.to_string(), + None => bail!( + "no parent-commit tag and could not determine best guess parent from patch timestamp" + ), + } }; let parent_commit = self @@ -623,10 +684,15 @@ impl RepoActions for Repo { None }; + let author_data = extract_signature_data_with_fallback(&patch.tags, "author", &patch.content)?; + let committer_data = extract_signature_data_with_fallback(&patch.tags, "committer", &patch.content)?; + let author_sig = author_data.to_signature()?; + let committer_sig = committer_data.to_signature()?; + let commit_buff = self.git_repo.commit_create_buffer( - &extract_sig_from_patch_tags(&patch.tags, "author")?, - &extract_sig_from_patch_tags(&patch.tags, "committer")?, - tag_value(patch, "description")?.as_str(), + &author_sig, + &committer_sig, + extract_description_from_patch(patch)?.as_str(), &tree, &[&parent_commit], )?; @@ -897,7 +963,14 @@ fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec { ] } -fn extract_sig_from_patch_tags<'a>(tags: &'a Tags, tag_name: &str) -> Result> { +struct SignatureData { + name: String, + email: String, + timestamp: i64, + offset_minutes: i32, +} + +fn extract_signature_data_from_tags(tags: &Tags, tag_name: &str) -> Result { let v = tags .iter() .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 Result { + if let Ok(data) = extract_signature_data_from_tags(tags, tag_name) { + return Ok(data); + } + + let metadata = crate::mbox_parser::parse_mbox_patch(patch_content) + .context("failed to parse patch content for fallback metadata")?; + + if tag_name == "author" { + Ok(SignatureData { + name: metadata.author_name, + email: metadata.author_email, + timestamp: metadata.author_timestamp, + offset_minutes: metadata.author_offset_minutes, + }) + } else if tag_name == "committer" { + let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp); + Ok(SignatureData { + name: metadata.author_name, + email: metadata.author_email, + timestamp, + offset_minutes: metadata.author_offset_minutes, + }) + } else { + bail!("unknown tag name for signature extraction: {}", tag_name) + } +} + +impl SignatureData { + fn to_signature(&self) -> Result> { + git2::Signature::new( + &self.name, + &self.email, + &git2::Time::new(self.timestamp, self.offset_minutes), + ) + .context("failed to create git signature") + } +} + +fn extract_description_from_patch(patch: &nostr::Event) -> Result { + if let Ok(desc) = tag_value(patch, "description") { + return Ok(desc); + } + + crate::mbox_parser::extract_description_from_patch(&patch.content) + .context("failed to extract description from patch content") } pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result> { @@ -1182,19 +1303,20 @@ mod tests { } } - mod extract_sig_from_patch_tags { + mod extract_signature_data_from_tags { use super::*; fn test(time: git2::Time) -> Result<()> { + let data = extract_signature_data_from_tags( + &Tags::from_list(vec![nostr::Tag::custom( + nostr::TagKind::Custom("author".to_string().into()), + prep(&time)?, + )]), + "author", + )?; + let sig = data.to_signature()?; assert_eq!( - extract_sig_from_patch_tags( - &Tags::from_list(vec![nostr::Tag::custom( - nostr::TagKind::Custom("author".to_string().into()), - prep(&time)?, - )]), - "author", - )? - .to_string(), + sig.to_string(), git2::Signature::new(NAME, EMAIL, &time)?.to_string(), ); 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 { } } +pub fn get_parent_commit_from_patch( + event: &Event, + git_repo: Option<&Repo>, +) -> Result { + if let Ok(parent) = tag_value(event, "parent-commit") { + return Ok(parent); + } + + let metadata = crate::mbox_parser::parse_mbox_patch(&event.content) + .context("failed to parse patch for timestamp")?; + let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp); + + if let Some(repo) = git_repo { + if let Some(best_guess) = repo + .find_best_guess_parent_commit(timestamp) + .context("failed to find best guess parent commit")? + { + return Ok(best_guess.to_string()); + } + } + + bail!("no parent-commit tag and could not determine best guess parent") +} + pub fn get_event_root(event: &nostr::Event) -> Result { Ok(EventId::parse( event @@ -88,11 +112,27 @@ pub fn event_is_revision_root(event: &Event) -> bool { } pub fn patch_supports_commit_ids(event: &Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags - .iter() - .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig")) + if !event.kind.eq(&Kind::GitPatch) { + return false; + } + + if event + .tags + .iter() + .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig")) + { + return true; + } + + if event + .tags + .iter() + .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("parent-commit")) + { + return true; + } + + crate::mbox_parser::parse_mbox_patch(&event.content).is_ok() } pub 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 @@ +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Datelike}; + +#[derive(Debug, Clone, PartialEq)] +pub struct PatchMetadata { + pub commit_id: String, + pub author_name: String, + pub author_email: String, + pub author_timestamp: i64, + pub author_offset_minutes: i32, + pub committer_timestamp: Option, + pub subject: String, + pub body: String, +} + +pub fn parse_mbox_patch(content: &str) -> Result { + let commit_id = extract_commit_id_from_mbox(content)?; + let (author_name, author_email) = extract_author_from_from_header(content)?; + let (author_timestamp, author_offset_minutes) = extract_date_from_header(content)?; + let committer_timestamp = extract_committer_date_from_mbox(content)?; + let subject = extract_subject(content)?; + let body = extract_commit_message_body(content)?; + + Ok(PatchMetadata { + commit_id, + author_name, + author_email, + author_timestamp, + author_offset_minutes, + committer_timestamp, + subject, + body, + }) +} + +fn extract_commit_id_from_mbox(content: &str) -> Result { + if !content.starts_with("From ") { + bail!("patch does not start with 'From ' - not a valid mbox format"); + } + + let first_line = content.lines().next().context("patch content is empty")?; + + let parts: Vec<&str> = first_line.split_whitespace().collect(); + if parts.len() < 2 { + bail!("mbox 'From ' line does not contain a commit id"); + } + + Ok(parts[1].to_string()) +} + +fn extract_author_from_from_header(content: &str) -> Result<(String, String)> { + let from_line = content + .lines() + .find(|line| line.starts_with("From:")) + .context("patch does not contain a 'From:' header")?; + + let from_value = from_line + .strip_prefix("From:") + .context("failed to strip 'From:' prefix")? + .trim(); + + parse_from_header_value(from_value) +} + +fn parse_from_header_value(value: &str) -> Result<(String, String)> { + if let Some(start) = value.find('<') { + if let Some(end) = value.find('>') { + let email = value[start + 1..end].to_string(); + let name_part = value[..start].trim(); + let name = name_part.trim_matches('"').trim().to_string(); + return Ok((name, email)); + } + } + + if value.contains('@') { + let email = value.trim().to_string(); + let name = email.split('@').next().unwrap_or("unknown").to_string(); + return Ok((name, email)); + } + + bail!("could not parse From header: {}", value) +} + +fn extract_date_from_header(content: &str) -> Result<(i64, i32)> { + let date_line = content + .lines() + .find(|line| line.starts_with("Date:")) + .context("patch does not contain a 'Date:' header")?; + + let date_value = date_line + .strip_prefix("Date:") + .context("failed to strip 'Date:' prefix")? + .trim(); + + parse_rfc2822_date(date_value) +} + +fn parse_rfc2822_date(value: &str) -> Result<(i64, i32)> { + let parsed = DateTime::parse_from_rfc2822(value) + .context(format!("failed to parse RFC2822 date: {}", value))?; + + let timestamp = parsed.timestamp(); + let offset_minutes = parsed.offset().local_minus_utc() / 60; + + Ok((timestamp, offset_minutes)) +} + +fn extract_committer_date_from_mbox(content: &str) -> Result> { + let first_line = content.lines().next().context("patch content is empty")?; + + let parts: Vec<&str> = first_line.split_whitespace().collect(); + + if parts.len() >= 6 { + let date_str = parts[3..6].join(" "); + if let Ok(dt) = DateTime::parse_from_rfc2822(&date_str) { + return Ok(Some(dt.timestamp())); + } + } + + if parts.len() >= 7 { + let date_str = format!("{} {} {}", parts[3], parts[4], parts[5]); + if let Ok(dt) = chrono::DateTime::parse_from_str(&date_str, "%a %b %d") { + if let Ok(year) = parts[6].parse::() { + let with_year = dt.with_year(year); + if let Some(dt_with_year) = with_year { + return Ok(Some(dt_with_year.timestamp())); + } + } + } + } + + Ok(None) +} + +fn extract_subject(content: &str) -> Result { + let subject_line = content + .lines() + .find(|line| line.starts_with("Subject:")) + .context("patch does not contain a 'Subject:' header")?; + + let subject_value = subject_line + .strip_prefix("Subject:") + .context("failed to strip 'Subject:' prefix")? + .trim(); + + Ok(cleanup_subject(subject_value)) +} + +fn cleanup_subject(subject: &str) -> String { + let mut result = subject.to_string(); + + loop { + let trimmed = result.trim(); + + if trimmed.starts_with("Re:") || trimmed.starts_with("re:") { + result = trimmed[3..].trim().to_string(); + continue; + } + + if let Some(stripped) = trimmed.strip_prefix(':') { + result = stripped.trim().to_string(); + continue; + } + + if trimmed.starts_with('[') { + if let Some(end) = trimmed.find(']') { + result = trimmed[end + 1..].trim().to_string(); + continue; + } + } + + break; + } + + result +} + +fn extract_commit_message_body(content: &str) -> Result { + let mut in_body = false; + let mut body_lines: Vec = Vec::new(); + let mut found_first_content = false; + + for line in content.lines() { + if !in_body { + if line.is_empty() { + in_body = true; + } + continue; + } + + if line.starts_with("diff --git ") + || line.starts_with("Index: ") + || line.starts_with("--- ") + || line.starts_with("From ") + { + break; + } + + if line.starts_with("---") && line.trim().eq("---") { + break; + } + + if line.starts_with("-- ") || line.starts_with("--\n") { + break; + } + + if !found_first_content && line.trim().is_empty() { + continue; + } + + found_first_content = true; + body_lines.push(line.to_string()); + } + + while body_lines.last().is_some_and(|l| l.trim().is_empty()) { + body_lines.pop(); + } + + Ok(body_lines.join("\n").trim().to_string()) +} + +pub fn extract_description_from_patch(content: &str) -> Result { + let subject = extract_subject(content)?; + let body = extract_commit_message_body(content)?; + + if body.is_empty() { + Ok(subject) + } else { + Ok(format!("{}\n\n{}", subject, body)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_patch() -> String { + "\ +From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001 +From: Joe Bloggs +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: [PATCH] add t2.md + +This is the commit message body. + +It can have multiple lines. + +--- + t2.md | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 t2.md + +diff --git a/t2.md b/t2.md +new file mode 100644 +index 0000000..a66525d +--- /dev/null ++++ b/t2.md +@@ -0,0 +1 @@ ++some content1 +\\ No newline at end of file +-- +libgit2 1.9.1 + +" + .to_string() + } + + #[test] + fn parse_commit_id() { + let patch = sample_patch(); + let result = extract_commit_id_from_mbox(&patch).unwrap(); + assert_eq!(result, "431b84edc0d2fa118d63faa3c2db9c73d630a5ae"); + } + + #[test] + fn parse_author() { + let patch = sample_patch(); + let (name, email) = extract_author_from_from_header(&patch).unwrap(); + assert_eq!(name, "Joe Bloggs"); + assert_eq!(email, "joe.bloggs@pm.me"); + } + + #[test] + fn parse_author_with_quoted_name() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: \"John (nickname) Doe\" +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: test + +Body +"; + let (name, email) = extract_author_from_from_header(patch).unwrap(); + assert_eq!(name, "John (nickname) Doe"); + assert_eq!(email, "john.doe@example.com"); + } + + #[test] + fn parse_author_email_only() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: john.doe@example.com +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: test + +Body +"; + let (name, email) = extract_author_from_from_header(patch).unwrap(); + assert_eq!(name, "john.doe"); + assert_eq!(email, "john.doe@example.com"); + } + + #[test] + fn parse_date() { + let patch = sample_patch(); + let (timestamp, offset) = extract_date_from_header(&patch).unwrap(); + assert_eq!(timestamp, 0); + assert_eq!(offset, 0); + } + + #[test] + fn parse_date_with_timezone() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: Joe +Date: Thu, 1 Jan 1970 00:00:00 +0500 +Subject: test + +Body +"; + let (timestamp, offset) = extract_date_from_header(patch).unwrap(); + assert_eq!(timestamp, -18000); + assert_eq!(offset, 300); + } + + #[test] + fn parse_subject() { + let patch = sample_patch(); + let subject = extract_subject(&patch).unwrap(); + assert_eq!(subject, "add t2.md"); + } + + #[test] + fn parse_subject_with_patch_prefix() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: Joe +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: [PATCH v2 3/5] fix: important bug + +Body +"; + let subject = extract_subject(patch).unwrap(); + assert_eq!(subject, "fix: important bug"); + } + + #[test] + fn parse_subject_with_re_prefix() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: Joe +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: Re: [PATCH] fix: important bug + +Body +"; + let subject = extract_subject(patch).unwrap(); + assert_eq!(subject, "fix: important bug"); + } + + #[test] + fn parse_body() { + let patch = sample_patch(); + let body = extract_commit_message_body(&patch).unwrap(); + assert_eq!( + body, + "This is the commit message body.\n\nIt can have multiple lines." + ); + } + + #[test] + fn parse_body_empty() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: Joe +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: test + +--- + file.txt | 1 + +diff --git a/file.txt b/file.txt +"; + let body = extract_commit_message_body(patch).unwrap(); + assert_eq!(body, ""); + } + + #[test] + fn parse_full_metadata() { + let patch = sample_patch(); + let metadata = parse_mbox_patch(&patch).unwrap(); + + assert_eq!( + metadata.commit_id, + "431b84edc0d2fa118d63faa3c2db9c73d630a5ae" + ); + assert_eq!(metadata.author_name, "Joe Bloggs"); + assert_eq!(metadata.author_email, "joe.bloggs@pm.me"); + assert_eq!(metadata.author_timestamp, 0); + assert_eq!(metadata.author_offset_minutes, 0); + assert_eq!(metadata.subject, "add t2.md"); + assert_eq!( + metadata.body, + "This is the commit message body.\n\nIt can have multiple lines." + ); + } + + #[test] + fn extract_description_combines_subject_and_body() { + let patch = sample_patch(); + let description = extract_description_from_patch(&patch).unwrap(); + assert_eq!( + description, + "add t2.md\n\nThis is the commit message body.\n\nIt can have multiple lines." + ); + } + + #[test] + fn extract_description_subject_only() { + let patch = "\ +From abc123 Mon Sep 17 00:00:00 2001 +From: Joe +Date: Thu, 1 Jan 1970 00:00:00 +0000 +Subject: [PATCH] simple fix + +--- + file.txt | 1 + +"; + let description = extract_description_from_patch(patch).unwrap(); + assert_eq!(description, "simple fix"); + } + + #[test] + fn cleanup_subject_strips_patch_prefixes() { + assert_eq!(cleanup_subject("[PATCH] test"), "test"); + assert_eq!(cleanup_subject("[PATCH v2] test"), "test"); + assert_eq!(cleanup_subject("[PATCH 1/3] test"), "test"); + assert_eq!(cleanup_subject("[PATCH v2 1/3] test"), "test"); + assert_eq!(cleanup_subject("Re: [PATCH] test"), "test"); + assert_eq!(cleanup_subject("re: test"), "test"); + assert_eq!(cleanup_subject(":test"), "test"); + } +} 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; pub mod git_events; pub mod list; pub mod login; +pub mod mbox_parser; pub mod push; pub mod repo_ref; pub mod repo_state; -- cgit v1.2.3