| 8 | 8 | | |
| 9 | 9 | | use g1t_contracts::access::{self, Capability}; |
| 10 | 10 | | use g1t_contracts::events::PullEvent; |
| 11 | | − | use g1t_contracts::repos::{CreateBranchArgs, HeadArgs, RemoveBranchArgs, Repo, RepoPath}; |
| 11 | + | use g1t_contracts::repos::{CreateBranchArgs, Divergence, HeadArgs, RemoveBranchArgs, Repo, RepoPath}; |
| 12 | 12 | | use g1t_contracts::rules::{InspectCommitsArgs, InspectedCommits, MergeMethod}; |
| 13 | 13 | | use g1t_contracts::work::*; |
| 14 | 14 | | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User}; |
| 15 | + | use g1t_kit::now_ms; |
| 15 | 16 | | use g1t_rules::merge::{Requirements, method_label}; |
| 17 | + | use serde::Deserialize; |
| 16 | 18 | | use worker::Result; |
| 17 | 19 | | |
| 18 | 20 | | use crate::merge_message::{self, CommitText, MessageFacts}; |
| ⋯ |
| 31 | 33 | | |
| 32 | 34 | | /// How many of a pull request's commits its default messages read. |
| 33 | 35 | | const MESSAGE_COMMITS: u32 = 250; |
| 36 | + | /// The most JSON of commits kept for one pull request (D1 holds 2 MB to a |
| 37 | + | /// row); a pull request with more has them read each time. |
| 38 | + | const MAX_KEPT_COMMITS: usize = 1_000_000; |
| 39 | + | |
| 40 | + | /// The commits kept for a pull request's default messages |
| 41 | + | /// (`pull_message_commits`), with its mergeability's pair beside them. |
| 42 | + | #[derive(Deserialize)] |
| 43 | + | struct KeptCommitsRow { |
| 44 | + | #[serde(default)] |
| 45 | + | head: Option<String>, |
| 46 | + | #[serde(default)] |
| 47 | + | pair: Option<String>, |
| 48 | + | #[serde(default)] |
| 49 | + | commits: Option<String>, |
| 50 | + | #[serde(default)] |
| 51 | + | mergeable_key: Option<String>, |
| 52 | + | } |
| 34 | 53 | | |
| 54 | + | /// Whether commits kept for `kept_head`, last confirmed for the pair of |
| 55 | + | /// commits `kept_pair`, are a pull request's now: its head is `kept_head` |
| 56 | + | /// and its mergeability was last worked out for that same pair. A push to |
| 57 | + | /// either side moves `mergeable_key` before they are kept again, so a list |
| 58 | + | /// read for another head or another base is never used. |
| 59 | + | pub(crate) fn kept_is_current(kept_head: Option<&str>, kept_pair: Option<&str>, head: Option<&str>, mergeable_key: Option<&str>) -> bool { |
| 60 | + | let (Some(kept_head), Some(kept_pair), Some(head), Some(mergeable_key)) = (kept_head, kept_pair, head, mergeable_key) else { |
| 61 | + | return false; |
| 62 | + | }; |
| 63 | + | kept_head == head && kept_pair == mergeable_key && mergeable_key.split_once("..").is_some_and(|(of, _)| of == head) |
| 64 | + | } |
| 65 | + | |
| 66 | + | /// What to do with the commits kept for a pull request when its |
| 67 | + | /// mergeability is worked out for a divergence. |
| 68 | + | #[derive(Debug, PartialEq, Eq)] |
| 69 | + | pub(crate) enum Keeping { |
| 70 | + | /// Kept for this pair of commits already. |
| 71 | + | Current, |
| 72 | + | /// The list holds (same head, same merge base: its base moved without |
| 73 | + | /// taking any of its commits); only the pair it holds for moves on. |
| 74 | + | Confirm, |
| 75 | + | /// Read them again: its head or where it left its base moved, or none |
| 76 | + | /// are kept. |
| 77 | + | Read, |
| 78 | + | /// Keep nothing: it shares no history with its base, so its commits |
| 79 | + | /// are read live each time. |
| 80 | + | Nothing, |
| 81 | + | } |
| 82 | + | |
| 83 | + | /// See [`Keeping`]. `kept` is what is stored: head, merge base and pair. |
| 84 | + | pub(crate) fn keeping(kept: Option<(&str, Option<&str>, &str)>, divergence: &Divergence) -> Keeping { |
| 85 | + | let Some(merge_base) = divergence.merge_base.as_deref() else { |
| 86 | + | return Keeping::Nothing; |
| 87 | + | }; |
| 88 | + | let pair = format!("{}..{}", divergence.head, divergence.base); |
| 89 | + | match kept { |
| 90 | + | Some((head, kept_base, kept_pair)) if head == divergence.head && kept_base == Some(merge_base) => { |
| 91 | + | if kept_pair == pair { Keeping::Current } else { Keeping::Confirm } |
| 92 | + | } |
| 93 | + | _ => Keeping::Read, |
| 94 | + | } |
| 95 | + | } |
| 96 | + | |
| 97 | + | /// A pull request's commits as its default messages read them, oldest |
| 98 | + | /// first, merge commits left out. |
| 99 | + | fn texts_of(inspected: InspectedCommits) -> Vec<CommitText> { |
| 100 | + | inspected |
| 101 | + | .commits |
| 102 | + | .into_iter() |
| 103 | + | .rev() |
| 104 | + | .filter(|commit| commit.parents <= 1) |
| 105 | + | .map(|commit| CommitText { message: commit.message, author_email: commit.author_email }) |
| 106 | + | .collect() |
| 107 | + | } |
| 108 | + | |
| 109 | + | /// The default title and body of the commit each of `methods` makes for |
| 110 | + | /// `pull`, from its `commits` (oldest first): `None` for one that makes no |
| 111 | + | /// commit of its own. |
| 112 | + | fn messages_for( |
| 113 | + | repo: &Repo, |
| 114 | + | pull: &Pull, |
| 115 | + | settings: &RepoSettings, |
| 116 | + | methods: &[MergeMethod], |
| 117 | + | commits: &[CommitText], |
| 118 | + | ) -> Vec<(MergeMethod, Option<(String, String)>)> { |
| 119 | + | let owner = pull.owner().username.clone(); |
| 120 | + | let (head_owner, head_repo) = match (&pull.fork, &pull.head_repo) { |
| 121 | + | // A working copy is whoever asked for the change's. |
| 122 | + | (Some(copy), _) => (owner.clone(), format!("{}/{}", copy.namespace, copy.name)), |
| 123 | + | (None, Some(fork)) => (fork.namespace.clone(), format!("{}/{}", fork.namespace, fork.name)), |
| 124 | + | (None, None) => (repo.namespace.clone(), format!("{}/{}", repo.namespace, repo.name)), |
| 125 | + | }; |
| 126 | + | let branch = pull |
| 127 | + | .branch |
| 128 | + | .clone() |
| 129 | + | .or_else(|| pull.fork.as_ref().map(|copy| copy.name.clone())) |
| 130 | + | .unwrap_or_default(); |
| 131 | + | let issues = issues_to_close(pull, repo, settings, false); |
| 132 | + | let facts = MessageFacts { |
| 133 | + | number: pull.number, |
| 134 | + | title: &pull.title, |
| 135 | + | body: pull.body.as_deref().unwrap_or_default(), |
| 136 | + | branch: &branch, |
| 137 | + | head_owner: &head_owner, |
| 138 | + | head_repo: &head_repo, |
| 139 | + | base: pull.base_branch(&repo.default_branch), |
| 140 | + | author: &owner, |
| 141 | + | commits, |
| 142 | + | issues: &issues, |
| 143 | + | }; |
| 144 | + | methods.iter().map(|method| (*method, merge_message::default_message(*method, settings, &facts))).collect() |
| 145 | + | } |
| 146 | + | |
| 35 | 147 | | /// Methods named for people, joined: "squash or rebase". |
| 36 | 148 | | fn labels(methods: &[MergeMethod]) -> String { |
| 37 | 149 | | let named: Vec<&str> = methods.iter().map(|method| method_label(*method)).collect(); |
| ⋯ |
| 178 | 290 | | } |
| 179 | 291 | | |
| 180 | 292 | | impl Work { |
| 181 | | − | /// A pull request's commits as its default messages read them, oldest |
| 182 | | − | /// first, merge commits left out. None when they cannot be read. |
| 183 | | − | async fn commit_texts(&self, repo: &Repo, pull: &Pull) -> Result<Vec<CommitText>> { |
| 293 | + | /// A pull request's commits as its default messages read them, read |
| 294 | + | /// from its source now: only their messages, from a pack fetched from |
| 295 | + | /// `merge_base` when that is known. Empty when they cannot be read. |
| 296 | + | async fn read_commit_texts( |
| 297 | + | &self, |
| 298 | + | pull: &Pull, |
| 299 | + | target_id: &str, |
| 300 | + | base_branch: &str, |
| 301 | + | merge_base: Option<String>, |
| 302 | + | ) -> Result<Vec<CommitText>> { |
| 184 | 303 | | let Some(head) = pull.head_commit.clone() else { |
| 185 | 304 | | return Ok(Vec::new()); |
| 186 | 305 | | }; |
| ⋯ |
| 190 | 309 | | &InspectCommitsArgs { |
| 191 | 310 | | source_id: pull.source_id().to_owned(), |
| 192 | 311 | | head, |
| 193 | | − | target_id: repo.id.clone(), |
| 194 | | − | base_branch: pull.base_branch(&repo.default_branch).to_owned(), |
| 312 | + | target_id: target_id.to_owned(), |
| 313 | + | base_branch: base_branch.to_owned(), |
| 195 | 314 | | limit: Some(MESSAGE_COMMITS), |
| 315 | + | merge_base, |
| 316 | + | messages_only: true, |
| 196 | 317 | | }, |
| 197 | 318 | | ) |
| 198 | 319 | | .await?; |
| 199 | | − | let Outcome::Ok(inspected) = found else { |
| 200 | | − | return Ok(Vec::new()); |
| 201 | | − | }; |
| 202 | | − | // Read newest first. |
| 203 | | − | Ok(inspected |
| 204 | | − | .commits |
| 205 | | − | .into_iter() |
| 206 | | − | .rev() |
| 207 | | − | .filter(|commit| commit.parents <= 1) |
| 208 | | − | .map(|commit| CommitText { message: commit.message, author_email: commit.author_email }) |
| 209 | | − | .collect()) |
| 320 | + | Ok(match found { |
| 321 | + | Outcome::Ok(inspected) => texts_of(inspected), |
| 322 | + | Outcome::Fail(_) => Vec::new(), |
| 323 | + | }) |
| 324 | + | } |
| 325 | + | |
| 326 | + | /// The commits kept for a pull request's default messages, if they are |
| 327 | + | /// its commits as it is now ([`kept_is_current`]). One database read. |
| 328 | + | async fn kept_commit_texts(&self, pull: &Pull) -> Result<Option<Vec<CommitText>>> { |
| 329 | + | if !pull.status.is_active() || pull.head_commit.is_none() { |
| 330 | + | return Ok(None); |
| 331 | + | } |
| 332 | + | let row = self |
| 333 | + | .timing |
| 334 | + | .db( |
| 335 | + | 1, |
| 336 | + | self.db |
| 337 | + | .prepare( |
| 338 | + | "SELECT kept.head, kept.pair, kept.commits, pulls.mergeable_key |
| 339 | + | FROM pulls LEFT JOIN pull_message_commits AS kept ON kept.pull_id = pulls.id |
| 340 | + | WHERE pulls.id = ?", |
| 341 | + | ) |
| 342 | + | .bind(&[pull.id.as_str().into()])? |
| 343 | + | .first::<KeptCommitsRow>(None), |
| 344 | + | ) |
| 345 | + | .await?; |
| 346 | + | let Some(row) = row else { return Ok(None) }; |
| 347 | + | if !kept_is_current(row.head.as_deref(), row.pair.as_deref(), pull.head_commit.as_deref(), row.mergeable_key.as_deref()) { |
| 348 | + | return Ok(None); |
| 349 | + | } |
| 350 | + | Ok(row.commits.as_deref().and_then(|commits| serde_json::from_str(commits).ok())) |
| 351 | + | } |
| 352 | + | |
| 353 | + | /// A pull request's commits as its default messages read them, oldest |
| 354 | + | /// first, merge commits left out: those kept for it when they are |
| 355 | + | /// current, else read now. |
| 356 | + | async fn commit_texts(&self, repo: &Repo, pull: &Pull) -> Result<Vec<CommitText>> { |
| 357 | + | if let Some(kept) = self.kept_commit_texts(pull).await? { |
| 358 | + | return Ok(kept); |
| 359 | + | } |
| 360 | + | self.read_commit_texts(pull, &repo.id, pull.base_branch(&repo.default_branch), None).await |
| 210 | 361 | | } |
| 211 | 362 | | |
| 363 | + | /// Keeps a pull request's commits for its default messages, once its |
| 364 | + | /// mergeability was worked out for `divergence` after a push, so that |
| 365 | + | /// the merge box reads them from here. They are read again only when |
| 366 | + | /// its head or where it left its base moved; a push to its base that |
| 367 | + | /// took none of its commits only moves the pair they hold for. Nothing |
| 368 | + | /// is read when no method the repository allows writes commits into a |
| 369 | + | /// message. |
| 370 | + | pub(crate) async fn keep_commit_texts(&self, pull: &Pull, divergence: &Divergence) -> Result<()> { |
| 371 | + | if !pull.status.is_active() || pull.head_commit.as_deref() != Some(divergence.head.as_str()) { |
| 372 | + | return Ok(()); |
| 373 | + | } |
| 374 | + | #[derive(Deserialize)] |
| 375 | + | struct Kept { |
| 376 | + | head: String, |
| 377 | + | merge_base: Option<String>, |
| 378 | + | pair: String, |
| 379 | + | } |
| 380 | + | let (settings, kept) = futures_util::future::try_join( |
| 381 | + | self.settings(&pull.repo_id), |
| 382 | + | self.db |
| 383 | + | .prepare("SELECT head, merge_base, pair FROM pull_message_commits WHERE pull_id = ?") |
| 384 | + | .bind(&[pull.id.as_str().into()])? |
| 385 | + | .first::<Kept>(None), |
| 386 | + | ) |
| 387 | + | .await?; |
| 388 | + | if !settings.allowed_methods().into_iter().any(|method| merge_message::needs_commits(method, &settings)) { |
| 389 | + | return Ok(()); |
| 390 | + | } |
| 391 | + | let pair = format!("{}..{}", divergence.head, divergence.base); |
| 392 | + | let now = g1t_contracts::time::rfc3339(now_ms()); |
| 393 | + | match keeping(kept.as_ref().map(|kept| (kept.head.as_str(), kept.merge_base.as_deref(), kept.pair.as_str())), divergence) { |
| 394 | + | Keeping::Current | Keeping::Nothing => Ok(()), |
| 395 | + | Keeping::Confirm => { |
| 396 | + | self.db |
| 397 | + | .prepare("UPDATE pull_message_commits SET pair = ?, updated_at = ? WHERE pull_id = ? AND head = ?") |
| 398 | + | .bind(&[pair.into(), now.into(), pull.id.as_str().into(), divergence.head.as_str().into()])? |
| 399 | + | .run() |
| 400 | + | .await?; |
| 401 | + | Ok(()) |
| 402 | + | } |
| 403 | + | Keeping::Read => { |
| 404 | + | let texts = self |
| 405 | + | .read_commit_texts(pull, &pull.repo_id, pull.base.as_deref().unwrap_or_default(), divergence.merge_base.clone()) |
| 406 | + | .await?; |
| 407 | + | let commits = serde_json::to_string(&texts)?; |
| 408 | + | if commits.len() > MAX_KEPT_COMMITS { |
| 409 | + | self.db |
| 410 | + | .prepare("DELETE FROM pull_message_commits WHERE pull_id = ?") |
| 411 | + | .bind(&[pull.id.as_str().into()])? |
| 412 | + | .run() |
| 413 | + | .await?; |
| 414 | + | return Ok(()); |
| 415 | + | } |
| 416 | + | self.db |
| 417 | + | .prepare( |
| 418 | + | "INSERT INTO pull_message_commits (pull_id, head, merge_base, pair, commits, updated_at) |
| 419 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6) |
| 420 | + | ON CONFLICT (pull_id) DO UPDATE SET head = ?2, merge_base = ?3, pair = ?4, commits = ?5, updated_at = ?6", |
| 421 | + | ) |
| 422 | + | .bind(&[ |
| 423 | + | pull.id.as_str().into(), |
| 424 | + | divergence.head.as_str().into(), |
| 425 | + | crate::optional(&divergence.merge_base), |
| 426 | + | pair.into(), |
| 427 | + | commits.into(), |
| 428 | + | now.into(), |
| 429 | + | ])? |
| 430 | + | .run() |
| 431 | + | .await?; |
| 432 | + | Ok(()) |
| 433 | + | } |
| 434 | + | } |
| 435 | + | } |
| 436 | + | |
| 212 | 437 | | /// The default title and body of the commit each of `methods` makes |
| 213 | 438 | | /// for `pull`, as the repository's settings write them: `None` for one |
| 214 | 439 | | /// that makes no commit of its own. |
| ⋯ |
| 223 | 448 | | self.commit_texts(repo, pull).await? |
| 224 | 449 | | } else { |
| 225 | 450 | | Vec::new() |
| 226 | | − | }; |
| 227 | | − | let owner = pull.owner().username.clone(); |
| 228 | | − | let (head_owner, head_repo) = match (&pull.fork, &pull.head_repo) { |
| 229 | | − | // A working copy is whoever asked for the change's. |
| 230 | | − | (Some(copy), _) => (owner.clone(), format!("{}/{}", copy.namespace, copy.name)), |
| 231 | | − | (None, Some(fork)) => (fork.namespace.clone(), format!("{}/{}", fork.namespace, fork.name)), |
| 232 | | − | (None, None) => (repo.namespace.clone(), format!("{}/{}", repo.namespace, repo.name)), |
| 233 | 451 | | }; |
| 234 | | − | let branch = pull |
| 235 | | − | .branch |
| 236 | | − | .clone() |
| 237 | | − | .or_else(|| pull.fork.as_ref().map(|copy| copy.name.clone())) |
| 238 | | − | .unwrap_or_default(); |
| 239 | | − | let issues = issues_to_close(pull, repo, settings, false); |
| 240 | | − | let facts = MessageFacts { |
| 241 | | − | number: pull.number, |
| 242 | | − | title: &pull.title, |
| 243 | | − | body: pull.body.as_deref().unwrap_or_default(), |
| 244 | | − | branch: &branch, |
| 245 | | − | head_owner: &head_owner, |
| 246 | | − | head_repo: &head_repo, |
| 247 | | − | base: pull.base_branch(&repo.default_branch), |
| 248 | | − | author: &owner, |
| 249 | | − | commits: &commits, |
| 250 | | − | issues: &issues, |
| 251 | | − | }; |
| 252 | | − | Ok(methods.iter().map(|method| (*method, merge_message::default_message(*method, settings, &facts))).collect()) |
| 452 | + | Ok(messages_for(repo, pull, settings, methods, &commits)) |
| 253 | 453 | | } |
| 254 | 454 | | |
| 255 | 455 | | /// The whole message of the commit a merge by `method` makes: what the |
| ⋯ |
| 281 | 481 | | let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?); |
| 282 | 482 | | crate::fill_base(&mut pull, &repo); |
| 283 | 483 | | let base = pull.base_branch(&repo.default_branch).to_owned(); |
| 284 | | − | let ((settings, requirements), behind) = |
| 285 | | − | futures_util::future::try_join(self.settings_and_requirements(&repo, &pull), self.behind_now(&pull)).await?; |
| 484 | + | // The commits kept for its messages are read beside its settings; |
| 485 | + | // only when none are current are they read from its source. |
| 486 | + | let ((settings, requirements), behind, kept) = futures_util::future::try_join3( |
| 487 | + | self.settings_and_requirements(&repo, &pull), |
| 488 | + | self.behind_now(&pull), |
| 489 | + | self.kept_commit_texts(&pull), |
| 490 | + | ) |
| 491 | + | .await?; |
| 286 | 492 | | let methods = offered(&settings, &requirements); |
| 287 | 493 | | let default_method = choose_method(None, &settings, &requirements, &base).ok(); |
| 288 | 494 | | let viewer = a.viewer.as_ref(); |
| ⋯ |
| 295 | 501 | | (None, true) => true, |
| 296 | 502 | | (None, false) => access::can(Some(viewer), &repo, Capability::Push), |
| 297 | 503 | | }); |
| 298 | | − | let messages = self.default_messages(&repo, &pull, &settings, &methods).await?; |
| 504 | + | let commits = if !methods.iter().any(|method| merge_message::needs_commits(*method, &settings)) { |
| 505 | + | Vec::new() |
| 506 | + | } else if let Some(kept) = kept { |
| 507 | + | kept |
| 508 | + | } else if pull.status.is_active() { |
| 509 | + | self.read_commit_texts(&pull, &repo.id, &base, None).await? |
| 510 | + | } else { |
| 511 | + | // A merged or closed one merges no more. |
| 512 | + | Vec::new() |
| 513 | + | }; |
| 514 | + | let messages = messages_for(&repo, &pull, &settings, &methods, &commits); |
| 299 | 515 | | let options = messages |
| 300 | 516 | | .into_iter() |
| 301 | 517 | | .map(|(method, message)| { |
| ⋯ |
| 627 | 843 | | assert!(issues_to_close(&fixes, &repo, &settings, false).is_empty()); |
| 628 | 844 | | } |
| 629 | 845 | | |
| 846 | + | fn diverged(head: &str, base: &str, merge_base: Option<&str>) -> Divergence { |
| 847 | + | Divergence { head: head.into(), base: base.into(), merge_base: merge_base.map(Into::into), ..Divergence::default() } |
| 848 | + | } |
| 849 | + | |
| 850 | + | #[test] |
| 851 | + | fn kept_commits_are_used_only_for_the_head_and_pair_they_were_kept_for() { |
| 852 | + | let current = |kept_head, kept_pair, head, key| kept_is_current(kept_head, kept_pair, head, key); |
| 853 | + | assert!(current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h1..b1"))); |
| 854 | + | // Its head moved: pushed to since they were kept. |
| 855 | + | assert!(!current(Some("h1"), Some("h1..b1"), Some("h2"), Some("h1..b1"))); |
| 856 | + | // Its base moved and mergeability was worked out again without them |
| 857 | + | // being confirmed for the new pair. |
| 858 | + | assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h1..b2"))); |
| 859 | + | // Mergeability worked out for another head than the one it has now. |
| 860 | + | assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h0..b1"))); |
| 861 | + | assert!(!current(Some("h1"), Some("h0..b1"), Some("h1"), Some("h0..b1"))); |
| 862 | + | // Nothing kept, nothing worked out, or nothing pushed. |
| 863 | + | assert!(!current(None, None, Some("h1"), Some("h1..b1"))); |
| 864 | + | assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), None)); |
| 865 | + | assert!(!current(Some("h1"), Some("h1..b1"), None, Some("h1..b1"))); |
| 866 | + | } |
| 867 | + | |
| 868 | + | #[test] |
| 869 | + | fn commits_are_read_again_only_when_the_head_or_merge_base_moves() { |
| 870 | + | let now = diverged("h1", "b1", Some("m1")); |
| 871 | + | assert_eq!(keeping(None, &now), Keeping::Read, "none kept"); |
| 872 | + | assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b1")), &now), Keeping::Current); |
| 873 | + | // Its base moved on without taking any of its commits. |
| 874 | + | assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b0")), &now), Keeping::Confirm); |
| 875 | + | // Pushed to. |
| 876 | + | assert_eq!(keeping(Some(("h0", Some("m1"), "h0..b1")), &now), Keeping::Read); |
| 877 | + | // Its base took some of its commits, or it caught up with its base. |
| 878 | + | assert_eq!(keeping(Some(("h1", Some("m0"), "h1..b0")), &now), Keeping::Read); |
| 879 | + | assert_eq!(keeping(Some(("h1", None, "h1..b0")), &now), Keeping::Read); |
| 880 | + | // No history in common: nothing is kept, and the box reads them live. |
| 881 | + | assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b1")), &diverged("h1", "b1", None)), Keeping::Nothing); |
| 882 | + | } |
| 883 | + | |
| 884 | + | #[test] |
| 885 | + | fn kept_commits_read_back_as_they_were_written() { |
| 886 | + | let texts = vec![ |
| 887 | + | CommitText { message: "feat: one\n\nWhy.".into(), author_email: Some("ada@acme.com".into()) }, |
| 888 | + | CommitText { message: "fix: two".into(), author_email: None }, |
| 889 | + | ]; |
| 890 | + | let json = serde_json::to_string(&texts).unwrap(); |
| 891 | + | assert_eq!(serde_json::from_str::<Vec<CommitText>>(&json).unwrap(), texts); |
| 892 | + | } |
| 893 | + | |
| 894 | + | #[test] |
| 895 | + | fn merge_commits_are_left_out_of_the_messages_oldest_first() { |
| 896 | + | let commit = |sha: &str, parents| g1t_contracts::rules::CommitFacts { |
| 897 | + | sha: sha.into(), |
| 898 | + | message: sha.into(), |
| 899 | + | parents, |
| 900 | + | ..g1t_contracts::rules::CommitFacts::default() |
| 901 | + | }; |
| 902 | + | let inspected = InspectedCommits { commits: vec![commit("newest", 1), commit("merge", 2), commit("oldest", 0)], complete: true }; |
| 903 | + | let texts: Vec<String> = texts_of(inspected).into_iter().map(|text| text.message).collect(); |
| 904 | + | assert_eq!(texts, ["oldest", "newest"]); |
| 905 | + | } |
| 906 | + | |
| 630 | 907 | | #[test] |
| 631 | 908 | | fn the_timeline_says_how_it_merged() { |
| 632 | 909 | | assert_eq!(merged_note(MergeMethod::Squash), "squashed and merged this"); |