| 1 | //! How a pull request merges: by which method (the repository's settings, |
| 2 | //! narrowed by the rules for the branch it merges into), with what commit |
| 3 | //! message (merge_message.rs), what happens when its base has moved, and |
| 4 | //! its branch afterwards, deleted and restored. |
| 5 | //! |
| 6 | //! `merge_pull` (lib.rs) asks these questions; `merge_choices` answers them |
| 7 | //! for the merge box before anyone clicks. |
| 8 | |
| 9 | use g1t_contracts::access::{self, Capability}; |
| 10 | use g1t_contracts::events::PullEvent; |
| 11 | use g1t_contracts::repos::{CreateBranchArgs, Divergence, HeadArgs, RemoveBranchArgs, Repo, RepoPath}; |
| 12 | use g1t_contracts::rules::{InspectCommitsArgs, InspectedCommits, MergeMethod}; |
| 13 | use g1t_contracts::work::*; |
| 14 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User}; |
| 15 | use g1t_kit::now_ms; |
| 16 | use g1t_rules::merge::{Requirements, method_label}; |
| 17 | use serde::Deserialize; |
| 18 | use worker::Result; |
| 19 | |
| 20 | use crate::merge_message::{self, CommitText, MessageFacts}; |
| 21 | use crate::retired::writable; |
| 22 | use crate::{Work, allowed, no_pull}; |
| 23 | |
| 24 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 25 | macro_rules! check { |
| 26 | ($outcome:expr) => { |
| 27 | match $outcome { |
| 28 | Outcome::Ok(value) => value, |
| 29 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 30 | } |
| 31 | }; |
| 32 | } |
| 33 | |
| 34 | /// How many of a pull request's commits its default messages read. |
| 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 | } |
| 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 | |
| 147 | /// Methods named for people, joined: "squash or rebase". |
| 148 | fn labels(methods: &[MergeMethod]) -> String { |
| 149 | let named: Vec<&str> = methods.iter().map(|method| method_label(*method)).collect(); |
| 150 | match named.as_slice() { |
| 151 | [] => "no method".to_owned(), |
| 152 | [one] => (*one).to_owned(), |
| 153 | [rest @ .., last] => format!("{} or {last}", rest.join(", ")), |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /// The methods a pull request may merge by: those the repository allows, |
| 158 | /// as the rules for its base allow them, in the order offered. |
| 159 | pub(crate) fn offered(settings: &RepoSettings, requirements: &Requirements) -> Vec<MergeMethod> { |
| 160 | settings.allowed_methods().into_iter().filter(|method| requirements.allows_method(*method)).collect() |
| 161 | } |
| 162 | |
| 163 | /// The method a merge uses: the one asked for, if the repository and the |
| 164 | /// rules for `base` allow it; with none asked for, the repository's |
| 165 | /// default where it may be used, else the first that may. Why not, if |
| 166 | /// none can. |
| 167 | pub(crate) fn choose_method( |
| 168 | asked: Option<MergeMethod>, |
| 169 | settings: &RepoSettings, |
| 170 | requirements: &Requirements, |
| 171 | base: &str, |
| 172 | ) -> std::result::Result<MergeMethod, String> { |
| 173 | let offered = offered(settings, requirements); |
| 174 | let Some(method) = asked else { |
| 175 | return if offered.contains(&settings.default_merge_method) { |
| 176 | Ok(settings.default_merge_method) |
| 177 | } else { |
| 178 | offered.first().copied().ok_or_else(|| { |
| 179 | format!( |
| 180 | "No merge method this repository allows ({}) is allowed by the rules for {base}. Its admins can allow another in Settings.", |
| 181 | labels(&settings.allowed_methods()) |
| 182 | ) |
| 183 | }) |
| 184 | }; |
| 185 | }; |
| 186 | if !settings.allows(method) { |
| 187 | return Err(format!( |
| 188 | "This repository does not allow merging by {}. It allows {}.", |
| 189 | method_label(method), |
| 190 | labels(&settings.allowed_methods()) |
| 191 | )); |
| 192 | } |
| 193 | if requirements.linear_history && !method.is_linear() { |
| 194 | return Err(format!( |
| 195 | "{base} requires linear history, so a merge commit cannot land there. Merge by {} instead.", |
| 196 | labels(&offered) |
| 197 | )); |
| 198 | } |
| 199 | if !requirements.allows_method(method) { |
| 200 | return Err(format!( |
| 201 | "The rules for {base} allow merging only by {}, not by {}.", |
| 202 | labels(requirements.allowed_merge_methods.as_deref().unwrap_or_default()), |
| 203 | method_label(method) |
| 204 | )); |
| 205 | } |
| 206 | Ok(method) |
| 207 | } |
| 208 | |
| 209 | /// What a merge does about its base having moved without it. |
| 210 | #[derive(Debug, PartialEq, Eq)] |
| 211 | pub(crate) enum WhenBehind { |
| 212 | /// Land it now: it is not behind, or the method makes new commits on |
| 213 | /// the base's head anyway. |
| 214 | Land, |
| 215 | /// Bring it up to date first, and land it once that push arrives: a |
| 216 | /// fast-forward needs the base's head in it. |
| 217 | CatchUpFirst, |
| 218 | /// Refuse, saying why. |
| 219 | Refuse(String), |
| 220 | } |
| 221 | |
| 222 | /// What to do when `behind`, for a merge by `method`. |
| 223 | pub(crate) fn when_behind(method: MergeMethod, behind: bool, require_up_to_date: bool, base: &str) -> WhenBehind { |
| 224 | if !behind { |
| 225 | return WhenBehind::Land; |
| 226 | } |
| 227 | if require_up_to_date { |
| 228 | return WhenBehind::Refuse(format!( |
| 229 | "{base} has moved since this pull request was made, and this repository requires pull requests to be up to date before they merge. Catch up with {base} first." |
| 230 | )); |
| 231 | } |
| 232 | match method { |
| 233 | MergeMethod::FastForward => WhenBehind::CatchUpFirst, |
| 234 | MergeMethod::Merge | MergeMethod::Squash | MergeMethod::Rebase => WhenBehind::Land, |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | /// Why a fast-forward cannot catch up first: whoever merges cannot push |
| 239 | /// to where the change is. |
| 240 | pub(crate) fn cannot_fast_forward(base: &str, source: &str) -> String { |
| 241 | format!( |
| 242 | "This pull request can only fast-forward once it is up to date with {base}, and its branch is in {source}, which you cannot push to. Merge it another way, or ask its author to catch it up with {base} first." |
| 243 | ) |
| 244 | } |
| 245 | |
| 246 | /// Who a squash commit is by: the person the pull request is theirs (who |
| 247 | /// asked g1t for it, or its author). The merger, for an agent's or g1t's |
| 248 | /// own, and for any other method. |
| 249 | pub(crate) fn squash_author(pull: &Pull, method: MergeMethod) -> Option<User> { |
| 250 | let owner = pull.owner(); |
| 251 | (method == MergeMethod::Squash && owner.kind == PrincipalKind::User && !g1t_contracts::rules::is_agent(owner)).then(|| owner.clone()) |
| 252 | } |
| 253 | |
| 254 | /// The branch a pull request's merge or close may delete, and where it is: |
| 255 | /// a branch of the repository, never its default branch nor the branch it |
| 256 | /// merges into, or a branch of a fork. Never a working copy's, which repos |
| 257 | /// retires with it. |
| 258 | pub(crate) fn deletable_branch(pull: &Pull, repo: &Repo) -> Option<(RepoPath, String)> { |
| 259 | if pull.fork_repo_id.is_some() { |
| 260 | return None; |
| 261 | } |
| 262 | let branch = pull.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty())?; |
| 263 | if pull.from_head_repo() { |
| 264 | return Some((pull.head_repo.clone()?, branch.to_owned())); |
| 265 | } |
| 266 | if branch == repo.default_branch || branch == pull.base_branch(&repo.default_branch) { |
| 267 | return None; |
| 268 | } |
| 269 | Some((RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, branch.to_owned())) |
| 270 | } |
| 271 | |
| 272 | /// What the timeline says a merge by `method` did. |
| 273 | pub(crate) fn merged_note(method: MergeMethod) -> &'static str { |
| 274 | match method { |
| 275 | MergeMethod::Squash => "squashed and merged this", |
| 276 | MergeMethod::Rebase => "rebased and merged this", |
| 277 | MergeMethod::Merge | MergeMethod::FastForward => "merged this", |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | /// The issues a merge into the default branch closes, where the repository |
| 282 | /// closes them: its own and those it names (closing.rs). None into another |
| 283 | /// branch, or when told to keep them open. |
| 284 | pub(crate) fn issues_to_close(pull: &Pull, repo: &Repo, settings: &RepoSettings, keep_issue_open: bool) -> Vec<u32> { |
| 285 | if keep_issue_open || !settings.auto_close_issues || !pull.targets_default(&repo.default_branch) { |
| 286 | return Vec::new(); |
| 287 | } |
| 288 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; |
| 289 | crate::closing::closes(pull.issue, &pull.title, pull.body.as_deref(), &path) |
| 290 | } |
| 291 | |
| 292 | impl Work { |
| 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>> { |
| 303 | let Some(head) = pull.head_commit.clone() else { |
| 304 | return Ok(Vec::new()); |
| 305 | }; |
| 306 | let found: Outcome<InspectedCommits> = g1t_kit::call( |
| 307 | &self.repos, |
| 308 | "inspect_commits", |
| 309 | &InspectCommitsArgs { |
| 310 | source_id: pull.source_id().to_owned(), |
| 311 | head, |
| 312 | target_id: target_id.to_owned(), |
| 313 | base_branch: base_branch.to_owned(), |
| 314 | limit: Some(MESSAGE_COMMITS), |
| 315 | merge_base, |
| 316 | messages_only: true, |
| 317 | }, |
| 318 | ) |
| 319 | .await?; |
| 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 |
| 361 | } |
| 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 | |
| 437 | /// The default title and body of the commit each of `methods` makes |
| 438 | /// for `pull`, as the repository's settings write them: `None` for one |
| 439 | /// that makes no commit of its own. |
| 440 | pub(crate) async fn default_messages( |
| 441 | &self, |
| 442 | repo: &Repo, |
| 443 | pull: &Pull, |
| 444 | settings: &RepoSettings, |
| 445 | methods: &[MergeMethod], |
| 446 | ) -> Result<Vec<(MergeMethod, Option<(String, String)>)>> { |
| 447 | let commits = if methods.iter().any(|method| merge_message::needs_commits(*method, settings)) { |
| 448 | self.commit_texts(repo, pull).await? |
| 449 | } else { |
| 450 | Vec::new() |
| 451 | }; |
| 452 | Ok(messages_for(repo, pull, settings, methods, &commits)) |
| 453 | } |
| 454 | |
| 455 | /// The whole message of the commit a merge by `method` makes: what the |
| 456 | /// merger wrote, over the repository's default. `None` for a rebase or |
| 457 | /// fast-forward. Or why what they wrote cannot be used. |
| 458 | pub(crate) async fn merge_message_for( |
| 459 | &self, |
| 460 | repo: &Repo, |
| 461 | pull: &Pull, |
| 462 | settings: &RepoSettings, |
| 463 | method: MergeMethod, |
| 464 | title: Option<&str>, |
| 465 | body: Option<&str>, |
| 466 | ) -> Result<std::result::Result<Option<String>, &'static str>> { |
| 467 | if !matches!(method, MergeMethod::Merge | MergeMethod::Squash) { |
| 468 | return Ok(Ok(None)); |
| 469 | } |
| 470 | // Nothing of the default is used when the merger wrote both. |
| 471 | let default = if title.is_some() && body.is_some() { |
| 472 | Some((String::new(), String::new())) |
| 473 | } else { |
| 474 | self.default_messages(repo, pull, settings, &[method]).await?.pop().and_then(|(_, message)| message) |
| 475 | }; |
| 476 | Ok(merge_message::chosen(default, title, body)) |
| 477 | } |
| 478 | |
| 479 | /// `merge_choices`: how a pull request may merge now, for the merge box. |
| 480 | pub(crate) async fn merge_choices(&self, a: ViewArgs) -> Result<Outcome<MergeChoices>> { |
| 481 | let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?); |
| 482 | crate::fill_base(&mut pull, &repo); |
| 483 | let base = pull.base_branch(&repo.default_branch).to_owned(); |
| 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?; |
| 492 | let methods = offered(&settings, &requirements); |
| 493 | let default_method = choose_method(None, &settings, &requirements, &base).ok(); |
| 494 | let viewer = a.viewer.as_ref(); |
| 495 | // Whoever looks may catch it up to fast-forward it: they could push |
| 496 | // the merge themselves (catch_up_pull). |
| 497 | let can_catch_up = viewer.is_some_and(|viewer| match (&pull.fork_repo_id, pull.from_head_repo()) { |
| 498 | (Some(_), _) => pull.is_owned_by(&viewer.id), |
| 499 | // A fork's branch takes pushes from whoever repos lets push |
| 500 | // there, which is not known here: offered, and refused clearly. |
| 501 | (None, true) => true, |
| 502 | (None, false) => access::can(Some(viewer), &repo, Capability::Push), |
| 503 | }); |
| 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); |
| 515 | let options = messages |
| 516 | .into_iter() |
| 517 | .map(|(method, message)| { |
| 518 | let unavailable = if behind && settings.require_up_to_date { |
| 519 | Some(format!("This repository requires it to be up to date with {base} before it merges.")) |
| 520 | } else { |
| 521 | (method == MergeMethod::FastForward && behind && !can_catch_up) |
| 522 | .then(|| format!("It can only fast-forward once it is up to date with {base}.")) |
| 523 | }; |
| 524 | let (commit_title, commit_message) = match message { |
| 525 | Some((title, body)) => (Some(title), Some(body)), |
| 526 | None => (None, None), |
| 527 | }; |
| 528 | MergeChoice { method, commit_title, commit_message, unavailable } |
| 529 | }) |
| 530 | .collect(); |
| 531 | let can_delete_branch = deletable_branch(&pull, &repo).is_some(); |
| 532 | let auto_merge_allowed = settings.allow_auto_merge |
| 533 | && pull.status.is_active() |
| 534 | && viewer.is_some_and(|viewer| access::can(Some(viewer), &repo, Capability::Merge)); |
| 535 | Ok(Outcome::Ok(MergeChoices { |
| 536 | methods, |
| 537 | default_method, |
| 538 | options, |
| 539 | behind, |
| 540 | can_fast_forward: !behind, |
| 541 | suggest_update: behind && (settings.suggest_updating_branches || settings.require_up_to_date), |
| 542 | require_up_to_date: settings.require_up_to_date, |
| 543 | merge_queue: settings.merge_queue, |
| 544 | auto_merge_allowed, |
| 545 | delete_branch_default: settings.delete_branch_on_merge && can_delete_branch, |
| 546 | can_delete_branch, |
| 547 | sign_off: settings.require_web_commit_sign_off, |
| 548 | })) |
| 549 | } |
| 550 | |
| 551 | /// Whether another open pull request still needs `branch` of |
| 552 | /// `source_id`: one merging into it, or one opened from it into |
| 553 | /// another branch. |
| 554 | async fn branch_in_use(&self, pull: &Pull, branch: &str) -> Result<bool> { |
| 555 | Ok(self |
| 556 | .db |
| 557 | .prepare( |
| 558 | "SELECT 1 AS value FROM pulls |
| 559 | WHERE id <> ?1 AND status IN ('draft', 'open') AND fork_repo_id IS NULL |
| 560 | AND ((repo_id = ?2 AND base_branch = ?3 AND ?4 IS NULL) |
| 561 | OR (source_branch = ?3 AND COALESCE(head_repo_id, repo_id) = ?5)) |
| 562 | LIMIT 1", |
| 563 | ) |
| 564 | .bind(&[ |
| 565 | pull.id.as_str().into(), |
| 566 | pull.repo_id.as_str().into(), |
| 567 | branch.into(), |
| 568 | crate::optional(&pull.head_repo_id), |
| 569 | pull.source_id().into(), |
| 570 | ])? |
| 571 | .first::<u32>(Some("value")) |
| 572 | .await? |
| 573 | .is_some()) |
| 574 | } |
| 575 | |
| 576 | /// Deletes a pull request's branch as `actor`, if it may be deleted and |
| 577 | /// still points at `head`, and says so. Repos decides whether `actor` |
| 578 | /// may push there and whether the rules for the branch let it go. |
| 579 | /// Whether it was deleted, or why not. |
| 580 | async fn remove_head_branch(&self, repo: &Repo, pull: &Pull, actor: &User, head: Option<String>) -> Result<Outcome<()>> { |
| 581 | let Some((path, branch)) = deletable_branch(pull, repo) else { |
| 582 | return Ok(Outcome::fail( |
| 583 | FailureCode::Invalid, |
| 584 | if pull.fork_repo_id.is_some() { |
| 585 | "This pull request's change is in a working copy made for it, which goes by itself.".to_owned() |
| 586 | } else { |
| 587 | "This pull request's branch cannot be deleted here.".to_owned() |
| 588 | }, |
| 589 | )); |
| 590 | }; |
| 591 | if self.branch_in_use(pull, &branch).await? { |
| 592 | return Ok(Outcome::fail( |
| 593 | FailureCode::Conflict, |
| 594 | format!("Another open pull request still uses {branch}, so it stays."), |
| 595 | )); |
| 596 | } |
| 597 | let removed: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call( |
| 598 | &self.repos, |
| 599 | "remove_branch", |
| 600 | &RemoveBranchArgs { actor: actor.clone(), path, branch: branch.clone(), head, surface: None }, |
| 601 | ) |
| 602 | .await?; |
| 603 | check!(removed); |
| 604 | self.db |
| 605 | .prepare("UPDATE pulls SET head_branch_deleted = 1 WHERE id = ?") |
| 606 | .bind(&[pull.id.as_str().into()])? |
| 607 | .run() |
| 608 | .await?; |
| 609 | self.note(&pull.repo_id, pull.number, (&actor.id, &actor.username), &format!("deleted the `{branch}` branch")) |
| 610 | .await?; |
| 611 | self.publish( |
| 612 | "pull.head_branch_deleted", |
| 613 | &pull.repo_id, |
| 614 | actor, |
| 615 | PullEvent { detail: Some(branch), ..Self::pull_event(pull) }, |
| 616 | ) |
| 617 | .await?; |
| 618 | Ok(Outcome::Ok(())) |
| 619 | } |
| 620 | |
| 621 | /// After a merge: deletes its branch where the merger asked for it, or |
| 622 | /// the repository does on merge. A branch that cannot go stays, quietly: |
| 623 | /// the merge itself succeeded. |
| 624 | pub(crate) async fn delete_branch_after_merge( |
| 625 | &self, |
| 626 | repo: &Repo, |
| 627 | pull: &Pull, |
| 628 | actor: &User, |
| 629 | asked: Option<bool>, |
| 630 | head: Option<String>, |
| 631 | ) -> Result<()> { |
| 632 | if !asked.unwrap_or(self.settings(&repo.id).await?.delete_branch_on_merge) || deletable_branch(pull, repo).is_none() { |
| 633 | return Ok(()); |
| 634 | } |
| 635 | // A branch that moved since it merged keeps the new commits. |
| 636 | if head.is_none() { |
| 637 | return Ok(()); |
| 638 | } |
| 639 | if let Outcome::Fail(failure) = self.remove_head_branch(repo, pull, actor, head).await? { |
| 640 | worker::console_warn!("branch of {} kept after merging: {}", pull.id, failure.message); |
| 641 | } |
| 642 | Ok(()) |
| 643 | } |
| 644 | |
| 645 | /// `delete_head_branch`: deletes a merged or closed pull request's |
| 646 | /// branch, if it still points where the pull request left it. |
| 647 | pub(crate) async fn delete_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> { |
| 648 | if !a.actor.verified { |
| 649 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 650 | } |
| 651 | let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?); |
| 652 | check!(writable(&repo)); |
| 653 | if pull.status.is_active() { |
| 654 | return Ok(Outcome::fail( |
| 655 | FailureCode::Conflict, |
| 656 | "This pull request is still open. Its branch can be deleted once it is merged or closed.", |
| 657 | )); |
| 658 | } |
| 659 | if pull.head_branch_deleted { |
| 660 | return Ok(Outcome::fail(FailureCode::Conflict, "Its branch is already deleted.")); |
| 661 | } |
| 662 | if !pull.from_head_repo() { |
| 663 | check!(allowed(Some(&a.actor), &repo, Capability::Push)); |
| 664 | } |
| 665 | check!(self.remove_head_branch(&repo, &pull, &a.actor, pull.head_commit.clone()).await?); |
| 666 | Ok(match self.pull(&repo.id, pull.number).await? { |
| 667 | Some(pull) => Outcome::Ok(pull), |
| 668 | None => no_pull(), |
| 669 | }) |
| 670 | } |
| 671 | |
| 672 | /// `restore_head_branch`: makes a merged or closed pull request's |
| 673 | /// branch again, at the commit it was left at. |
| 674 | pub(crate) async fn restore_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> { |
| 675 | if !a.actor.verified { |
| 676 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 677 | } |
| 678 | let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?); |
| 679 | check!(writable(&repo)); |
| 680 | if pull.status.is_active() { |
| 681 | return Ok(Outcome::fail(FailureCode::Conflict, "This pull request is still open, and so is its branch.")); |
| 682 | } |
| 683 | let (Some((path, branch)), Some(head)) = (deletable_branch(&pull, &repo), pull.head_commit.clone()) else { |
| 684 | return Ok(Outcome::fail(FailureCode::Invalid, "This pull request has no branch that can be restored.")); |
| 685 | }; |
| 686 | let exists: Option<String> = g1t_kit::call( |
| 687 | &self.repos, |
| 688 | "head", |
| 689 | &HeadArgs { repo_id: pull.source_id().to_owned(), branch: branch.clone() }, |
| 690 | ) |
| 691 | .await?; |
| 692 | if exists.is_some() { |
| 693 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} exists already."))); |
| 694 | } |
| 695 | let created: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call( |
| 696 | &self.repos, |
| 697 | "create_branch", |
| 698 | &CreateBranchArgs { actor: a.actor.clone(), path, name: branch.clone(), from: head, surface: None }, |
| 699 | ) |
| 700 | .await?; |
| 701 | check!(created); |
| 702 | self.db |
| 703 | .prepare("UPDATE pulls SET head_branch_deleted = 0 WHERE id = ?") |
| 704 | .bind(&[pull.id.as_str().into()])? |
| 705 | .run() |
| 706 | .await?; |
| 707 | self.note(&pull.repo_id, pull.number, (&a.actor.id, &a.actor.username), &format!("restored the `{branch}` branch")) |
| 708 | .await?; |
| 709 | self.publish( |
| 710 | "pull.head_branch_restored", |
| 711 | &pull.repo_id, |
| 712 | &a.actor, |
| 713 | PullEvent { detail: Some(branch), ..Self::pull_event(&pull) }, |
| 714 | ) |
| 715 | .await?; |
| 716 | Ok(match self.pull(&repo.id, pull.number).await? { |
| 717 | Some(pull) => Outcome::Ok(pull), |
| 718 | None => no_pull(), |
| 719 | }) |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | #[cfg(test)] |
| 724 | mod tests { |
| 725 | use super::*; |
| 726 | use crate::rows::stored::{ASKER, G1T, pull}; |
| 727 | |
| 728 | fn rules(allowed: Option<Vec<MergeMethod>>, linear: bool) -> Requirements { |
| 729 | Requirements { allowed_merge_methods: allowed, linear_history: linear, ..Requirements::default() } |
| 730 | } |
| 731 | |
| 732 | #[test] |
| 733 | fn the_method_is_the_one_asked_for_if_allowed() { |
| 734 | let settings = RepoSettings::default(); |
| 735 | let open = rules(None, false); |
| 736 | assert_eq!(choose_method(Some(MergeMethod::Squash), &settings, &open, "main"), Ok(MergeMethod::Squash)); |
| 737 | assert_eq!(choose_method(None, &settings, &open, "main"), Ok(MergeMethod::Merge)); |
| 738 | // Fast-forward is off unless the repository turns it on. |
| 739 | let refused = choose_method(Some(MergeMethod::FastForward), &settings, &open, "main").unwrap_err(); |
| 740 | assert_eq!(refused, "This repository does not allow merging by fast-forward. It allows merge commit, squash or rebase."); |
| 741 | let ff = RepoSettings { allow_fast_forward: true, ..RepoSettings::default() }; |
| 742 | assert_eq!(choose_method(Some(MergeMethod::FastForward), &ff, &open, "main"), Ok(MergeMethod::FastForward)); |
| 743 | } |
| 744 | |
| 745 | #[test] |
| 746 | fn linear_history_refuses_a_merge_commit_and_moves_the_default_on() { |
| 747 | let settings = RepoSettings::default(); |
| 748 | let linear = rules(None, true); |
| 749 | let refused = choose_method(Some(MergeMethod::Merge), &settings, &linear, "main").unwrap_err(); |
| 750 | assert_eq!(refused, "main requires linear history, so a merge commit cannot land there. Merge by squash or rebase instead."); |
| 751 | // Unasked, the default (merge) gives way to the first that may. |
| 752 | assert_eq!(choose_method(None, &settings, &linear, "main"), Ok(MergeMethod::Squash)); |
| 753 | assert_eq!(offered(&settings, &linear), vec![MergeMethod::Squash, MergeMethod::Rebase]); |
| 754 | } |
| 755 | |
| 756 | #[test] |
| 757 | fn rules_narrow_the_methods() { |
| 758 | let settings = RepoSettings { default_merge_method: MergeMethod::Squash, ..RepoSettings::default() }; |
| 759 | let rebase_only = rules(Some(vec![MergeMethod::Rebase]), false); |
| 760 | assert_eq!(choose_method(None, &settings, &rebase_only, "main"), Ok(MergeMethod::Rebase)); |
| 761 | let refused = choose_method(Some(MergeMethod::Squash), &settings, &rebase_only, "release").unwrap_err(); |
| 762 | assert_eq!(refused, "The rules for release allow merging only by rebase, not by squash."); |
| 763 | // Nothing both allow. |
| 764 | let ff_only = rules(Some(vec![MergeMethod::FastForward]), false); |
| 765 | let none = choose_method(None, &settings, &ff_only, "main").unwrap_err(); |
| 766 | assert!(none.starts_with("No merge method this repository allows (merge commit, squash or rebase)"), "{none}"); |
| 767 | assert!(offered(&settings, &ff_only).is_empty()); |
| 768 | } |
| 769 | |
| 770 | #[test] |
| 771 | fn behind_is_no_problem_unless_it_must_be_up_to_date_or_fast_forward() { |
| 772 | assert_eq!(when_behind(MergeMethod::Merge, false, true, "main"), WhenBehind::Land); |
| 773 | for method in [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase] { |
| 774 | assert_eq!(when_behind(method, true, false, "main"), WhenBehind::Land, "{method:?}"); |
| 775 | assert!(matches!(when_behind(method, true, true, "main"), WhenBehind::Refuse(_))); |
| 776 | } |
| 777 | assert_eq!(when_behind(MergeMethod::FastForward, true, false, "main"), WhenBehind::CatchUpFirst); |
| 778 | assert_eq!(when_behind(MergeMethod::FastForward, false, false, "main"), WhenBehind::Land); |
| 779 | assert!(matches!(when_behind(MergeMethod::FastForward, true, true, "main"), WhenBehind::Refuse(_))); |
| 780 | } |
| 781 | |
| 782 | fn repo() -> Repo { |
| 783 | serde_json::from_value(serde_json::json!({ |
| 784 | "id": "rep_1", "namespace": "flagon", "name": "g1t", "isPrivate": false, |
| 785 | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "protected": false, |
| 786 | "createdAt": "", "updatedAt": "" |
| 787 | })) |
| 788 | .unwrap() |
| 789 | } |
| 790 | |
| 791 | fn branch_pull(branch: &str) -> Pull { |
| 792 | let mut made = pull(ASKER, None); |
| 793 | made.fork = None; |
| 794 | made.fork_repo_id = None; |
| 795 | made.branch = Some(branch.into()); |
| 796 | made |
| 797 | } |
| 798 | |
| 799 | #[test] |
| 800 | fn only_a_branch_that_is_not_the_base_or_a_working_copy_is_deleted() { |
| 801 | let repo = repo(); |
| 802 | let feature = branch_pull("feature"); |
| 803 | assert_eq!( |
| 804 | deletable_branch(&feature, &repo), |
| 805 | Some((RepoPath { namespace: "flagon".into(), name: "g1t".into() }, "feature".into())) |
| 806 | ); |
| 807 | assert!(deletable_branch(&branch_pull("main"), &repo).is_none(), "the default branch stays"); |
| 808 | let mut into_release = branch_pull("release"); |
| 809 | into_release.base = Some("release".into()); |
| 810 | assert!(deletable_branch(&into_release, &repo).is_none()); |
| 811 | // A working copy goes by itself. |
| 812 | assert!(deletable_branch(&pull(G1T, Some(ASKER)), &repo).is_none()); |
| 813 | // A fork's branch, even one named like the base. |
| 814 | let mut forked = branch_pull("main"); |
| 815 | forked.head_repo = Some(RepoPath { namespace: "ana".into(), name: "g1t".into() }); |
| 816 | forked.head_repo_id = Some("rep_2".into()); |
| 817 | assert_eq!(deletable_branch(&forked, &repo).map(|(path, branch)| (path.namespace, branch)), Some(("ana".into(), "main".into()))); |
| 818 | } |
| 819 | |
| 820 | #[test] |
| 821 | fn a_squash_is_by_the_person_whose_change_it_is() { |
| 822 | let own = branch_pull("feature"); |
| 823 | assert_eq!(squash_author(&own, MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned())); |
| 824 | assert!(squash_author(&own, MergeMethod::Merge).is_none()); |
| 825 | // What g1t made for someone is theirs. |
| 826 | assert_eq!(squash_author(&pull(G1T, Some(ASKER)), MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned())); |
| 827 | // g1t's own work is the merger's. |
| 828 | assert!(squash_author(&pull(("g1t", "g1t"), None), MergeMethod::Squash).is_none()); |
| 829 | } |
| 830 | |
| 831 | #[test] |
| 832 | fn issues_close_only_into_the_default_branch_where_the_repository_says() { |
| 833 | let repo = repo(); |
| 834 | let mut fixes = branch_pull("feature"); |
| 835 | fixes.issue = Some(12); |
| 836 | fixes.body = Some("Fixes #3. See `closes #4`.".into()); |
| 837 | let settings = RepoSettings::default(); |
| 838 | assert_eq!(issues_to_close(&fixes, &repo, &settings, false), vec![12, 3]); |
| 839 | assert!(issues_to_close(&fixes, &repo, &settings, true).is_empty()); |
| 840 | let off = RepoSettings { auto_close_issues: false, ..RepoSettings::default() }; |
| 841 | assert!(issues_to_close(&fixes, &repo, &off, false).is_empty()); |
| 842 | fixes.base = Some("release".into()); |
| 843 | assert!(issues_to_close(&fixes, &repo, &settings, false).is_empty()); |
| 844 | } |
| 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 | |
| 907 | #[test] |
| 908 | fn the_timeline_says_how_it_merged() { |
| 909 | assert_eq!(merged_note(MergeMethod::Squash), "squashed and merged this"); |
| 910 | assert_eq!(merged_note(MergeMethod::FastForward), "merged this"); |
| 911 | assert_eq!(labels(&[MergeMethod::Squash, MergeMethod::Rebase, MergeMethod::FastForward]), "squash, rebase or fast-forward"); |
| 912 | } |
| 913 | } |