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.

g1t.sh
913 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) -> 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".
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
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.
284pub(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
292impl 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)]
724mod 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}