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.

889 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, Divergence, HeadArgs, RemoveBranchArgs, Repo, RepoPath};
12use g1t_contracts::rules::{InspectCommitsArgs, InspectedCommits, MergeMethod};
13use g1t_contracts::work::*;
14use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User};
15use g1t_kit::now_ms;
16use g1t_rules::merge::{Requirements, method_label};
17use serde::Deserialize;
18use worker::Result;
19
20use crate::merge_message::{self, CommitText, MessageFacts};
21use crate::retired::writable;
22use crate::{Work, allowed, no_pull};
23
24/// Unwraps an `Outcome`, returning its failure from the enclosing method.
25macro_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.
35const 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.
38const 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)]
43struct 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.
59pub(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)]
69pub(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.
84pub(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.
99fn 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.
112fn messages_for(
113 repo: &Repo,
114 pull: &Pull,
115 settings: &RepoSettings,
116 methods: &[MergeMethod],
117 commits: &[CommitText],
118 items: &[u32],
119) -> Vec<(MergeMethod, Option<(String, String)>)> {
120 let owner = pull.owner().username.clone();
121 let (head_owner, head_repo) = match (&pull.fork, &pull.head_repo) {
122 // A working copy is whoever asked for the change's.
123 (Some(copy), _) => (owner.clone(), format!("{}/{}", copy.namespace, copy.name)),
124 (None, Some(fork)) => (fork.namespace.clone(), format!("{}/{}", fork.namespace, fork.name)),
125 (None, None) => (repo.namespace.clone(), format!("{}/{}", repo.namespace, repo.name)),
126 };
127 let branch = pull
128 .branch
129 .clone()
130 .or_else(|| pull.fork.as_ref().map(|copy| copy.name.clone()))
131 .unwrap_or_default();
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 items,
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".
148fn 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.
159pub(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.
167pub(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)]
211pub(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`.
223pub(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.
240pub(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.
249pub(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.
258pub(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.
273pub(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
281impl Work {
282 /// A pull request's commits as its default messages read them, read
283 /// from its source now: only their messages, from a pack fetched from
284 /// `merge_base` when that is known. Empty when they cannot be read.
285 async fn read_commit_texts(
286 &self,
287 pull: &Pull,
288 target_id: &str,
289 base_branch: &str,
290 merge_base: Option<String>,
291 ) -> Result<Vec<CommitText>> {
292 let Some(head) = pull.head_commit.clone() else {
293 return Ok(Vec::new());
294 };
295 let found: Outcome<InspectedCommits> = g1t_kit::call(
296 &self.repos,
297 "inspect_commits",
298 &InspectCommitsArgs {
299 source_id: pull.source_id().to_owned(),
300 head,
301 target_id: target_id.to_owned(),
302 base_branch: base_branch.to_owned(),
303 limit: Some(MESSAGE_COMMITS),
304 merge_base,
305 messages_only: true,
306 },
307 )
308 .await?;
309 Ok(match found {
310 Outcome::Ok(inspected) => texts_of(inspected),
311 Outcome::Fail(_) => Vec::new(),
312 })
313 }
314
315 /// The commits kept for a pull request's default messages, if they are
316 /// its commits as it is now ([`kept_is_current`]). One database read.
317 async fn kept_commit_texts(&self, pull: &Pull) -> Result<Option<Vec<CommitText>>> {
318 if !pull.status.is_active() || pull.head_commit.is_none() {
319 return Ok(None);
320 }
321 let row = self
322 .timing
323 .db(
324 1,
325 self.db
326 .prepare(
327 "SELECT kept.head, kept.pair, kept.commits, pulls.mergeable_key
328 FROM pulls LEFT JOIN pull_message_commits AS kept ON kept.pull_id = pulls.id
329 WHERE pulls.id = ?",
330 )
331 .bind(&[pull.id.as_str().into()])?
332 .first::<KeptCommitsRow>(None),
333 )
334 .await?;
335 let Some(row) = row else { return Ok(None) };
336 if !kept_is_current(row.head.as_deref(), row.pair.as_deref(), pull.head_commit.as_deref(), row.mergeable_key.as_deref()) {
337 return Ok(None);
338 }
339 Ok(row.commits.as_deref().and_then(|commits| serde_json::from_str(commits).ok()))
340 }
341
342 /// A pull request's commits as its default messages read them, oldest
343 /// first, merge commits left out: those kept for it when they are
344 /// current, else read now.
345 async fn commit_texts(&self, repo: &Repo, pull: &Pull) -> Result<Vec<CommitText>> {
346 if let Some(kept) = self.kept_commit_texts(pull).await? {
347 return Ok(kept);
348 }
349 self.read_commit_texts(pull, &repo.id, pull.base_branch(&repo.default_branch), None).await
350 }
351
352 /// Keeps a pull request's commits for its default messages, once its
353 /// mergeability was worked out for `divergence` after a push, so that
354 /// the merge box reads them from here. They are read again only when
355 /// its head or where it left its base moved; a push to its base that
356 /// took none of its commits only moves the pair they hold for. Nothing
357 /// is read when no method the repository allows writes commits into a
358 /// message.
359 pub(crate) async fn keep_commit_texts(&self, pull: &Pull, divergence: &Divergence) -> Result<()> {
360 if !pull.status.is_active() || pull.head_commit.as_deref() != Some(divergence.head.as_str()) {
361 return Ok(());
362 }
363 #[derive(Deserialize)]
364 struct Kept {
365 head: String,
366 merge_base: Option<String>,
367 pair: String,
368 }
369 let (settings, kept) = futures_util::future::try_join(
370 self.settings(&pull.repo_id),
371 self.db
372 .prepare("SELECT head, merge_base, pair FROM pull_message_commits WHERE pull_id = ?")
373 .bind(&[pull.id.as_str().into()])?
374 .first::<Kept>(None),
375 )
376 .await?;
377 if !settings.allowed_methods().into_iter().any(|method| merge_message::needs_commits(method, &settings)) {
378 return Ok(());
379 }
380 let pair = format!("{}..{}", divergence.head, divergence.base);
381 let now = g1t_contracts::time::rfc3339(now_ms());
382 match keeping(kept.as_ref().map(|kept| (kept.head.as_str(), kept.merge_base.as_deref(), kept.pair.as_str())), divergence) {
383 Keeping::Current | Keeping::Nothing => Ok(()),
384 Keeping::Confirm => {
385 self.db
386 .prepare("UPDATE pull_message_commits SET pair = ?, updated_at = ? WHERE pull_id = ? AND head = ?")
387 .bind(&[pair.into(), now.into(), pull.id.as_str().into(), divergence.head.as_str().into()])?
388 .run()
389 .await?;
390 Ok(())
391 }
392 Keeping::Read => {
393 let texts = self
394 .read_commit_texts(pull, &pull.repo_id, pull.base.as_deref().unwrap_or_default(), divergence.merge_base.clone())
395 .await?;
396 let commits = serde_json::to_string(&texts)?;
397 if commits.len() > MAX_KEPT_COMMITS {
398 self.db
399 .prepare("DELETE FROM pull_message_commits WHERE pull_id = ?")
400 .bind(&[pull.id.as_str().into()])?
401 .run()
402 .await?;
403 return Ok(());
404 }
405 self.db
406 .prepare(
407 "INSERT INTO pull_message_commits (pull_id, head, merge_base, pair, commits, updated_at)
408 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
409 ON CONFLICT (pull_id) DO UPDATE SET head = ?2, merge_base = ?3, pair = ?4, commits = ?5, updated_at = ?6",
410 )
411 .bind(&[
412 pull.id.as_str().into(),
413 divergence.head.as_str().into(),
414 crate::optional(&divergence.merge_base),
415 pair.into(),
416 commits.into(),
417 now.into(),
418 ])?
419 .run()
420 .await?;
421 Ok(())
422 }
423 }
424 }
425
426 /// The default title and body of the commit each of `methods` makes
427 /// for `pull`, as the repository's settings write them: `None` for one
428 /// that makes no commit of its own.
429 pub(crate) async fn default_messages(
430 &self,
431 repo: &Repo,
432 pull: &Pull,
433 settings: &RepoSettings,
434 methods: &[MergeMethod],
435 ) -> Result<Vec<(MergeMethod, Option<(String, String)>)>> {
436 let commits = if methods.iter().any(|method| merge_message::needs_commits(*method, settings)) {
437 self.commit_texts(repo, pull).await?
438 } else {
439 Vec::new()
440 };
441 let items = self.items_to_close(pull, repo, settings, false, &[]).await?.close_numbers();
442 Ok(messages_for(repo, pull, settings, methods, &commits, &items))
443 }
444
445 /// The whole message of the commit a merge by `method` makes: what the
446 /// merger wrote, over the repository's default. `None` for a rebase or
447 /// fast-forward. Or why what they wrote cannot be used.
448 pub(crate) async fn merge_message_for(
449 &self,
450 repo: &Repo,
451 pull: &Pull,
452 settings: &RepoSettings,
453 method: MergeMethod,
454 title: Option<&str>,
455 body: Option<&str>,
456 ) -> Result<std::result::Result<Option<String>, &'static str>> {
457 if !matches!(method, MergeMethod::Merge | MergeMethod::Squash) {
458 return Ok(Ok(None));
459 }
460 // Nothing of the default is used when the merger wrote both.
461 let default = if title.is_some() && body.is_some() {
462 Some((String::new(), String::new()))
463 } else {
464 self.default_messages(repo, pull, settings, &[method]).await?.pop().and_then(|(_, message)| message)
465 };
466 Ok(merge_message::chosen(default, title, body))
467 }
468
469 /// `merge_choices`: how a pull request may merge now, for the merge box.
470 pub(crate) async fn merge_choices(&self, a: ViewArgs) -> Result<Outcome<MergeChoices>> {
471 let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?);
472 crate::fill_base(&mut pull, &repo);
473 let base = pull.base_branch(&repo.default_branch).to_owned();
474 // The commits kept for its messages are read beside its settings;
475 // only when none are current are they read from its source.
476 let ((settings, requirements), behind, kept) = futures_util::future::try_join3(
477 self.settings_and_requirements(&repo, &pull),
478 self.behind_now(&pull),
479 self.kept_commit_texts(&pull),
480 )
481 .await?;
482 let methods = offered(&settings, &requirements);
483 let default_method = choose_method(None, &settings, &requirements, &base).ok();
484 let viewer = a.viewer.as_ref();
485 // Whoever looks may catch it up to fast-forward it: they could push
486 // the merge themselves (catch_up_pull).
487 let can_catch_up = viewer.is_some_and(|viewer| match (&pull.fork_repo_id, pull.from_head_repo()) {
488 (Some(_), _) => pull.is_owned_by(&viewer.id),
489 // A fork's branch takes pushes from whoever repos lets push
490 // there, which is not known here: offered, and refused clearly.
491 (None, true) => true,
492 (None, false) => access::can(Some(viewer), &repo, Capability::Push),
493 });
494 let commits = if !methods.iter().any(|method| merge_message::needs_commits(*method, &settings)) {
495 Vec::new()
496 } else if let Some(kept) = kept {
497 kept
498 } else if pull.status.is_active() {
499 self.read_commit_texts(&pull, &repo.id, &base, None).await?
500 } else {
501 // A merged or closed one merges no more.
502 Vec::new()
503 };
504 let items = self.items_to_close(&pull, &repo, &settings, false, &[]).await?.close_numbers();
505 let messages = messages_for(&repo, &pull, &settings, &methods, &commits, &items);
506 let options = messages
507 .into_iter()
508 .map(|(method, message)| {
509 let unavailable = if behind && settings.require_up_to_date {
510 Some(format!("This repository requires it to be up to date with {base} before it merges."))
511 } else {
512 (method == MergeMethod::FastForward && behind && !can_catch_up)
513 .then(|| format!("It can only fast-forward once it is up to date with {base}."))
514 };
515 let (commit_title, commit_message) = match message {
516 Some((title, body)) => (Some(title), Some(body)),
517 None => (None, None),
518 };
519 MergeChoice { method, commit_title, commit_message, unavailable }
520 })
521 .collect();
522 let can_delete_branch = deletable_branch(&pull, &repo).is_some();
523 let auto_merge_allowed = settings.allow_auto_merge
524 && pull.status.is_active()
525 && viewer.is_some_and(|viewer| access::can(Some(viewer), &repo, Capability::Merge));
526 Ok(Outcome::Ok(MergeChoices {
527 methods,
528 default_method,
529 options,
530 behind,
531 can_fast_forward: !behind,
532 suggest_update: behind && (settings.suggest_updating_branches || settings.require_up_to_date),
533 require_up_to_date: settings.require_up_to_date,
534 merge_queue: settings.merge_queue,
535 auto_merge_allowed,
536 delete_branch_default: settings.delete_branch_on_merge && can_delete_branch,
537 can_delete_branch,
538 sign_off: settings.require_web_commit_sign_off,
539 }))
540 }
541
542 /// Whether another open pull request still needs `branch` of
543 /// `source_id`: one merging into it, or one opened from it into
544 /// another branch.
545 async fn branch_in_use(&self, pull: &Pull, branch: &str) -> Result<bool> {
546 Ok(self
547 .db
548 .prepare(
549 "SELECT 1 AS value FROM pulls
550 WHERE id <> ?1 AND status IN ('draft', 'open') AND fork_repo_id IS NULL
551 AND ((repo_id = ?2 AND base_branch = ?3 AND ?4 IS NULL)
552 OR (source_branch = ?3 AND COALESCE(head_repo_id, repo_id) = ?5))
553 LIMIT 1",
554 )
555 .bind(&[
556 pull.id.as_str().into(),
557 pull.repo_id.as_str().into(),
558 branch.into(),
559 crate::optional(&pull.head_repo_id),
560 pull.source_id().into(),
561 ])?
562 .first::<u32>(Some("value"))
563 .await?
564 .is_some())
565 }
566
567 /// Deletes a pull request's branch as `actor`, if it may be deleted and
568 /// still points at `head`, and says so. Repos decides whether `actor`
569 /// may push there and whether the rules for the branch let it go.
570 /// Whether it was deleted, or why not.
571 async fn remove_head_branch(&self, repo: &Repo, pull: &Pull, actor: &User, head: Option<String>) -> Result<Outcome<()>> {
572 let Some((path, branch)) = deletable_branch(pull, repo) else {
573 return Ok(Outcome::fail(
574 FailureCode::Invalid,
575 if pull.fork_repo_id.is_some() {
576 "This pull request's change is in a working copy made for it, which goes by itself.".to_owned()
577 } else {
578 "This pull request's branch cannot be deleted here.".to_owned()
579 },
580 ));
581 };
582 if self.branch_in_use(pull, &branch).await? {
583 return Ok(Outcome::fail(
584 FailureCode::Conflict,
585 format!("Another open pull request still uses {branch}, so it stays."),
586 ));
587 }
588 let removed: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call(
589 &self.repos,
590 "remove_branch",
591 &RemoveBranchArgs { actor: actor.clone(), path, branch: branch.clone(), head, surface: None },
592 )
593 .await?;
594 check!(removed);
595 self.db
596 .prepare("UPDATE pulls SET head_branch_deleted = 1 WHERE id = ?")
597 .bind(&[pull.id.as_str().into()])?
598 .run()
599 .await?;
600 self.note(&pull.repo_id, pull.number, (&actor.id, &actor.username), &format!("deleted the `{branch}` branch"))
601 .await?;
602 self.publish(
603 "pull.head_branch_deleted",
604 &pull.repo_id,
605 actor,
606 PullEvent { detail: Some(branch), ..Self::pull_event(pull) },
607 )
608 .await?;
609 Ok(Outcome::Ok(()))
610 }
611
612 /// After a merge: deletes its branch where the merger asked for it, or
613 /// the repository does on merge. A branch that cannot go stays, quietly:
614 /// the merge itself succeeded.
615 pub(crate) async fn delete_branch_after_merge(
616 &self,
617 repo: &Repo,
618 pull: &Pull,
619 actor: &User,
620 asked: Option<bool>,
621 head: Option<String>,
622 ) -> Result<()> {
623 if !asked.unwrap_or(self.settings(&repo.id).await?.delete_branch_on_merge) || deletable_branch(pull, repo).is_none() {
624 return Ok(());
625 }
626 // A branch that moved since it merged keeps the new commits.
627 if head.is_none() {
628 return Ok(());
629 }
630 if let Outcome::Fail(failure) = self.remove_head_branch(repo, pull, actor, head).await? {
631 worker::console_warn!("branch of {} kept after merging: {}", pull.id, failure.message);
632 }
633 Ok(())
634 }
635
636 /// `delete_head_branch`: deletes a merged or closed pull request's
637 /// branch, if it still points where the pull request left it.
638 pub(crate) async fn delete_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
639 if !a.actor.verified {
640 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
641 }
642 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
643 check!(writable(&repo));
644 if pull.status.is_active() {
645 return Ok(Outcome::fail(
646 FailureCode::Conflict,
647 "This pull request is still open. Its branch can be deleted once it is merged or closed.",
648 ));
649 }
650 if pull.head_branch_deleted {
651 return Ok(Outcome::fail(FailureCode::Conflict, "Its branch is already deleted."));
652 }
653 if !pull.from_head_repo() {
654 check!(allowed(Some(&a.actor), &repo, Capability::Push));
655 }
656 check!(self.remove_head_branch(&repo, &pull, &a.actor, pull.head_commit.clone()).await?);
657 Ok(match self.pull(&repo.id, pull.number).await? {
658 Some(pull) => Outcome::Ok(pull),
659 None => no_pull(),
660 })
661 }
662
663 /// `restore_head_branch`: makes a merged or closed pull request's
664 /// branch again, at the commit it was left at.
665 pub(crate) async fn restore_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
666 if !a.actor.verified {
667 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
668 }
669 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
670 check!(writable(&repo));
671 if pull.status.is_active() {
672 return Ok(Outcome::fail(FailureCode::Conflict, "This pull request is still open, and so is its branch."));
673 }
674 let (Some((path, branch)), Some(head)) = (deletable_branch(&pull, &repo), pull.head_commit.clone()) else {
675 return Ok(Outcome::fail(FailureCode::Invalid, "This pull request has no branch that can be restored."));
676 };
677 let exists: Option<String> = g1t_kit::call(
678 &self.repos,
679 "head",
680 &HeadArgs { repo_id: pull.source_id().to_owned(), branch: branch.clone() },
681 )
682 .await?;
683 if exists.is_some() {
684 return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} exists already.")));
685 }
686 let created: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call(
687 &self.repos,
688 "create_branch",
689 &CreateBranchArgs { actor: a.actor.clone(), path, name: branch.clone(), from: head, surface: None },
690 )
691 .await?;
692 check!(created);
693 self.db
694 .prepare("UPDATE pulls SET head_branch_deleted = 0 WHERE id = ?")
695 .bind(&[pull.id.as_str().into()])?
696 .run()
697 .await?;
698 self.note(&pull.repo_id, pull.number, (&a.actor.id, &a.actor.username), &format!("restored the `{branch}` branch"))
699 .await?;
700 self.publish(
701 "pull.head_branch_restored",
702 &pull.repo_id,
703 &a.actor,
704 PullEvent { detail: Some(branch), ..Self::pull_event(&pull) },
705 )
706 .await?;
707 Ok(match self.pull(&repo.id, pull.number).await? {
708 Some(pull) => Outcome::Ok(pull),
709 None => no_pull(),
710 })
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use crate::rows::stored::{ASKER, G1T, pull};
718
719 fn rules(allowed: Option<Vec<MergeMethod>>, linear: bool) -> Requirements {
720 Requirements { allowed_merge_methods: allowed, linear_history: linear, ..Requirements::default() }
721 }
722
723 #[test]
724 fn the_method_is_the_one_asked_for_if_allowed() {
725 let settings = RepoSettings::default();
726 let open = rules(None, false);
727 assert_eq!(choose_method(Some(MergeMethod::Squash), &settings, &open, "main"), Ok(MergeMethod::Squash));
728 assert_eq!(choose_method(None, &settings, &open, "main"), Ok(MergeMethod::Merge));
729 // Fast-forward is off unless the repository turns it on.
730 let refused = choose_method(Some(MergeMethod::FastForward), &settings, &open, "main").unwrap_err();
731 assert_eq!(refused, "This repository does not allow merging by fast-forward. It allows merge commit, squash or rebase.");
732 let ff = RepoSettings { allow_fast_forward: true, ..RepoSettings::default() };
733 assert_eq!(choose_method(Some(MergeMethod::FastForward), &ff, &open, "main"), Ok(MergeMethod::FastForward));
734 }
735
736 #[test]
737 fn linear_history_refuses_a_merge_commit_and_moves_the_default_on() {
738 let settings = RepoSettings::default();
739 let linear = rules(None, true);
740 let refused = choose_method(Some(MergeMethod::Merge), &settings, &linear, "main").unwrap_err();
741 assert_eq!(refused, "main requires linear history, so a merge commit cannot land there. Merge by squash or rebase instead.");
742 // Unasked, the default (merge) gives way to the first that may.
743 assert_eq!(choose_method(None, &settings, &linear, "main"), Ok(MergeMethod::Squash));
744 assert_eq!(offered(&settings, &linear), vec![MergeMethod::Squash, MergeMethod::Rebase]);
745 }
746
747 #[test]
748 fn rules_narrow_the_methods() {
749 let settings = RepoSettings { default_merge_method: MergeMethod::Squash, ..RepoSettings::default() };
750 let rebase_only = rules(Some(vec![MergeMethod::Rebase]), false);
751 assert_eq!(choose_method(None, &settings, &rebase_only, "main"), Ok(MergeMethod::Rebase));
752 let refused = choose_method(Some(MergeMethod::Squash), &settings, &rebase_only, "release").unwrap_err();
753 assert_eq!(refused, "The rules for release allow merging only by rebase, not by squash.");
754 // Nothing both allow.
755 let ff_only = rules(Some(vec![MergeMethod::FastForward]), false);
756 let none = choose_method(None, &settings, &ff_only, "main").unwrap_err();
757 assert!(none.starts_with("No merge method this repository allows (merge commit, squash or rebase)"), "{none}");
758 assert!(offered(&settings, &ff_only).is_empty());
759 }
760
761 #[test]
762 fn behind_is_no_problem_unless_it_must_be_up_to_date_or_fast_forward() {
763 assert_eq!(when_behind(MergeMethod::Merge, false, true, "main"), WhenBehind::Land);
764 for method in [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase] {
765 assert_eq!(when_behind(method, true, false, "main"), WhenBehind::Land, "{method:?}");
766 assert!(matches!(when_behind(method, true, true, "main"), WhenBehind::Refuse(_)));
767 }
768 assert_eq!(when_behind(MergeMethod::FastForward, true, false, "main"), WhenBehind::CatchUpFirst);
769 assert_eq!(when_behind(MergeMethod::FastForward, false, false, "main"), WhenBehind::Land);
770 assert!(matches!(when_behind(MergeMethod::FastForward, true, true, "main"), WhenBehind::Refuse(_)));
771 }
772
773 fn repo() -> Repo {
774 serde_json::from_value(serde_json::json!({
775 "id": "rep_1", "namespace": "flagon", "name": "g1t", "isPrivate": false,
776 "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "protected": false,
777 "createdAt": "", "updatedAt": ""
778 }))
779 .unwrap()
780 }
781
782 fn branch_pull(branch: &str) -> Pull {
783 let mut made = pull(ASKER, None);
784 made.fork = None;
785 made.fork_repo_id = None;
786 made.branch = Some(branch.into());
787 made
788 }
789
790 #[test]
791 fn only_a_branch_that_is_not_the_base_or_a_working_copy_is_deleted() {
792 let repo = repo();
793 let feature = branch_pull("feature");
794 assert_eq!(
795 deletable_branch(&feature, &repo),
796 Some((RepoPath { namespace: "flagon".into(), name: "g1t".into() }, "feature".into()))
797 );
798 assert!(deletable_branch(&branch_pull("main"), &repo).is_none(), "the default branch stays");
799 let mut into_release = branch_pull("release");
800 into_release.base = Some("release".into());
801 assert!(deletable_branch(&into_release, &repo).is_none());
802 // A working copy goes by itself.
803 assert!(deletable_branch(&pull(G1T, Some(ASKER)), &repo).is_none());
804 // A fork's branch, even one named like the base.
805 let mut forked = branch_pull("main");
806 forked.head_repo = Some(RepoPath { namespace: "ana".into(), name: "g1t".into() });
807 forked.head_repo_id = Some("rep_2".into());
808 assert_eq!(deletable_branch(&forked, &repo).map(|(path, branch)| (path.namespace, branch)), Some(("ana".into(), "main".into())));
809 }
810
811 #[test]
812 fn a_squash_is_by_the_person_whose_change_it_is() {
813 let own = branch_pull("feature");
814 assert_eq!(squash_author(&own, MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned()));
815 assert!(squash_author(&own, MergeMethod::Merge).is_none());
816 // What g1t made for someone is theirs.
817 assert_eq!(squash_author(&pull(G1T, Some(ASKER)), MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned()));
818 // g1t's own work is the merger's.
819 assert!(squash_author(&pull(("g1t", "g1t"), None), MergeMethod::Squash).is_none());
820 }
821
822 fn diverged(head: &str, base: &str, merge_base: Option<&str>) -> Divergence {
823 Divergence { head: head.into(), base: base.into(), merge_base: merge_base.map(Into::into), ..Divergence::default() }
824 }
825
826 #[test]
827 fn kept_commits_are_used_only_for_the_head_and_pair_they_were_kept_for() {
828 let current = |kept_head, kept_pair, head, key| kept_is_current(kept_head, kept_pair, head, key);
829 assert!(current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h1..b1")));
830 // Its head moved: pushed to since they were kept.
831 assert!(!current(Some("h1"), Some("h1..b1"), Some("h2"), Some("h1..b1")));
832 // Its base moved and mergeability was worked out again without them
833 // being confirmed for the new pair.
834 assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h1..b2")));
835 // Mergeability worked out for another head than the one it has now.
836 assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h0..b1")));
837 assert!(!current(Some("h1"), Some("h0..b1"), Some("h1"), Some("h0..b1")));
838 // Nothing kept, nothing worked out, or nothing pushed.
839 assert!(!current(None, None, Some("h1"), Some("h1..b1")));
840 assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), None));
841 assert!(!current(Some("h1"), Some("h1..b1"), None, Some("h1..b1")));
842 }
843
844 #[test]
845 fn commits_are_read_again_only_when_the_head_or_merge_base_moves() {
846 let now = diverged("h1", "b1", Some("m1"));
847 assert_eq!(keeping(None, &now), Keeping::Read, "none kept");
848 assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b1")), &now), Keeping::Current);
849 // Its base moved on without taking any of its commits.
850 assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b0")), &now), Keeping::Confirm);
851 // Pushed to.
852 assert_eq!(keeping(Some(("h0", Some("m1"), "h0..b1")), &now), Keeping::Read);
853 // Its base took some of its commits, or it caught up with its base.
854 assert_eq!(keeping(Some(("h1", Some("m0"), "h1..b0")), &now), Keeping::Read);
855 assert_eq!(keeping(Some(("h1", None, "h1..b0")), &now), Keeping::Read);
856 // No history in common: nothing is kept, and the box reads them live.
857 assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b1")), &diverged("h1", "b1", None)), Keeping::Nothing);
858 }
859
860 #[test]
861 fn kept_commits_read_back_as_they_were_written() {
862 let texts = vec![
863 CommitText { message: "feat: one\n\nWhy.".into(), author_email: Some("ada@acme.com".into()) },
864 CommitText { message: "fix: two".into(), author_email: None },
865 ];
866 let json = serde_json::to_string(&texts).unwrap();
867 assert_eq!(serde_json::from_str::<Vec<CommitText>>(&json).unwrap(), texts);
868 }
869
870 #[test]
871 fn merge_commits_are_left_out_of_the_messages_oldest_first() {
872 let commit = |sha: &str, parents| g1t_contracts::rules::CommitFacts {
873 sha: sha.into(),
874 message: sha.into(),
875 parents,
876 ..g1t_contracts::rules::CommitFacts::default()
877 };
878 let inspected = InspectedCommits { commits: vec![commit("newest", 1), commit("merge", 2), commit("oldest", 0)], complete: true };
879 let texts: Vec<String> = texts_of(inspected).into_iter().map(|text| text.message).collect();
880 assert_eq!(texts, ["oldest", "newest"]);
881 }
882
883 #[test]
884 fn the_timeline_says_how_it_merged() {
885 assert_eq!(merged_note(MergeMethod::Squash), "squashed and merged this");
886 assert_eq!(merged_note(MergeMethod::FastForward), "merged this");
887 assert_eq!(labels(&[MergeMethod::Squash, MergeMethod::Rebase, MergeMethod::FastForward]), "squash, rebase or fast-forward");
888 }
889}