| 1 | //! What a merge or squash commit says unless its merger writes it: the |
| 2 | //! repository's choice (`RepoSettings::merge_commit_message` and |
| 3 | //! `squash_commit_message`), filled in from the pull request. A rebase and |
| 4 | //! a fast-forward make no commit of their own, so say nothing. |
| 5 | |
| 6 | use g1t_contracts::rules::MergeMethod; |
| 7 | use g1t_contracts::work::{CommitMessageDefault, RepoSettings}; |
| 8 | |
| 9 | /// The longest commit title a merger may write. |
| 10 | pub(crate) const MAX_TITLE_CHARS: usize = 500; |
| 11 | /// The longest commit message body a merger may write. |
| 12 | pub(crate) const MAX_MESSAGE_CHARS: usize = 64_000; |
| 13 | |
| 14 | /// One commit the pull request adds, as its message says. Kept as JSON |
| 15 | /// with its pull request (`pull_message_commits`, merging.rs). |
| 16 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 17 | pub(crate) struct CommitText { |
| 18 | pub message: String, |
| 19 | pub author_email: Option<String>, |
| 20 | } |
| 21 | |
| 22 | impl CommitText { |
| 23 | /// Its message's first line. |
| 24 | pub(crate) fn subject(&self) -> &str { |
| 25 | self.message.lines().next().unwrap_or_default().trim() |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | /// What a message is made from. |
| 30 | #[derive(Clone, Debug, Default)] |
| 31 | pub(crate) struct MessageFacts<'a> { |
| 32 | pub number: u32, |
| 33 | pub title: &'a str, |
| 34 | pub body: &'a str, |
| 35 | /// The branch holding the change. |
| 36 | pub branch: &'a str, |
| 37 | /// Who the branch belongs to: the workspace of the repository or fork |
| 38 | /// it is in, or for a working copy, whoever asked for the change. |
| 39 | pub head_owner: &'a str, |
| 40 | /// The repository or fork holding it, `owner/name`. |
| 41 | pub head_repo: &'a str, |
| 42 | /// The branch it merges into. |
| 43 | pub base: &'a str, |
| 44 | /// Its author's username. |
| 45 | pub author: &'a str, |
| 46 | /// Its commits, oldest first, merge commits left out. |
| 47 | pub commits: &'a [CommitText], |
| 48 | /// The issues it closes. |
| 49 | pub issues: &'a [u32], |
| 50 | } |
| 51 | |
| 52 | /// Whether making the default message for `method` needs the pull |
| 53 | /// request's commits read. |
| 54 | pub(crate) fn needs_commits(method: MergeMethod, settings: &RepoSettings) -> bool { |
| 55 | let (choice, template) = match method { |
| 56 | MergeMethod::Merge => (settings.merge_commit_message, settings.merge_commit_template.as_deref()), |
| 57 | MergeMethod::Squash => (settings.squash_commit_message, settings.squash_commit_template.as_deref()), |
| 58 | MergeMethod::Rebase | MergeMethod::FastForward => return false, |
| 59 | }; |
| 60 | match choice { |
| 61 | CommitMessageDefault::Default => method == MergeMethod::Squash, |
| 62 | CommitMessageDefault::PullTitleAndCommits => method == MergeMethod::Squash, |
| 63 | CommitMessageDefault::Custom => template.is_some_and(|text| text.contains("{commits}") || text.contains("{co_authors}")), |
| 64 | CommitMessageDefault::PullTitle | CommitMessageDefault::PullTitleAndDescription => false, |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// The default title and body of the commit `method` makes, or `None` for |
| 69 | /// one that makes none of its own. |
| 70 | pub(crate) fn default_message(method: MergeMethod, settings: &RepoSettings, facts: &MessageFacts<'_>) -> Option<(String, String)> { |
| 71 | let numbered = format!("{} (#{})", facts.title.trim(), facts.number); |
| 72 | let description = facts.body.trim().to_owned(); |
| 73 | match method { |
| 74 | MergeMethod::Rebase | MergeMethod::FastForward => None, |
| 75 | MergeMethod::Merge => Some(match settings.merge_commit_message { |
| 76 | CommitMessageDefault::PullTitle => (numbered, String::new()), |
| 77 | CommitMessageDefault::PullTitleAndDescription => (numbered, description), |
| 78 | CommitMessageDefault::Custom => custom(settings.merge_commit_template.as_deref(), facts).unwrap_or_else(|| merge_default(facts)), |
| 79 | // Listing commits is for squashes; settings refuse it here. |
| 80 | CommitMessageDefault::Default | CommitMessageDefault::PullTitleAndCommits => merge_default(facts), |
| 81 | }), |
| 82 | MergeMethod::Squash => Some(match settings.squash_commit_message { |
| 83 | CommitMessageDefault::PullTitle => (numbered, String::new()), |
| 84 | CommitMessageDefault::PullTitleAndDescription => (numbered, description), |
| 85 | CommitMessageDefault::PullTitleAndCommits => ( |
| 86 | numbered, |
| 87 | facts.commits.iter().map(|commit| commit.message.trim()).filter(|message| !message.is_empty()).collect::<Vec<_>>().join("\n\n"), |
| 88 | ), |
| 89 | CommitMessageDefault::Custom => custom(settings.squash_commit_template.as_deref(), facts).unwrap_or_else(|| squash_default(facts)), |
| 90 | CommitMessageDefault::Default => squash_default(facts), |
| 91 | }), |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /// "Merge pull request #12 from owner/branch", and its title below. |
| 96 | fn merge_default(facts: &MessageFacts<'_>) -> (String, String) { |
| 97 | ( |
| 98 | format!("Merge pull request #{} from {}/{}", facts.number, facts.head_owner, facts.branch), |
| 99 | facts.title.trim().to_owned(), |
| 100 | ) |
| 101 | } |
| 102 | |
| 103 | /// Its title and number; below, its commits' subjects when it has more |
| 104 | /// than one. |
| 105 | fn squash_default(facts: &MessageFacts<'_>) -> (String, String) { |
| 106 | let body = if facts.commits.len() > 1 { bullets(facts.commits) } else { String::new() }; |
| 107 | (format!("{} (#{})", facts.title.trim(), facts.number), body) |
| 108 | } |
| 109 | |
| 110 | fn bullets(commits: &[CommitText]) -> String { |
| 111 | commits |
| 112 | .iter() |
| 113 | .map(CommitText::subject) |
| 114 | .filter(|subject| !subject.is_empty()) |
| 115 | .map(|subject| format!("* {subject}")) |
| 116 | .collect::<Vec<_>>() |
| 117 | .join("\n") |
| 118 | } |
| 119 | |
| 120 | /// `Co-authored-by:` lines for the commits' authors, each once. |
| 121 | fn co_authors(commits: &[CommitText]) -> String { |
| 122 | let mut seen: Vec<String> = Vec::new(); |
| 123 | for email in commits.iter().filter_map(|commit| commit.author_email.as_deref()) { |
| 124 | let email = email.trim().to_lowercase(); |
| 125 | if !email.is_empty() && !seen.contains(&email) { |
| 126 | seen.push(email); |
| 127 | } |
| 128 | } |
| 129 | seen.iter() |
| 130 | .map(|email| format!("Co-authored-by: {} <{email}>", email.split('@').next().unwrap_or(email))) |
| 131 | .collect::<Vec<_>>() |
| 132 | .join("\n") |
| 133 | } |
| 134 | |
| 135 | /// The repository's template filled in: its first line is the title, the |
| 136 | /// rest the body. `None` when there is no template or it says nothing. |
| 137 | fn custom(template: Option<&str>, facts: &MessageFacts<'_>) -> Option<(String, String)> { |
| 138 | let issues = facts.issues.iter().map(|number| format!("#{number}")).collect::<Vec<_>>().join(", "); |
| 139 | let filled = fill( |
| 140 | template?, |
| 141 | &[ |
| 142 | ("title", facts.title.trim()), |
| 143 | ("number", &facts.number.to_string()), |
| 144 | ("body", facts.body.trim()), |
| 145 | ("branch", facts.branch), |
| 146 | ("head_repo", facts.head_repo), |
| 147 | ("base", facts.base), |
| 148 | ("author", facts.author), |
| 149 | ("commits", &bullets(facts.commits)), |
| 150 | ("issues", &issues), |
| 151 | ("co_authors", &co_authors(facts.commits)), |
| 152 | ], |
| 153 | ); |
| 154 | let (title, body) = split(&filled); |
| 155 | (!title.is_empty()).then_some((title, body)) |
| 156 | } |
| 157 | |
| 158 | /// `template` with each `{name}` replaced once, left to right: what a |
| 159 | /// value holds is never filled in again. Unknown names stay as written. |
| 160 | fn fill(template: &str, values: &[(&str, &str)]) -> String { |
| 161 | let mut out = String::with_capacity(template.len()); |
| 162 | let mut rest = template; |
| 163 | while let Some(open) = rest.find('{') { |
| 164 | out.push_str(&rest[..open]); |
| 165 | let after = &rest[open + 1..]; |
| 166 | let named = after.find('}').map(|close| (&after[..close], &after[close + 1..])); |
| 167 | match named.and_then(|(name, tail)| values.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value, tail))) { |
| 168 | Some((value, tail)) => { |
| 169 | out.push_str(value); |
| 170 | rest = tail; |
| 171 | } |
| 172 | None => { |
| 173 | out.push('{'); |
| 174 | rest = after; |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | out.push_str(rest); |
| 179 | out |
| 180 | } |
| 181 | |
| 182 | /// A whole message as its title (first line) and body (the rest, blank |
| 183 | /// lines around it dropped). |
| 184 | pub(crate) fn split(message: &str) -> (String, String) { |
| 185 | let message = message.trim(); |
| 186 | match message.split_once('\n') { |
| 187 | Some((title, body)) => (title.trim().to_owned(), body.trim().to_owned()), |
| 188 | None => (message.to_owned(), String::new()), |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /// A title and body as one commit message. |
| 193 | pub(crate) fn compose(title: &str, body: &str) -> String { |
| 194 | let (title, body) = (title.trim(), body.trim()); |
| 195 | if body.is_empty() { title.to_owned() } else { format!("{title}\n\n{body}") } |
| 196 | } |
| 197 | |
| 198 | /// The message a merger wrote, over the default: a title they give |
| 199 | /// replaces the default's, a body they give (even empty) replaces its body. |
| 200 | /// Or why it cannot be used. |
| 201 | pub(crate) fn chosen( |
| 202 | default: Option<(String, String)>, |
| 203 | title: Option<&str>, |
| 204 | body: Option<&str>, |
| 205 | ) -> std::result::Result<Option<String>, &'static str> { |
| 206 | let Some((default_title, default_body)) = default else { |
| 207 | return Ok(None); |
| 208 | }; |
| 209 | let title = match title.map(str::trim) { |
| 210 | Some("") => return Err("A commit title is required."), |
| 211 | Some(title) if title.chars().count() > MAX_TITLE_CHARS => return Err("That commit title is too long."), |
| 212 | Some(title) if title.contains('\n') => return Err("A commit title is one line; put the rest in the message."), |
| 213 | Some(title) => title.to_owned(), |
| 214 | None => default_title, |
| 215 | }; |
| 216 | let body = match body { |
| 217 | Some(body) if body.chars().count() > MAX_MESSAGE_CHARS => return Err("That commit message is too long."), |
| 218 | Some(body) => body.to_owned(), |
| 219 | None => default_body, |
| 220 | }; |
| 221 | Ok(Some(compose(&title, &body))) |
| 222 | } |
| 223 | |
| 224 | #[cfg(test)] |
| 225 | mod tests { |
| 226 | use super::*; |
| 227 | |
| 228 | fn commit(message: &str, email: &str) -> CommitText { |
| 229 | CommitText { message: message.into(), author_email: Some(email.into()) } |
| 230 | } |
| 231 | |
| 232 | fn facts<'a>(commits: &'a [CommitText], issues: &'a [u32]) -> MessageFacts<'a> { |
| 233 | MessageFacts { |
| 234 | number: 12, |
| 235 | title: "Add forks ", |
| 236 | body: "Forks are ordinary repositories.\n", |
| 237 | branch: "forks", |
| 238 | head_owner: "ana", |
| 239 | head_repo: "ana/g1t", |
| 240 | base: "main", |
| 241 | author: "ana", |
| 242 | commits, |
| 243 | issues, |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | fn with(merge: CommitMessageDefault, squash: CommitMessageDefault) -> RepoSettings { |
| 248 | RepoSettings { merge_commit_message: merge, squash_commit_message: squash, ..RepoSettings::default() } |
| 249 | } |
| 250 | |
| 251 | fn message(method: MergeMethod, settings: &RepoSettings, facts: &MessageFacts<'_>) -> (String, String) { |
| 252 | default_message(method, settings, facts).expect("a commit of its own") |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn a_merge_commit_names_where_it_came_from_by_default() { |
| 257 | let none = []; |
| 258 | let settings = RepoSettings::default(); |
| 259 | assert_eq!( |
| 260 | message(MergeMethod::Merge, &settings, &facts(&none, &[])), |
| 261 | ("Merge pull request #12 from ana/forks".into(), "Add forks".into()) |
| 262 | ); |
| 263 | let titled = with(CommitMessageDefault::PullTitle, CommitMessageDefault::Default); |
| 264 | assert_eq!(message(MergeMethod::Merge, &titled, &facts(&none, &[])), ("Add forks (#12)".into(), String::new())); |
| 265 | let described = with(CommitMessageDefault::PullTitleAndDescription, CommitMessageDefault::Default); |
| 266 | assert_eq!( |
| 267 | message(MergeMethod::Merge, &described, &facts(&none, &[])), |
| 268 | ("Add forks (#12)".into(), "Forks are ordinary repositories.".into()) |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn a_squash_lists_its_commits_when_it_has_more_than_one() { |
| 274 | let settings = RepoSettings::default(); |
| 275 | let one = [commit("Add forks\n\nLong story.", "ana@example.com")]; |
| 276 | assert_eq!(message(MergeMethod::Squash, &settings, &facts(&one, &[])), ("Add forks (#12)".into(), String::new())); |
| 277 | let two = [commit("Add forks\n\nLong story.", "ana@example.com"), commit("Fix the test", "bo@example.com")]; |
| 278 | assert_eq!( |
| 279 | message(MergeMethod::Squash, &settings, &facts(&two, &[])), |
| 280 | ("Add forks (#12)".into(), "* Add forks\n* Fix the test".into()) |
| 281 | ); |
| 282 | let all = with(CommitMessageDefault::Default, CommitMessageDefault::PullTitleAndCommits); |
| 283 | assert_eq!( |
| 284 | message(MergeMethod::Squash, &all, &facts(&two, &[])), |
| 285 | ("Add forks (#12)".into(), "Add forks\n\nLong story.\n\nFix the test".into()) |
| 286 | ); |
| 287 | let described = with(CommitMessageDefault::Default, CommitMessageDefault::PullTitleAndDescription); |
| 288 | assert_eq!(message(MergeMethod::Squash, &described, &facts(&two, &[])).1, "Forks are ordinary repositories."); |
| 289 | } |
| 290 | |
| 291 | #[test] |
| 292 | fn rebases_and_fast_forwards_make_no_message() { |
| 293 | let settings = RepoSettings::default(); |
| 294 | assert!(default_message(MergeMethod::Rebase, &settings, &facts(&[], &[])).is_none()); |
| 295 | assert!(default_message(MergeMethod::FastForward, &settings, &facts(&[], &[])).is_none()); |
| 296 | assert!(!needs_commits(MergeMethod::Rebase, &settings)); |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn a_template_is_filled_in_once() { |
| 301 | let commits = [commit("One", "ana@example.com"), commit("Two", "Ana@Example.com"), commit("Three", "bo@example.com")]; |
| 302 | let settings = RepoSettings { |
| 303 | squash_commit_message: CommitMessageDefault::Custom, |
| 304 | squash_commit_template: Some( |
| 305 | "{title} (#{number}) into {base}\n\n{body}\nFrom {head_repo}:{branch} by {author}\n{commits}\nCloses {issues}\n{co_authors}\n{unknown} {title" |
| 306 | .into(), |
| 307 | ), |
| 308 | ..RepoSettings::default() |
| 309 | }; |
| 310 | let (title, body) = message(MergeMethod::Squash, &settings, &facts(&commits, &[3, 4])); |
| 311 | assert_eq!(title, "Add forks (#12) into main"); |
| 312 | assert_eq!( |
| 313 | body, |
| 314 | "Forks are ordinary repositories.\nFrom ana/g1t:forks by ana\n* One\n* Two\n* Three\nCloses #3, #4\nCo-authored-by: ana <ana@example.com>\nCo-authored-by: bo <bo@example.com>\n{unknown} {title" |
| 315 | ); |
| 316 | assert!(needs_commits(MergeMethod::Squash, &settings)); |
| 317 | // A value holding a placeholder is not filled in again. |
| 318 | let tricky = MessageFacts { title: "{body}", ..facts(&[], &[]) }; |
| 319 | let echo = RepoSettings { merge_commit_message: CommitMessageDefault::Custom, merge_commit_template: Some("{title}".into()), ..RepoSettings::default() }; |
| 320 | assert_eq!(message(MergeMethod::Merge, &echo, &tricky).0, "{body}"); |
| 321 | // A template that fills in nothing falls back to the default. |
| 322 | let empty = RepoSettings { merge_commit_message: CommitMessageDefault::Custom, merge_commit_template: Some("{body}".into()), ..RepoSettings::default() }; |
| 323 | let blank = MessageFacts { body: "", ..facts(&[], &[]) }; |
| 324 | assert_eq!(message(MergeMethod::Merge, &empty, &blank).0, "Merge pull request #12 from ana/forks"); |
| 325 | assert!(!needs_commits(MergeMethod::Merge, &echo)); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn what_the_merger_writes_wins() { |
| 330 | let default = Some(("Add forks (#12)".to_owned(), "* One".to_owned())); |
| 331 | assert_eq!(chosen(default.clone(), None, None), Ok(Some("Add forks (#12)\n\n* One".into()))); |
| 332 | assert_eq!(chosen(default.clone(), Some(" Mine "), None), Ok(Some("Mine\n\n* One".into()))); |
| 333 | assert_eq!(chosen(default.clone(), None, Some("")), Ok(Some("Add forks (#12)".into()))); |
| 334 | assert_eq!(chosen(default.clone(), Some("Mine"), Some("Why\n")), Ok(Some("Mine\n\nWhy".into()))); |
| 335 | assert!(chosen(default.clone(), Some(" "), None).is_err()); |
| 336 | assert!(chosen(default.clone(), Some("a\nb"), None).is_err()); |
| 337 | assert!(chosen(default.clone(), Some(&"x".repeat(MAX_TITLE_CHARS + 1)), None).is_err()); |
| 338 | // Nothing to write for a rebase or fast-forward. |
| 339 | assert_eq!(chosen(None, Some("Mine"), None), Ok(None)); |
| 340 | } |
| 341 | |
| 342 | #[test] |
| 343 | fn a_message_splits_into_title_and_body() { |
| 344 | assert_eq!(split("Title\n\n\nBody\n"), ("Title".into(), "Body".into())); |
| 345 | assert_eq!(split(" Title "), ("Title".into(), String::new())); |
| 346 | assert_eq!(compose("Title", " "), "Title"); |
| 347 | } |
| 348 | } |