Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

636 linesCodeBlameRaw
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
9use g1t_contracts::access::{self, Capability};
10use g1t_contracts::events::PullEvent;
11use g1t_contracts::repos::{CreateBranchArgs, HeadArgs, RemoveBranchArgs, Repo, RepoPath};
12use g1t_contracts::rules::{InspectCommitsArgs, InspectedCommits, MergeMethod};
13use g1t_contracts::work::*;
14use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User};
15use g1t_rules::merge::{Requirements, method_label};
16use worker::Result;
17
18use crate::merge_message::{self, CommitText, MessageFacts};
19use crate::retired::writable;
20use crate::{Work, allowed, no_pull};
21
22/// Unwraps an `Outcome`, returning its failure from the enclosing method.
23macro_rules! check {
24 ($outcome:expr) => {
25 match $outcome {
26 Outcome::Ok(value) => value,
27 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
28 }
29 };
30}
31
32/// How many of a pull request's commits its default messages read.
33const MESSAGE_COMMITS: u32 = 250;
34
35/// Methods named for people, joined: "squash or rebase".
36fn labels(methods: &[MergeMethod]) -> String {
37 let named: Vec<&str> = methods.iter().map(|method| method_label(*method)).collect();
38 match named.as_slice() {
39 [] => "no method".to_owned(),
40 [one] => (*one).to_owned(),
41 [rest @ .., last] => format!("{} or {last}", rest.join(", ")),
42 }
43}
44
45/// The methods a pull request may merge by: those the repository allows,
46/// as the rules for its base allow them, in the order offered.
47pub(crate) fn offered(settings: &RepoSettings, requirements: &Requirements) -> Vec<MergeMethod> {
48 settings.allowed_methods().into_iter().filter(|method| requirements.allows_method(*method)).collect()
49}
50
51/// The method a merge uses: the one asked for, if the repository and the
52/// rules for `base` allow it; with none asked for, the repository's
53/// default where it may be used, else the first that may. Why not, if
54/// none can.
55pub(crate) fn choose_method(
56 asked: Option<MergeMethod>,
57 settings: &RepoSettings,
58 requirements: &Requirements,
59 base: &str,
60) -> std::result::Result<MergeMethod, String> {
61 let offered = offered(settings, requirements);
62 let Some(method) = asked else {
63 return if offered.contains(&settings.default_merge_method) {
64 Ok(settings.default_merge_method)
65 } else {
66 offered.first().copied().ok_or_else(|| {
67 format!(
68 "No merge method this repository allows ({}) is allowed by the rules for {base}. Its admins can allow another in Settings.",
69 labels(&settings.allowed_methods())
70 )
71 })
72 };
73 };
74 if !settings.allows(method) {
75 return Err(format!(
76 "This repository does not allow merging by {}. It allows {}.",
77 method_label(method),
78 labels(&settings.allowed_methods())
79 ));
80 }
81 if requirements.linear_history && !method.is_linear() {
82 return Err(format!(
83 "{base} requires linear history, so a merge commit cannot land there. Merge by {} instead.",
84 labels(&offered)
85 ));
86 }
87 if !requirements.allows_method(method) {
88 return Err(format!(
89 "The rules for {base} allow merging only by {}, not by {}.",
90 labels(requirements.allowed_merge_methods.as_deref().unwrap_or_default()),
91 method_label(method)
92 ));
93 }
94 Ok(method)
95}
96
97/// What a merge does about its base having moved without it.
98#[derive(Debug, PartialEq, Eq)]
99pub(crate) enum WhenBehind {
100 /// Land it now: it is not behind, or the method makes new commits on
101 /// the base's head anyway.
102 Land,
103 /// Bring it up to date first, and land it once that push arrives: a
104 /// fast-forward needs the base's head in it.
105 CatchUpFirst,
106 /// Refuse, saying why.
107 Refuse(String),
108}
109
110/// What to do when `behind`, for a merge by `method`.
111pub(crate) fn when_behind(method: MergeMethod, behind: bool, require_up_to_date: bool, base: &str) -> WhenBehind {
112 if !behind {
113 return WhenBehind::Land;
114 }
115 if require_up_to_date {
116 return WhenBehind::Refuse(format!(
117 "{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."
118 ));
119 }
120 match method {
121 MergeMethod::FastForward => WhenBehind::CatchUpFirst,
122 MergeMethod::Merge | MergeMethod::Squash | MergeMethod::Rebase => WhenBehind::Land,
123 }
124}
125
126/// Why a fast-forward cannot catch up first: whoever merges cannot push
127/// to where the change is.
128pub(crate) fn cannot_fast_forward(base: &str, source: &str) -> String {
129 format!(
130 "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."
131 )
132}
133
134/// Who a squash commit is by: the person the pull request is theirs (who
135/// asked g1t for it, or its author). The merger, for an agent's or g1t's
136/// own, and for any other method.
137pub(crate) fn squash_author(pull: &Pull, method: MergeMethod) -> Option<User> {
138 let owner = pull.owner();
139 (method == MergeMethod::Squash && owner.kind == PrincipalKind::User && !g1t_contracts::rules::is_agent(owner)).then(|| owner.clone())
140}
141
142/// The branch a pull request's merge or close may delete, and where it is:
143/// a branch of the repository, never its default branch nor the branch it
144/// merges into, or a branch of a fork. Never a working copy's, which repos
145/// retires with it.
146pub(crate) fn deletable_branch(pull: &Pull, repo: &Repo) -> Option<(RepoPath, String)> {
147 if pull.fork_repo_id.is_some() {
148 return None;
149 }
150 let branch = pull.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty())?;
151 if pull.from_head_repo() {
152 return Some((pull.head_repo.clone()?, branch.to_owned()));
153 }
154 if branch == repo.default_branch || branch == pull.base_branch(&repo.default_branch) {
155 return None;
156 }
157 Some((RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, branch.to_owned()))
158}
159
160/// What the timeline says a merge by `method` did.
161pub(crate) fn merged_note(method: MergeMethod) -> &'static str {
162 match method {
163 MergeMethod::Squash => "squashed and merged this",
164 MergeMethod::Rebase => "rebased and merged this",
165 MergeMethod::Merge | MergeMethod::FastForward => "merged this",
166 }
167}
168
169/// The issues a merge into the default branch closes, where the repository
170/// closes them: its own and those it names (closing.rs). None into another
171/// branch, or when told to keep them open.
172pub(crate) fn issues_to_close(pull: &Pull, repo: &Repo, settings: &RepoSettings, keep_issue_open: bool) -> Vec<u32> {
173 if keep_issue_open || !settings.auto_close_issues || !pull.targets_default(&repo.default_branch) {
174 return Vec::new();
175 }
176 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
177 crate::closing::closes(pull.issue, &pull.title, pull.body.as_deref(), &path)
178}
179
180impl 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>> {
184 let Some(head) = pull.head_commit.clone() else {
185 return Ok(Vec::new());
186 };
187 let found: Outcome<InspectedCommits> = g1t_kit::call(
188 &self.repos,
189 "inspect_commits",
190 &InspectCommitsArgs {
191 source_id: pull.source_id().to_owned(),
192 head,
193 target_id: repo.id.clone(),
194 base_branch: pull.base_branch(&repo.default_branch).to_owned(),
195 limit: Some(MESSAGE_COMMITS),
196 },
197 )
198 .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())
210 }
211
212 /// The default title and body of the commit each of `methods` makes
213 /// for `pull`, as the repository's settings write them: `None` for one
214 /// that makes no commit of its own.
215 pub(crate) async fn default_messages(
216 &self,
217 repo: &Repo,
218 pull: &Pull,
219 settings: &RepoSettings,
220 methods: &[MergeMethod],
221 ) -> Result<Vec<(MergeMethod, Option<(String, String)>)>> {
222 let commits = if methods.iter().any(|method| merge_message::needs_commits(*method, settings)) {
223 self.commit_texts(repo, pull).await?
224 } else {
225 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 };
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())
253 }
254
255 /// The whole message of the commit a merge by `method` makes: what the
256 /// merger wrote, over the repository's default. `None` for a rebase or
257 /// fast-forward. Or why what they wrote cannot be used.
258 pub(crate) async fn merge_message_for(
259 &self,
260 repo: &Repo,
261 pull: &Pull,
262 settings: &RepoSettings,
263 method: MergeMethod,
264 title: Option<&str>,
265 body: Option<&str>,
266 ) -> Result<std::result::Result<Option<String>, &'static str>> {
267 if !matches!(method, MergeMethod::Merge | MergeMethod::Squash) {
268 return Ok(Ok(None));
269 }
270 // Nothing of the default is used when the merger wrote both.
271 let default = if title.is_some() && body.is_some() {
272 Some((String::new(), String::new()))
273 } else {
274 self.default_messages(repo, pull, settings, &[method]).await?.pop().and_then(|(_, message)| message)
275 };
276 Ok(merge_message::chosen(default, title, body))
277 }
278
279 /// `merge_choices`: how a pull request may merge now, for the merge box.
280 pub(crate) async fn merge_choices(&self, a: ViewArgs) -> Result<Outcome<MergeChoices>> {
281 let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?);
282 crate::fill_base(&mut pull, &repo);
283 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?;
286 let methods = offered(&settings, &requirements);
287 let default_method = choose_method(None, &settings, &requirements, &base).ok();
288 let viewer = a.viewer.as_ref();
289 // Whoever looks may catch it up to fast-forward it: they could push
290 // the merge themselves (catch_up_pull).
291 let can_catch_up = viewer.is_some_and(|viewer| match (&pull.fork_repo_id, pull.from_head_repo()) {
292 (Some(_), _) => pull.is_owned_by(&viewer.id),
293 // A fork's branch takes pushes from whoever repos lets push
294 // there, which is not known here: offered, and refused clearly.
295 (None, true) => true,
296 (None, false) => access::can(Some(viewer), &repo, Capability::Push),
297 });
298 let messages = self.default_messages(&repo, &pull, &settings, &methods).await?;
299 let options = messages
300 .into_iter()
301 .map(|(method, message)| {
302 let unavailable = if behind && settings.require_up_to_date {
303 Some(format!("This repository requires it to be up to date with {base} before it merges."))
304 } else {
305 (method == MergeMethod::FastForward && behind && !can_catch_up)
306 .then(|| format!("It can only fast-forward once it is up to date with {base}."))
307 };
308 let (commit_title, commit_message) = match message {
309 Some((title, body)) => (Some(title), Some(body)),
310 None => (None, None),
311 };
312 MergeChoice { method, commit_title, commit_message, unavailable }
313 })
314 .collect();
315 let can_delete_branch = deletable_branch(&pull, &repo).is_some();
316 let auto_merge_allowed = settings.allow_auto_merge
317 && pull.status.is_active()
318 && viewer.is_some_and(|viewer| access::can(Some(viewer), &repo, Capability::Merge));
319 Ok(Outcome::Ok(MergeChoices {
320 methods,
321 default_method,
322 options,
323 behind,
324 can_fast_forward: !behind,
325 suggest_update: behind && (settings.suggest_updating_branches || settings.require_up_to_date),
326 require_up_to_date: settings.require_up_to_date,
327 merge_queue: settings.merge_queue,
328 auto_merge_allowed,
329 delete_branch_default: settings.delete_branch_on_merge && can_delete_branch,
330 can_delete_branch,
331 sign_off: settings.require_web_commit_sign_off,
332 }))
333 }
334
335 /// Whether another open pull request still needs `branch` of
336 /// `source_id`: one merging into it, or one opened from it into
337 /// another branch.
338 async fn branch_in_use(&self, pull: &Pull, branch: &str) -> Result<bool> {
339 Ok(self
340 .db
341 .prepare(
342 "SELECT 1 AS value FROM pulls
343 WHERE id <> ?1 AND status IN ('draft', 'open') AND fork_repo_id IS NULL
344 AND ((repo_id = ?2 AND base_branch = ?3 AND ?4 IS NULL)
345 OR (source_branch = ?3 AND COALESCE(head_repo_id, repo_id) = ?5))
346 LIMIT 1",
347 )
348 .bind(&[
349 pull.id.as_str().into(),
350 pull.repo_id.as_str().into(),
351 branch.into(),
352 crate::optional(&pull.head_repo_id),
353 pull.source_id().into(),
354 ])?
355 .first::<u32>(Some("value"))
356 .await?
357 .is_some())
358 }
359
360 /// Deletes a pull request's branch as `actor`, if it may be deleted and
361 /// still points at `head`, and says so. Repos decides whether `actor`
362 /// may push there and whether the rules for the branch let it go.
363 /// Whether it was deleted, or why not.
364 async fn remove_head_branch(&self, repo: &Repo, pull: &Pull, actor: &User, head: Option<String>) -> Result<Outcome<()>> {
365 let Some((path, branch)) = deletable_branch(pull, repo) else {
366 return Ok(Outcome::fail(
367 FailureCode::Invalid,
368 if pull.fork_repo_id.is_some() {
369 "This pull request's change is in a working copy made for it, which goes by itself.".to_owned()
370 } else {
371 "This pull request's branch cannot be deleted here.".to_owned()
372 },
373 ));
374 };
375 if self.branch_in_use(pull, &branch).await? {
376 return Ok(Outcome::fail(
377 FailureCode::Conflict,
378 format!("Another open pull request still uses {branch}, so it stays."),
379 ));
380 }
381 let removed: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call(
382 &self.repos,
383 "remove_branch",
384 &RemoveBranchArgs { actor: actor.clone(), path, branch: branch.clone(), head, surface: None },
385 )
386 .await?;
387 check!(removed);
388 self.db
389 .prepare("UPDATE pulls SET head_branch_deleted = 1 WHERE id = ?")
390 .bind(&[pull.id.as_str().into()])?
391 .run()
392 .await?;
393 self.note(&pull.repo_id, pull.number, (&actor.id, &actor.username), &format!("deleted the `{branch}` branch"))
394 .await?;
395 self.publish(
396 "pull.head_branch_deleted",
397 &pull.repo_id,
398 actor,
399 PullEvent { detail: Some(branch), ..Self::pull_event(pull) },
400 )
401 .await?;
402 Ok(Outcome::Ok(()))
403 }
404
405 /// After a merge: deletes its branch where the merger asked for it, or
406 /// the repository does on merge. A branch that cannot go stays, quietly:
407 /// the merge itself succeeded.
408 pub(crate) async fn delete_branch_after_merge(
409 &self,
410 repo: &Repo,
411 pull: &Pull,
412 actor: &User,
413 asked: Option<bool>,
414 head: Option<String>,
415 ) -> Result<()> {
416 if !asked.unwrap_or(self.settings(&repo.id).await?.delete_branch_on_merge) || deletable_branch(pull, repo).is_none() {
417 return Ok(());
418 }
419 // A branch that moved since it merged keeps the new commits.
420 if head.is_none() {
421 return Ok(());
422 }
423 if let Outcome::Fail(failure) = self.remove_head_branch(repo, pull, actor, head).await? {
424 worker::console_warn!("branch of {} kept after merging: {}", pull.id, failure.message);
425 }
426 Ok(())
427 }
428
429 /// `delete_head_branch`: deletes a merged or closed pull request's
430 /// branch, if it still points where the pull request left it.
431 pub(crate) async fn delete_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
432 if !a.actor.verified {
433 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
434 }
435 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
436 check!(writable(&repo));
437 if pull.status.is_active() {
438 return Ok(Outcome::fail(
439 FailureCode::Conflict,
440 "This pull request is still open. Its branch can be deleted once it is merged or closed.",
441 ));
442 }
443 if pull.head_branch_deleted {
444 return Ok(Outcome::fail(FailureCode::Conflict, "Its branch is already deleted."));
445 }
446 if !pull.from_head_repo() {
447 check!(allowed(Some(&a.actor), &repo, Capability::Push));
448 }
449 check!(self.remove_head_branch(&repo, &pull, &a.actor, pull.head_commit.clone()).await?);
450 Ok(match self.pull(&repo.id, pull.number).await? {
451 Some(pull) => Outcome::Ok(pull),
452 None => no_pull(),
453 })
454 }
455
456 /// `restore_head_branch`: makes a merged or closed pull request's
457 /// branch again, at the commit it was left at.
458 pub(crate) async fn restore_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
459 if !a.actor.verified {
460 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
461 }
462 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
463 check!(writable(&repo));
464 if pull.status.is_active() {
465 return Ok(Outcome::fail(FailureCode::Conflict, "This pull request is still open, and so is its branch."));
466 }
467 let (Some((path, branch)), Some(head)) = (deletable_branch(&pull, &repo), pull.head_commit.clone()) else {
468 return Ok(Outcome::fail(FailureCode::Invalid, "This pull request has no branch that can be restored."));
469 };
470 let exists: Option<String> = g1t_kit::call(
471 &self.repos,
472 "head",
473 &HeadArgs { repo_id: pull.source_id().to_owned(), branch: branch.clone() },
474 )
475 .await?;
476 if exists.is_some() {
477 return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} exists already.")));
478 }
479 let created: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call(
480 &self.repos,
481 "create_branch",
482 &CreateBranchArgs { actor: a.actor.clone(), path, name: branch.clone(), from: head, surface: None },
483 )
484 .await?;
485 check!(created);
486 self.db
487 .prepare("UPDATE pulls SET head_branch_deleted = 0 WHERE id = ?")
488 .bind(&[pull.id.as_str().into()])?
489 .run()
490 .await?;
491 self.note(&pull.repo_id, pull.number, (&a.actor.id, &a.actor.username), &format!("restored the `{branch}` branch"))
492 .await?;
493 self.publish(
494 "pull.head_branch_restored",
495 &pull.repo_id,
496 &a.actor,
497 PullEvent { detail: Some(branch), ..Self::pull_event(&pull) },
498 )
499 .await?;
500 Ok(match self.pull(&repo.id, pull.number).await? {
501 Some(pull) => Outcome::Ok(pull),
502 None => no_pull(),
503 })
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::rows::stored::{ASKER, G1T, pull};
511
512 fn rules(allowed: Option<Vec<MergeMethod>>, linear: bool) -> Requirements {
513 Requirements { allowed_merge_methods: allowed, linear_history: linear, ..Requirements::default() }
514 }
515
516 #[test]
517 fn the_method_is_the_one_asked_for_if_allowed() {
518 let settings = RepoSettings::default();
519 let open = rules(None, false);
520 assert_eq!(choose_method(Some(MergeMethod::Squash), &settings, &open, "main"), Ok(MergeMethod::Squash));
521 assert_eq!(choose_method(None, &settings, &open, "main"), Ok(MergeMethod::Merge));
522 // Fast-forward is off unless the repository turns it on.
523 let refused = choose_method(Some(MergeMethod::FastForward), &settings, &open, "main").unwrap_err();
524 assert_eq!(refused, "This repository does not allow merging by fast-forward. It allows merge commit, squash or rebase.");
525 let ff = RepoSettings { allow_fast_forward: true, ..RepoSettings::default() };
526 assert_eq!(choose_method(Some(MergeMethod::FastForward), &ff, &open, "main"), Ok(MergeMethod::FastForward));
527 }
528
529 #[test]
530 fn linear_history_refuses_a_merge_commit_and_moves_the_default_on() {
531 let settings = RepoSettings::default();
532 let linear = rules(None, true);
533 let refused = choose_method(Some(MergeMethod::Merge), &settings, &linear, "main").unwrap_err();
534 assert_eq!(refused, "main requires linear history, so a merge commit cannot land there. Merge by squash or rebase instead.");
535 // Unasked, the default (merge) gives way to the first that may.
536 assert_eq!(choose_method(None, &settings, &linear, "main"), Ok(MergeMethod::Squash));
537 assert_eq!(offered(&settings, &linear), vec![MergeMethod::Squash, MergeMethod::Rebase]);
538 }
539
540 #[test]
541 fn rules_narrow_the_methods() {
542 let settings = RepoSettings { default_merge_method: MergeMethod::Squash, ..RepoSettings::default() };
543 let rebase_only = rules(Some(vec![MergeMethod::Rebase]), false);
544 assert_eq!(choose_method(None, &settings, &rebase_only, "main"), Ok(MergeMethod::Rebase));
545 let refused = choose_method(Some(MergeMethod::Squash), &settings, &rebase_only, "release").unwrap_err();
546 assert_eq!(refused, "The rules for release allow merging only by rebase, not by squash.");
547 // Nothing both allow.
548 let ff_only = rules(Some(vec![MergeMethod::FastForward]), false);
549 let none = choose_method(None, &settings, &ff_only, "main").unwrap_err();
550 assert!(none.starts_with("No merge method this repository allows (merge commit, squash or rebase)"), "{none}");
551 assert!(offered(&settings, &ff_only).is_empty());
552 }
553
554 #[test]
555 fn behind_is_no_problem_unless_it_must_be_up_to_date_or_fast_forward() {
556 assert_eq!(when_behind(MergeMethod::Merge, false, true, "main"), WhenBehind::Land);
557 for method in [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase] {
558 assert_eq!(when_behind(method, true, false, "main"), WhenBehind::Land, "{method:?}");
559 assert!(matches!(when_behind(method, true, true, "main"), WhenBehind::Refuse(_)));
560 }
561 assert_eq!(when_behind(MergeMethod::FastForward, true, false, "main"), WhenBehind::CatchUpFirst);
562 assert_eq!(when_behind(MergeMethod::FastForward, false, false, "main"), WhenBehind::Land);
563 assert!(matches!(when_behind(MergeMethod::FastForward, true, true, "main"), WhenBehind::Refuse(_)));
564 }
565
566 fn repo() -> Repo {
567 serde_json::from_value(serde_json::json!({
568 "id": "rep_1", "namespace": "flagon", "name": "g1t", "description": null, "isPrivate": false,
569 "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "protected": false,
570 "createdAt": "", "updatedAt": ""
571 }))
572 .unwrap()
573 }
574
575 fn branch_pull(branch: &str) -> Pull {
576 let mut made = pull(ASKER, None);
577 made.fork = None;
578 made.fork_repo_id = None;
579 made.branch = Some(branch.into());
580 made
581 }
582
583 #[test]
584 fn only_a_branch_that_is_not_the_base_or_a_working_copy_is_deleted() {
585 let repo = repo();
586 let feature = branch_pull("feature");
587 assert_eq!(
588 deletable_branch(&feature, &repo),
589 Some((RepoPath { namespace: "flagon".into(), name: "g1t".into() }, "feature".into()))
590 );
591 assert!(deletable_branch(&branch_pull("main"), &repo).is_none(), "the default branch stays");
592 let mut into_release = branch_pull("release");
593 into_release.base = Some("release".into());
594 assert!(deletable_branch(&into_release, &repo).is_none());
595 // A working copy goes by itself.
596 assert!(deletable_branch(&pull(G1T, Some(ASKER)), &repo).is_none());
597 // A fork's branch, even one named like the base.
598 let mut forked = branch_pull("main");
599 forked.head_repo = Some(RepoPath { namespace: "ana".into(), name: "g1t".into() });
600 forked.head_repo_id = Some("rep_2".into());
601 assert_eq!(deletable_branch(&forked, &repo).map(|(path, branch)| (path.namespace, branch)), Some(("ana".into(), "main".into())));
602 }
603
604 #[test]
605 fn a_squash_is_by_the_person_whose_change_it_is() {
606 let own = branch_pull("feature");
607 assert_eq!(squash_author(&own, MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned()));
608 assert!(squash_author(&own, MergeMethod::Merge).is_none());
609 // What g1t made for someone is theirs.
610 assert_eq!(squash_author(&pull(G1T, Some(ASKER)), MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned()));
611 // g1t's own work is the merger's.
612 assert!(squash_author(&pull(("g1t", "g1t"), None), MergeMethod::Squash).is_none());
613 }
614
615 #[test]
616 fn issues_close_only_into_the_default_branch_where_the_repository_says() {
617 let repo = repo();
618 let mut fixes = branch_pull("feature");
619 fixes.issue = Some(12);
620 fixes.body = Some("Fixes #3. See `closes #4`.".into());
621 let settings = RepoSettings::default();
622 assert_eq!(issues_to_close(&fixes, &repo, &settings, false), vec![12, 3]);
623 assert!(issues_to_close(&fixes, &repo, &settings, true).is_empty());
624 let off = RepoSettings { auto_close_issues: false, ..RepoSettings::default() };
625 assert!(issues_to_close(&fixes, &repo, &off, false).is_empty());
626 fixes.base = Some("release".into());
627 assert!(issues_to_close(&fixes, &repo, &settings, false).is_empty());
628 }
629
630 #[test]
631 fn the_timeline_says_how_it_merged() {
632 assert_eq!(merged_note(MergeMethod::Squash), "squashed and merged this");
633 assert_eq!(merged_note(MergeMethod::FastForward), "merged this");
634 assert_eq!(labels(&[MergeMethod::Squash, MergeMethod::Rebase, MergeMethod::FastForward]), "squash, rebase or fast-forward");
635 }
636}