Skip to content

Commit

A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent60e3604Browse files
13 files+1047−200/13 viewed
+46−1
14141414 always the default branch: see
14151415 [pull requests into other branches](/guides/base-branches/).
14161416
1417+### What a pull request's run builds
1418+
1419+As on GitHub, a `pull_request` run builds the pull request merged into
1420+its base branch, not its head alone:
1421+
1422+| | Is |
1423+| --- | --- |
1424+| `github.ref`, `GITHUB_REF` | `refs/pull/<number>/merge` |
1425+| `github.sha`, `GITHUB_SHA` | The test merge commit at `refs/pull/<number>/merge`: the head merged into the base branch's latest commit, its parents the base's commit and then the head, with the message `Merge <head> into <base>` |
1426+| `github.event.pull_request.head.sha` | The head commit |
1427+| `github.event.pull_request.merge_commit_sha` | The test merge commit |
1428+| `actions/checkout` with no `ref` | The test merge commit, fetched from the repository itself as `+<sha>:refs/remotes/pull/<number>/merge` |
1429+
1430+The workflow files the run uses are read from the test merge commit too.
1431+Its status, its check runs and the run itself stay on the pull request's
1432+head, so required checks hold the merge as before. `pull_request_review`
1433+runs build the same commit. `pull_request_target` runs are unchanged: they
1434+build the base branch, never the pull request's code.
1435+
1436+g1t keeps two refs in the repository for every open pull request, from a
1437+branch, a fork or a pull request g1t made: `refs/pull/<number>/head` at
1438+its head (a fork's head commit is copied into the repository), and
1439+`refs/pull/<number>/merge` at its test merge commit. Both move each time
1440+the head is pushed to, the pull request is opened or reopened, or its
1441+base branch changes, and both can be fetched like any branch:
1442+
1443+```sh
1444+git fetch origin refs/pull/12/head refs/pull/12/merge
1445+```
1446+
1447+The merge commit moves when the head does. When only the base branch
1448+moves, it moves at the pull request's next event, as on GitHub, where
1449+workflows do not run again because the base branch moved.
1450+
1451+**A pull request with conflicts runs no `pull_request` workflows**, as on
1452+GitHub: it has no merge commit to build. Its checks box says so, and the
1453+runs start once the conflicts are resolved and the head is pushed again.
1454+g1t merges the two the way git does: a file only one side changed is
1455+taken from it, and changes both made to the same file merge unless they
1456+change the same or neighbouring lines differently, or the file is binary.
1457+When g1t cannot make the merge itself (a file one side renamed or deleted
1458+while the other changed it, a file where the other side has a directory,
1459+more than 300 changed files, or a file over 1 MB that both changed), the
1460+run builds the head commit instead, as it did before.
1461+
14171462 A pull request from outside the workspace may wait for approval before
14181463 its workflows run: see [pull requests from outside](#pull-requests-from-outside).
14191464
14661511 ## Checks
14671512
14681513 A pull request's checks are its workflows. Each workflow that runs on
1469−`pull_request` runs on every pull request's head, whoever opened it, a
1514+`pull_request` runs for every pull request, whoever opened it, a
14701515 person or an agent, and its runs report a check named after the workflow:
14711516 a workflow with `name: CI` reports `CI`, with the status context
14721517 `CI / pull_request` (the workflow's name and the event). Each of its jobs
+5−0
208208 -d "{\"name\": \"coverage\", \"head_sha\": \"$GITHUB_SHA\", \"conclusion\": \"neutral\", \"output\": {\"title\": \"81% covered\", \"summary\": \"Up 2% from main.\"}}"
209209 ```
210210
211+On a pull request's run, `GITHUB_SHA` is its
212+[test merge commit](/guides/actions/#what-a-pull-requests-run-builds), as on
213+GitHub: to show the check on the pull request, report it on the head,
214+`${{ github.event.pull_request.head.sha || github.sha }}`.
215+
211216 A pull request's runs from someone without the Write role get no token
212217 that can write, so they cannot report checks.
213218
+20−2
324324 earlier,
325325 canRerunWorkflows,
326326 settingsUrl,
327+ conflicting = false,
327328 error,
328329 loading = false,
329330 settled = false,
341342 canRerunWorkflows: boolean;
342343 /** Where the required checks are chosen, for those who may. */
343344 settingsUrl?: string | null;
345+ /**
346+ * It conflicts with its target, so its `pull_request` workflows do not
347+ * run: there is no merge commit to build, as on GitHub.
348+ */
349+ conflicting?: boolean;
344350 error?: string | null;
345351 /** The workflow runs' jobs are still being read: their rows wait, busy. */
346352 loading?: boolean;
350356 */
351357 settled?: boolean;
352358 }) {
353− if (!run && statuses.length === 0 && required.length === 0) return null;
359+ const held = conflicting && !settled;
360+ if (!run && statuses.length === 0 && required.length === 0 && !held) return null;
354361
355362 // Every workflow row, so they can be counted the same way they are shown.
356363 const workflow: Standing[] = statuses.flatMap((status) => {
374381 ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running`
375382 : total > 0
376383 ? "All checks have passed"
377− : "Checks";
384+ : held
385+ ? "Workflows have not run"
386+ : "Checks";
378387 const others = [
379388 failed > 0 && `${failed} failed`,
380389 pending > 0 && `${pending} running`,
409418 <CircleX size={16} className="text-danger" />
410419 ) : standing === "running" ? (
411420 <LoaderCircle size={16} className="animate-spin text-warn" />
421+ ) : held && total === 0 ? (
422+ <CircleAlert size={16} className="text-warn" />
412423 ) : (
413424 <CircleCheck size={16} className="text-success" />
414425 )}
416427 <div className="min-w-0 grow">
417428 <p className="text-sm font-medium">{headline}</p>
418429 {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
430+ {held && (
431+ <p className="mt-0.5 text-xs text-muted">
432+ It conflicts with the branch it merges into, so workflows that run on{" "}
433+ <code className="font-mono">pull_request</code> do not start for it: they build the pull request
434+ merged into that branch, and there is no such merge. They run again once the conflicts are resolved.
435+ </p>
436+ )}
419437 {!summary && !settled && settingsUrl && (
420438 <p className="mt-0.5 text-xs text-muted">
421439 <Link to={settingsUrl} className="text-fg hover:underline">
+1−0
677677 earlier={earlierChecks}
678678 canRerunWorkflows={canMerge}
679679 settingsUrl={canProtect ? `${base}/settings/branches` : null}
680+ conflicting={mergeable === "conflicting" && !noChecks}
680681 error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null}
681682 loading={loading}
682683 />
+50−0
905905 },
906906 }
907907
908+/// `pull_merge_ref`: points `refs/pull/<number>/head` in the repository a
909+/// pull request merges into at its head, fetching the head's objects from
910+/// the fork first when it comes from one, and `refs/pull/<number>/merge`
911+/// at the commit that merges that head into the base branch's tip, made
912+/// without a sandbox: what GitHub's `pull_request` runs build. For the
913+/// actions service, which has already decided the pull request's events
914+/// may start runs. Returns `PullMergeRef`.
915+#[derive(Debug, Serialize, Deserialize)]
916+#[serde(rename_all = "camelCase")]
917+pub struct PullMergeRefArgs {
918+ /// The repository the pull request merges into.
919+ pub repo_id: String,
920+ /// The pull request's fork, or the repository itself for a branch.
921+ pub source_id: String,
922+ /// The pull request's head commit.
923+ pub head: String,
924+ pub number: u32,
925+ /// The branch it merges into; the default branch when absent.
926+ #[serde(default)]
927+ pub target_branch: Option<String>,
928+}
929+
930+/// What came of `pull_merge_ref`.
931+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
932+#[serde(tag = "outcome", rename_all = "snake_case")]
933+pub enum PullMergeRef {
934+ /// `refs/pull/<number>/merge` is `merge`, whose parents are `base`
935+ /// (the base branch's tip) and `head`.
936+ Merged { head: String, base: String, merge: String },
937+ /// The head and the base branch change the same lines, or the same
938+ /// binary file, differently: there is no merge commit, and
939+ /// `refs/pull/<number>/merge` is removed, as on GitHub.
940+ Conflict { head: String, base: String, paths: Vec<String> },
941+ /// g1t could not make the merge commit itself (a file renamed on one
942+ /// side and changed on the other, a very large change, a history it
943+ /// could not follow), or either side has no commits: `reason` says why.
944+ Unavailable { reason: String },
945+}
946+
947+/// The ref a pull request's head is kept at in the repository it merges
948+/// into, as on GitHub.
949+pub fn pull_head_ref(number: u32) -> String {
950+ format!("refs/pull/{number}/head")
951+}
952+
953+/// The ref of a pull request's test merge commit, as on GitHub.
954+pub fn pull_merge_ref(number: u32) -> String {
955+ format!("refs/pull/{number}/merge")
956+}
957+
908958 /// `head`: the commit a branch points to, or null. For services reacting
909959 /// to a push, which have no viewer; it reveals nothing but a commit hash.
910960 /// Returns `Option<String>`.
+10−4
8686 /// Why the run waits for someone with the Write role to approve it
8787 /// before anything starts: a pull request from outside (protection.rs).
8888 pub approval: Option<String>,
89+ /// The commit the run is listed, and its statuses and checks reported,
90+ /// on when it is not the one it builds (`info.sha`): a pull request's
91+ /// head, when the run builds its test merge commit, as on GitHub.
92+ pub head_sha: Option<String>,
8993 }
9094
9195 #[derive(Clone, Deserialize)]
679683 info.event_name.as_str().into(),
680684 optional(new.action.as_deref()),
681685 info.git_ref.as_str().into(),
682− info.sha.as_str().into(),
686+ new.head_sha.as_deref().unwrap_or(&info.sha).into(),
683687 new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
684688 if approval.is_some() { "action_required" } else { "queued" }.into(),
685689 optional(new.actor.as_deref()),
23592363 debug_logging(&mut variables, &mut runner);
23602364 }
23612365
2362− // Where to check out: a pull request's fork, or the repository.
2366+ // Where to check out: the repository, which holds a pull request's
2367+ // test merge commit; a pull request's fork when the run builds its
2368+ // head instead (`run.sha` is the head, `info.sha` what is built).
23632369 let clone_url = match run.pull {
2364− Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" => {
2370+ Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" && info.sha == run.sha => {
23652371 let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call(
23662372 &self.work,
23672373 "get_pull",
24142420 "checkout": {
24152421 "repository": run.repo,
24162422 "url": clone_url,
2417− "sha": run.sha,
2423+ "sha": info.sha,
24182424 "ref": run.git_ref,
24192425 "token": token,
24202426 },
+1−1
392392 .filter(|call| call["role"] == "callee")
393393 .and_then(|call| call.get("origin").cloned())
394394 .filter(Value::is_object)
395− .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.sha }));
395+ .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.info().sha }));
396396 (origin, local.split('@').next().unwrap_or(local).to_owned())
397397 }
398398 Some(used) => {
+108−6
88 use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun};
99 use g1t_contracts::events::{Event, caused_by_job};
1010 use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
11−use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, Repo, RepoPath};
11+use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
1212 use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
1313 use g1t_contracts::{FailureCode, Outcome, User, new_id};
1414 use g1t_contracts::time::rfc3339;
4747 /// Why its runs wait for approval first: a pull request from outside,
4848 /// by the repository's approval policy (protection.rs).
4949 approval: Option<String>,
50+ /// What the run builds when it is not `sha`: a pull request's test
51+ /// merge commit (`refs/pull/<n>/merge`), as on GitHub, which becomes
52+ /// `github.sha` while statuses and checks stay on `sha`, its head.
53+ merge: Option<String>,
5054 }
5155
5256 /// Whether a deployment's `ref` is a commit's full hash rather than a
7579 matches!(event_name, "create" | "delete")
7680 }
7781
82+/// What a pull request's run builds, given its test merge commit.
83+#[derive(Debug, PartialEq, Eq)]
84+enum PullBuild {
85+ /// The merge commit, as on GitHub.
86+ Merge(String),
87+ /// The head: g1t could not make the merge commit itself, or a
88+ /// conflicting pull request's event that still runs without one.
89+ Head,
90+ /// No run: GitHub starts no `pull_request` workflow while the pull
91+ /// request has conflicts, since there is no merge commit to build. The
92+ /// pull request's checks say so (apps/web `pull.tsx`).
93+ Nothing,
94+}
95+
96+fn pull_build(event_name: &str, action: Option<&str>, made: &PullMergeRef) -> PullBuild {
97+ match made {
98+ PullMergeRef::Merged { merge, .. } => PullBuild::Merge(merge.clone()),
99+ PullMergeRef::Conflict { .. } if event_name == "pull_request" && action != Some("closed") => PullBuild::Nothing,
100+ PullMergeRef::Conflict { .. } | PullMergeRef::Unavailable { .. } => PullBuild::Head,
101+ }
102+}
103+
78104 /// Whether an `*.assigned` event added nobody: g1t says so for a change
79105 /// that only took people off.
80106 fn nobody_added(data: &Value) -> bool {
213239 .await
214240 }
215241
242+ /// The pull request's test merge commit for `head` (repos
243+ /// `pull_merge_ref`), made or moved now. A failure to ask is
244+ /// unavailable, so that its runs still start, on the head.
245+ async fn test_merge(&self, repo: &Repo, pull: &g1t_contracts::work::Pull, head: &str, base_ref: &str) -> PullMergeRef {
246+ let asked: Result<PullMergeRef> = g1t_kit::call(
247+ &self.repos,
248+ "pull_merge_ref",
249+ &PullMergeRefArgs {
250+ repo_id: repo.id.clone(),
251+ source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
252+ head: head.to_owned(),
253+ number: pull.number,
254+ target_branch: Some(base_ref.to_owned()),
255+ },
256+ )
257+ .await;
258+ asked.unwrap_or_else(|error| PullMergeRef::Unavailable { reason: error.to_string() })
259+ }
260+
216261 /// Whether `name` is one of the repository's branches.
217262 async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
218263 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
253298 title,
254299 trusted: true,
255300 approval: None,
301+ merge: None,
256302 };
257303 let view = |number: u32| ViewArgs {
258304 repo: path.clone(),
288334 title,
289335 trusted: true,
290336 approval: None,
337+ merge: None,
291338 })
292339 }
293340 // A branch or tag was made: its own commit, as on GitHub.
323370 title: format!("Created {ref_type} {name}"),
324371 trusted: true,
325372 approval: None,
373+ merge: None,
326374 })
327375 }
328376 // A branch or tag was deleted: on the default branch's latest
408456 (None, None, Some(head)) => head.clone(),
409457 (None, None, None) => return Ok(None),
410458 };
411− let source = match landed {
412− Some(_) => path.clone(),
413− None => pull.fork.clone().unwrap_or_else(|| path.clone()),
459+ // Otherwise it builds the head merged into the base's tip,
460+ // as on GitHub: the test merge commit at refs/pull/<n>/merge,
461+ // in the repository, whose workflows it reads.
462+ let merge = match landed {
463+ Some(_) => None,
464+ None => {
465+ let made = self.test_merge(repo, pull, &sha, &base_ref).await;
466+ match pull_build(event_name, action, &made) {
467+ PullBuild::Merge(merge) => Some(merge),
468+ PullBuild::Nothing => {
469+ worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number);
470+ return Ok(None);
471+ }
472+ PullBuild::Head => {
473+ worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
474+ None
475+ }
476+ }
477+ }
478+ };
479+ if let Some(merge) = &merge {
480+ payload["pull_request"]["merge_commit_sha"] = json!(merge);
481+ }
482+ let (source, source_ref) = match (&landed, &merge) {
483+ (Some(_), _) => (path.clone(), sha.clone()),
484+ (None, Some(merge)) => (path.clone(), merge.clone()),
485+ (None, None) => (pull.fork.clone().unwrap_or_else(|| path.clone()), sha.clone()),
414486 };
415487 Some(Subject {
416488 source,
417− source_ref: Some(sha.clone()),
489+ source_ref: Some(source_ref),
490+ merge,
418491 git_ref: format!("refs/pull/{}/merge", pull.number),
419492 sha,
420493 head_ref: Some(head_ref),
562635 title: format!("Release {name} {}", action.unwrap_or("changed")),
563636 trusted: true,
564637 approval: None,
638+ merge: None,
565639 })
566640 }
567641 // A deployment, or a new status of one: at the commit deployed,
608682 title,
609683 trusted: true,
610684 approval: None,
685+ merge: None,
611686 })
612687 }
613688 _ => None,
791866 actor: Some(sender.to_owned()),
792867 trusted: subject.trusted,
793868 approval: subject.approval.clone().or(held),
869+ head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()),
794870 })
795871 .await?;
796872 }
855931 event_name: event_name.to_owned(),
856932 event: subject.payload.clone(),
857933 git_ref: subject.git_ref.clone(),
858− sha: subject.sha.clone(),
934+ sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()),
859935 head_ref: subject.head_ref.clone(),
860936 base_ref: subject.base_ref.clone(),
861937 actor: sender.to_owned(),
9351011 title: format!("Scheduled: {cron}"),
9361012 trusted: true,
9371013 approval: None,
1014+ merge: None,
9381015 };
9391016 subject.filter_ref = subject.git_ref.clone();
9401017 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
9531030 actor: None,
9541031 trusted: true,
9551032 approval: None,
1033+ head_sha: None,
9561034 })
9571035 .await?;
9581036 }
10521130 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
10531131 trusted: true,
10541132 approval: None,
1133+ merge: None,
10551134 };
10561135 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
10571136 let created = self
10701149 actor: Some(a.actor.username.clone()),
10711150 trusted: true,
10721151 approval: None,
1152+ head_sha: None,
10731153 })
10741154 .await?;
10751155 match created {
11421222 title: event_type.clone(),
11431223 trusted: true,
11441224 approval: None,
1225+ merge: None,
11451226 };
11461227 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
11471228 let created = self
11601241 actor: Some(a.actor.username.clone()),
11611242 trusted: true,
11621243 approval: None,
1244+ head_sha: None,
11631245 })
11641246 .await?;
11651247 if created.is_some() {
12381320 title: format!("Merge queue: #{}{ahead}", a.number),
12391321 trusted: true,
12401322 approval: None,
1323+ merge: None,
12411324 };
12421325 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
12431326 let created = self
12561339 actor: None,
12571340 trusted: true,
12581341 approval: None,
1342+ head_sha: None,
12591343 })
12601344 .await?;
12611345 if created.is_some() {
13681452 }
13691453 use g1t_contracts::{Membership, PrincipalKind};
13701454
1455+ #[test]
1456+ fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1457+ let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1458+ let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1459+ let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1460+ assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1461+ assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1462+ // GitHub runs no pull_request workflow while it conflicts.
1463+ for action in ["opened", "synchronize", "reopened", "edited"] {
1464+ assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1465+ }
1466+ // Closing it, or reviewing it, still runs, on the head.
1467+ assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1468+ assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1469+ // What g1t could not merge itself runs on the head.
1470+ assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1471+ }
1472+
13711473 fn repo() -> Repo {
13721474 serde_json::from_value(json!({
13731475 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
+28−0
2424 path: String,
2525 old: Option<String>,
2626 new: Option<String>,
27+ old_kind: Option<EntryKind>,
28+ new_kind: Option<EntryKind>,
2729 }
2830
2931 async fn entries<R: GitRepo>(repo: &R, tree: Option<&str>) -> Result<BTreeMap<String, TreeEntry>> {
8688 .filter(|entry| entry.kind != EntryKind::Tree)
8789 .map(|entry| entry.hash.clone())
8890 };
91+ let file_kind = |entry: Option<&TreeEntry>| entry.map(|entry| entry.kind).filter(|kind| *kind != EntryKind::Tree);
8992 let (old_dir, new_dir) = (subtree(before), subtree(after));
9093 if old_dir.is_some() || new_dir.is_some() {
9194 next.push((format!("{path}/"), old_dir, new_dir));
100103 path,
101104 old: old_file,
102105 new: new_file,
106+ old_kind: file_kind(before),
107+ new_kind: file_kind(after),
103108 });
104109 }
105110 }
122127 Ok((changes.into_iter().map(|change| change.path).collect(), truncated))
123128 }
124129
130+/// A file as one tree has it: its kind and blob.
131+pub type FileEntry = (EntryKind, String);
132+
133+/// The files that differ between two trees, each as it was and as it is
134+/// (`None` where it is not there), without reading any file. Returns them
135+/// by path and whether the list was cut short.
136+pub async fn changed_entries<R: GitRepo>(
137+ repo: &R,
138+ old_tree: Option<&str>,
139+ new_tree: &str,
140+) -> Result<(Vec<(String, Option<FileEntry>, Option<FileEntry>)>, bool)> {
141+ let (changes, truncated) = changed_files(repo, old_tree, new_tree).await?;
142+ let entries = changes
143+ .into_iter()
144+ .map(|change| {
145+ let old = change.old_kind.zip(change.old);
146+ let new = change.new_kind.zip(change.new);
147+ (change.path, old, new)
148+ })
149+ .collect();
150+ Ok((entries, truncated))
151+}
152+
125153 /// The text of a blob, or `None` if it is binary, too large or missing.
126154 async fn text<R: GitRepo>(repo: &R, hash: Option<&str>) -> Result<Option<String>> {
127155 let Some(hash) = hash else {
+3−0
4747 mod signatures;
4848 mod stats;
4949 mod store;
50+mod test_merge;
5051 mod transfer;
5152 mod workflow_gate;
5253
26662667 "divergence" => reply(&repos.divergence(args(body)?).await?),
26672668 "land" => reply(&repos.land(args(body)?).await?),
26682669 "update_pull_branch" => reply(&repos.update_pull_branch(args(body)?).await?),
2670+ // Services only: a pull request's test merge commit (test_merge.rs).
2671+ "pull_merge_ref" => reply(&repos.pull_merge_ref(args(body)?).await?),
26692672 "delete_branch" => reply(&repos.delete_branch(args(body)?).await?),
26702673 "commit_file" => reply(&repos.commit_file(args(body)?).await?),
26712674 "compare" => reply(&repos.compare(args(body)?).await?),
+728−0
1+//! A pull request's test merge commit, as GitHub makes one for every open
2+//! pull request: `refs/pull/<number>/head` at the pull request's head, and
3+//! `refs/pull/<number>/merge` at a commit merging that head into the tip of
4+//! the branch it targets. `pull_request` runs build that commit.
5+//!
6+//! It is made here, without a sandbox. A fork's head is first copied into
7+//! the repository (only the objects it lacks travel). Files only one side
8+//! changed since the two last agreed are taken from that side, as catching
9+//! up does (catch_up.rs); a file both changed is merged line by line, the
10+//! way git merges text, and changes to the same or neighbouring lines that
11+//! differ are a conflict, as is a binary file both changed differently.
12+//! Then there is no merge commit, as on GitHub, and the merge ref goes.
13+//!
14+//! What g1t cannot merge itself (a file one side renamed or deleted and the
15+//! other changed, a file against a directory, a change too large to list)
16+//! is answered as unavailable, and the caller decides what to do without it.
17+
18+use std::collections::{BTreeSet, HashMap};
19+
20+use futures_util::future::{try_join, try_join_all};
21+use g1t_contracts::repos::{EntryKind, PullMergeRef, PullMergeRefArgs, TreeEntry, pull_head_ref, pull_merge_ref};
22+use g1t_contracts::time::parse_rfc3339;
23+use g1t_scan::pack::{ObjectKind, object_id, write_pack};
24+use similar::{Algorithm, DiffTag, capture_diff_slices};
25+use worker::Result;
26+
27+use crate::catch_up::{Change, Signature, ancestors, commit_object, merge_tree, overlapping, read_dirs};
28+use crate::diff::{FileEntry, changed_entries};
29+use crate::registry::store_key;
30+use crate::store::{GitRepo, GitStore, Scope};
31+use crate::{MAX_ANCESTRY, Repos, descends_from, land, nearest_ancestor_in, refs};
32+
33+/// Files larger than this are not merged line by line.
34+const MAX_MERGE_BYTES: usize = 1024 * 1024;
35+const ZERO_ID: &str = "0000000000000000000000000000000000000000";
36+
37+/// One side's change to the base: its lines `start..end` become `lines`.
38+#[derive(Debug)]
39+struct Hunk<'a> {
40+ start: usize,
41+ end: usize,
42+ lines: Vec<&'a str>,
43+}
44+
45+/// What `side` changed of `base`, as hunks in order, with no two touching.
46+fn hunks<'a>(base: &[&'a str], side: &[&'a str]) -> Vec<Hunk<'a>> {
47+ let mut out: Vec<Hunk<'a>> = Vec::new();
48+ for op in capture_diff_slices(Algorithm::Myers, base, side) {
49+ let (tag, old, new) = op.as_tag_tuple();
50+ if tag == DiffTag::Equal {
51+ continue;
52+ }
53+ let lines = side[new].to_vec();
54+ match out.last_mut() {
55+ Some(last) if last.end == old.start => {
56+ last.end = old.end;
57+ last.lines.extend(lines);
58+ }
59+ _ => out.push(Hunk { start: old.start, end: old.end, lines }),
60+ }
61+ }
62+ out
63+}
64+
65+/// The base's lines `start..end` with `hunks` (all inside them) applied.
66+fn apply<'a>(base: &[&'a str], hunks: &[Hunk<'a>], start: usize, end: usize) -> Vec<&'a str> {
67+ let mut out = Vec::new();
68+ let mut at = start;
69+ for hunk in hunks {
70+ out.extend_from_slice(&base[at..hunk.start]);
71+ out.extend_from_slice(&hunk.lines);
72+ at = hunk.end;
73+ }
74+ out.extend_from_slice(&base[at..end]);
75+ out
76+}
77+
78+/// Merges two texts that each changed `base`, as git does: a change only
79+/// one side made is taken, and where the two sides' changes overlap or
80+/// touch (no unchanged line between them) they must be the same. `None`
81+/// when they conflict.
82+pub(crate) fn merge_text(base: &str, ours: &str, theirs: &str) -> Option<String> {
83+ fn lines(text: &str) -> Vec<&str> {
84+ text.split_inclusive('\n').collect()
85+ }
86+ let (base, ours, theirs) = (lines(base), lines(ours), lines(theirs));
87+ let (mine, other) = (hunks(&base, &ours), hunks(&base, &theirs));
88+ let mut out = String::new();
89+ let (mut i, mut j, mut at) = (0, 0, 0);
90+ while i < mine.len() || j < other.len() {
91+ // The earliest change starts a run; every change that overlaps or
92+ // touches the run joins it.
93+ let first = if j >= other.len() || (i < mine.len() && mine[i].start <= other[j].start) { &mine[i] } else { &other[j] };
94+ let (start, mut end) = (first.start, first.end);
95+ let (from_mine, from_other) = (i, j);
96+ loop {
97+ if i < mine.len() && mine[i].start <= end {
98+ end = end.max(mine[i].end);
99+ i += 1;
100+ } else if j < other.len() && other[j].start <= end {
101+ end = end.max(other[j].end);
102+ j += 1;
103+ } else {
104+ break;
105+ }
106+ }
107+ let (mine_run, other_run) = (&mine[from_mine..i], &other[from_other..j]);
108+ let merged = match (mine_run.is_empty(), other_run.is_empty()) {
109+ (false, true) => apply(&base, mine_run, start, end),
110+ (true, false) => apply(&base, other_run, start, end),
111+ _ => {
112+ let (a, b) = (apply(&base, mine_run, start, end), apply(&base, other_run, start, end));
113+ if a != b {
114+ return None;
115+ }
116+ a
117+ }
118+ };
119+ out.extend(base[at..start].iter().copied());
120+ out.extend(merged);
121+ at = end;
122+ }
123+ out.extend(base[at..].iter().copied());
124+ Some(out)
125+}
126+
127+/// What becomes of one path in the merge, the base branch's tree being
128+/// where the merge starts.
129+#[derive(Debug, PartialEq, Eq)]
130+pub(crate) enum Step {
131+ /// Put the pull request's version there (`None`: remove it).
132+ Take(Option<FileEntry>),
133+ /// Both changed the file: merge the three texts, as `kind`.
134+ Content { kind: EntryKind, base: Option<String>, ours: String, theirs: String },
135+}
136+
137+/// What the files each side changed since the merge base come to, without
138+/// reading any file.
139+#[derive(Debug, PartialEq, Eq)]
140+pub(crate) enum Plan {
141+ Steps(Vec<(String, Step)>),
142+ Conflict(Vec<String>),
143+ Unsupported(String),
144+}
145+
146+fn text_kind(kind: EntryKind) -> bool {
147+ matches!(kind, EntryKind::Blob | EntryKind::Exec)
148+}
149+
150+type Changed = (String, Option<FileEntry>, Option<FileEntry>);
151+
152+/// The steps that make the merge, from the files the pull request
153+/// (`ours`) and the base branch (`theirs`) changed since they last agreed.
154+pub(crate) fn plan(ours: &[Changed], theirs: &[Changed]) -> Plan {
155+ let their: HashMap<&str, &Changed> = theirs.iter().map(|change| (change.0.as_str(), change)).collect();
156+ let paths = |changes: &[Changed]| changes.iter().map(|change| change.0.clone()).collect::<Vec<_>>();
157+ // A file on one side where the other has a directory.
158+ let ours_paths = paths(ours);
159+ if let Some(met) = overlapping(&ours_paths, &paths(theirs)).into_iter().find(|path| !their.contains_key(path.as_str()) || !ours_paths.contains(path)) {
160+ return Plan::Unsupported(format!("{met} is a file on one side and a directory on the other"));
161+ }
162+ let mut steps = Vec::new();
163+ let mut conflicts = Vec::new();
164+ for (path, base, mine) in ours {
165+ let Some((_, _, other)) = their.get(path.as_str()) else {
166+ steps.push((path.clone(), Step::Take(mine.clone())));
167+ continue;
168+ };
169+ // Both made it the same, or the base branch's is already there.
170+ if mine == other {
171+ continue;
172+ }
173+ let (Some(mine), Some(other)) = (mine, other) else {
174+ return Plan::Unsupported(format!("{path} was deleted or renamed on one side and changed on the other"));
175+ };
176+ let base_kind = base.as_ref().map(|(kind, _)| *kind);
177+ let kind = if mine.0 == other.0 {
178+ mine.0
179+ } else if base_kind == Some(mine.0) {
180+ other.0
181+ } else if base_kind == Some(other.0) {
182+ mine.0
183+ } else {
184+ conflicts.push(path.clone());
185+ continue;
186+ };
187+ if mine.1 == other.1 {
188+ steps.push((path.clone(), Step::Take(Some((kind, mine.1.clone())))));
189+ continue;
190+ }
191+ if !text_kind(mine.0) || !text_kind(other.0) || base_kind.is_some_and(|kind| !text_kind(kind)) {
192+ conflicts.push(path.clone());
193+ continue;
194+ }
195+ steps.push((
196+ path.clone(),
197+ Step::Content { kind, base: base.as_ref().map(|(_, hash)| hash.clone()), ours: mine.1.clone(), theirs: other.1.clone() },
198+ ));
199+ }
200+ if conflicts.is_empty() { Plan::Steps(steps) } else { Plan::Conflict(conflicts) }
201+}
202+
203+/// What GitHub's test merge commits say.
204+pub(crate) fn message(head: &str, base: &str) -> String {
205+ format!("Merge {head} into {base}")
206+}
207+
208+/// A merge, worked out.
209+enum Made {
210+ Merged { commit: String, objects: Vec<(ObjectKind, Vec<u8>)> },
211+ Conflict(Vec<String>),
212+ Unsupported(String),
213+}
214+
215+/// A blob's text, for merging: `Err(true)` when it is binary, `Err(false)`
216+/// when it is too large or missing.
217+fn as_text(bytes: Option<Vec<u8>>) -> std::result::Result<String, bool> {
218+ let bytes = bytes.ok_or(false)?;
219+ if bytes.len() > MAX_MERGE_BYTES {
220+ return Err(false);
221+ }
222+ if bytes.contains(&0) {
223+ return Err(true);
224+ }
225+ String::from_utf8(bytes).map_err(|_| true)
226+}
227+
228+/// When a merge of two commits says it was made: the later of their
229+/// dates, so that the same two always make the same commit, however many
230+/// events ask for it, and a merge ref that is already right stays.
231+fn merged_at(head: &g1t_contracts::repos::Commit, base: &g1t_contracts::repos::Commit) -> u64 {
232+ let at = |commit: &g1t_contracts::repos::Commit| parse_rfc3339(&commit.authored_at).unwrap_or(0) / 1000;
233+ at(head).max(at(base))
234+}
235+
236+/// Merges `head` into `base`, both in `git`.
237+async fn make<R: GitRepo>(git: &R, base: &str, head: &str) -> Result<Made> {
238+ let (head_log, base_log) = try_join(git.log(head, MAX_ANCESTRY), git.log(base, MAX_ANCESTRY)).await?;
239+ let (Some(head_commit), Some(base_commit)) = (head_log.first(), base_log.first()) else {
240+ return Ok(Made::Unsupported("the head or the base commit could not be read".to_owned()));
241+ };
242+ let seconds = merged_at(head_commit, base_commit);
243+ let mut objects: Vec<(ObjectKind, Vec<u8>)> = Vec::new();
244+ let tree = if descends_from(git, &head_log, base).await? {
245+ head_commit.tree_hash.clone()
246+ } else {
247+ let shared = base_log.iter().map(|commit| commit.hash.clone()).collect();
248+ let merge_base = nearest_ancestor_in(git, &head_log, &shared).await?;
249+ let Some(merge_base_tree) = merge_base.and_then(|hash| base_log.iter().find(|commit| commit.hash == hash)).map(|commit| commit.tree_hash.clone()) else {
250+ return Ok(Made::Unsupported("g1t could not find where the head left the base branch".to_owned()));
251+ };
252+ let ((ours, ours_cut), (theirs, theirs_cut)) = try_join(
253+ changed_entries(git, Some(&merge_base_tree), &head_commit.tree_hash),
254+ changed_entries(git, Some(&merge_base_tree), &base_commit.tree_hash),
255+ )
256+ .await?;
257+ if ours_cut || theirs_cut {
258+ return Ok(Made::Unsupported("the change is too large to merge without a sandbox".to_owned()));
259+ }
260+ let steps = match plan(&ours, &theirs) {
261+ Plan::Steps(steps) => steps,
262+ Plan::Conflict(paths) => return Ok(Made::Conflict(paths)),
263+ Plan::Unsupported(why) => return Ok(Made::Unsupported(why)),
264+ };
265+ // Files both changed, merged line by line.
266+ let read = try_join_all(steps.iter().map(|(_, step)| async move {
267+ let Step::Content { base, ours, theirs, .. } = step else { return Ok(None) };
268+ let base = async {
269+ match base {
270+ Some(hash) => git.read_blob(hash).await,
271+ None => Ok(Some(Vec::new())),
272+ }
273+ };
274+ let (base, (ours, theirs)) = try_join(base, try_join(git.read_blob(ours), git.read_blob(theirs))).await?;
275+ Ok::<_, worker::Error>(Some((base, ours, theirs)))
276+ }))
277+ .await?;
278+ let mut changes = Vec::new();
279+ let mut conflicts = Vec::new();
280+ for ((path, step), texts) in steps.into_iter().zip(read) {
281+ match (step, texts) {
282+ (Step::Take(entry), _) => changes.push(Change { path, entry }),
283+ (Step::Content { kind, .. }, Some((base, ours, theirs))) => {
284+ let texts = (as_text(base), as_text(ours), as_text(theirs));
285+ let (Ok(base), Ok(ours), Ok(theirs)) = texts else {
286+ if matches!(texts, (Err(true), _, _) | (_, Err(true), _) | (_, _, Err(true))) {
287+ conflicts.push(path);
288+ continue;
289+ }
290+ return Ok(Made::Unsupported(format!("{path} is too large to merge without a sandbox")));
291+ };
292+ match merge_text(&base, &ours, &theirs) {
293+ Some(merged) => {
294+ let blob = merged.into_bytes();
295+ let id = object_id(ObjectKind::Blob, &blob);
296+ objects.push((ObjectKind::Blob, blob));
297+ changes.push(Change { path, entry: Some((kind, id)) });
298+ }
299+ None => conflicts.push(path),
300+ }
301+ }
302+ (Step::Content { .. }, None) => {}
303+ }
304+ }
305+ if !conflicts.is_empty() {
306+ return Ok(Made::Conflict(conflicts));
307+ }
308+ let dirs: BTreeSet<String> = changes.iter().flat_map(|change| ancestors(&change.path).map(str::to_owned)).collect();
309+ let trees: HashMap<String, Vec<TreeEntry>> = read_dirs(git, &base_commit.tree_hash, &dirs).await?.into_values().collect();
310+ match merge_tree(&base_commit.tree_hash, &trees, &changes) {
311+ Ok(merged) => {
312+ objects.extend(merged.objects.into_iter().map(|bytes| (ObjectKind::Tree, bytes)));
313+ merged.tree
314+ }
315+ Err(why) => return Ok(Made::Unsupported(why)),
316+ }
317+ };
318+ let commit = commit_object(
319+ &tree,
320+ &[base, head],
321+ &Signature { name: g1t_contracts::system::USERNAME, email: g1t_contracts::system::EMAIL, seconds },
322+ &message(head, base),
323+ );
324+ let id = object_id(ObjectKind::Commit, &commit);
325+ objects.push((ObjectKind::Commit, commit));
326+ Ok(Made::Merged { commit: id, objects })
327+}
328+
329+fn unavailable(reason: impl Into<String>) -> Result<PullMergeRef> {
330+ Ok(PullMergeRef::Unavailable { reason: reason.into() })
331+}
332+
333+impl<S: GitStore> Repos<S> {
334+ /// `pull_merge_ref` (contracts `PullMergeRefArgs`).
335+ pub(crate) async fn pull_merge_ref(&self, a: PullMergeRefArgs) -> Result<PullMergeRef> {
336+ let Some(target) = self.registry.by_id(&a.repo_id).await? else {
337+ return unavailable("the repository is gone");
338+ };
339+ if let Some((_, message)) = crate::lifecycle::read_only_refusal(&target) {
340+ return unavailable(message);
341+ }
342+ let target = match self.unpaused(target).await? {
343+ Ok(target) => target,
344+ Err((_, message)) => return unavailable(message),
345+ };
346+ let git = self.store.open(&store_key(&target)).await?;
347+ let refs = refs::all(&git.access(Scope::Read).await?).await?;
348+ let find = |refs: &[(String, String)], name: &str| refs.iter().find(|(known, _)| known == name).map(|(_, hash)| hash.clone());
349+ let (head_ref, merge_ref) = (pull_head_ref(a.number), pull_merge_ref(a.number));
350+ let base_branch = a.target_branch.clone().filter(|branch| !branch.is_empty()).unwrap_or_else(|| target.default_branch.clone());
351+ let Some(base) = find(&refs, &format!("refs/heads/{base_branch}")) else {
352+ return unavailable(format!("{base_branch} has no commits"));
353+ };
354+ let head = a.head.clone();
355+
356+ // The head, kept in the repository, its objects brought from the fork.
357+ let held_head = find(&refs, &head_ref);
358+ if held_head.as_deref() != Some(head.as_str()) {
359+ let write = git.access(Scope::Write).await?;
360+ let moved = if !git.log(&head, 1).await?.is_empty() {
361+ land::push_ref(&write, &head_ref, held_head.as_deref(), &head, Some(land::EMPTY_PACK.to_vec())).await?
362+ } else {
363+ let source = match self.registry.by_id(&a.source_id).await? {
364+ Some(source) if source.id != target.id => source,
365+ _ => return unavailable("the head commit is not in the repository"),
366+ };
367+ // A working copy removed after its pull request closed is made again.
368+ self.live(&source).await?;
369+ let source_read = self.store.open(&store_key(&source)).await?.access(Scope::Read).await?;
370+ let command = (head_ref.clone(), held_head.clone(), head.clone());
371+ land::copy_refs(&source_read, &write, &[command], std::slice::from_ref(&head), std::slice::from_ref(&base)).await?
372+ };
373+ self.refs_moved(&target.id).await;
374+ if let Err(reason) = moved {
375+ return unavailable(format!("{head_ref} could not be kept: {reason}"));
376+ }
377+ }
378+
379+ let held_merge = find(&refs, &merge_ref);
380+ match make(&git, &base, &head).await? {
381+ Made::Unsupported(reason) => unavailable(reason),
382+ Made::Conflict(paths) => {
383+ // No merge commit, so no merge ref, as on GitHub.
384+ if let Some(old) = &held_merge {
385+ let write = git.access(Scope::Write).await?;
386+ if let Err(reason) = land::push_ref(&write, &merge_ref, Some(old), ZERO_ID, None).await? {
387+ worker::console_warn!("{merge_ref} of {} not removed: {reason}", target.id);
388+ }
389+ self.refs_moved(&target.id).await;
390+ }
391+ Ok(PullMergeRef::Conflict { head, base, paths })
392+ }
393+ // Made the same for the same head and base: there already when
394+ // an earlier event made it.
395+ Made::Merged { commit, .. } if held_merge.as_deref() == Some(commit.as_str()) => {
396+ Ok(PullMergeRef::Merged { head, base, merge: commit })
397+ }
398+ Made::Merged { commit, objects } => {
399+ let write = git.access(Scope::Write).await?;
400+ let pushed = land::push_ref(&write, &merge_ref, held_merge.as_deref(), &commit, Some(write_pack(&objects))).await?;
401+ self.refs_moved(&target.id).await;
402+ if let Err(reason) = pushed {
403+ // Another event for the same pair made it meanwhile.
404+ let refs = refs::all(&git.access(Scope::Read).await?).await?;
405+ if find(&refs, &merge_ref).as_deref() != Some(commit.as_str()) {
406+ return unavailable(format!("{merge_ref} could not be moved: {reason}"));
407+ }
408+ }
409+ Ok(PullMergeRef::Merged { head, base, merge: commit })
410+ }
411+ }
412+ }
413+}
414+
415+#[cfg(test)]
416+mod tests {
417+ use super::*;
418+
419+ fn blob(hash: &str) -> Option<FileEntry> {
420+ Some((EntryKind::Blob, hash.to_owned()))
421+ }
422+
423+ fn changed(path: &str, base: Option<FileEntry>, now: Option<FileEntry>) -> Changed {
424+ (path.to_owned(), base, now)
425+ }
426+
427+ #[test]
428+ fn changes_to_different_lines_merge() {
429+ let base = "a\nb\nc\nd\ne\n";
430+ let ours = "A\nb\nc\nd\ne\n";
431+ let theirs = "a\nb\nc\nd\nE\n";
432+ assert_eq!(merge_text(base, ours, theirs).as_deref(), Some("A\nb\nc\nd\nE\n"));
433+ // Lines added at either end, and one taken out in the middle.
434+ assert_eq!(merge_text(base, "top\na\nb\nc\nd\ne\n", "a\nb\nd\ne\nend\n").as_deref(), Some("top\na\nb\nd\ne\nend\n"));
435+ }
436+
437+ #[test]
438+ fn the_same_change_on_both_sides_merges() {
439+ let base = "a\nb\nc\n";
440+ assert_eq!(merge_text(base, "a\nB\nc\n", "a\nB\nc\n").as_deref(), Some("a\nB\nc\n"));
441+ }
442+
443+ #[test]
444+ fn different_changes_to_the_same_or_touching_lines_conflict() {
445+ let base = "a\nb\nc\nd\n";
446+ assert_eq!(merge_text(base, "a\nX\nc\nd\n", "a\nY\nc\nd\n"), None);
447+ // Neighbouring lines, with no unchanged line between: git conflicts.
448+ assert_eq!(merge_text(base, "a\nX\nc\nd\n", "a\nb\nY\nd\n"), None);
449+ // Both added different files from nothing.
450+ assert_eq!(merge_text("", "one\n", "two\n"), None);
451+ // One unchanged line between them is enough.
452+ assert_eq!(merge_text(base, "X\nb\nc\nd\n", "a\nb\nY\nd\n").as_deref(), Some("X\nb\nY\nd\n"));
453+ }
454+
455+ #[test]
456+ fn a_missing_final_newline_is_kept() {
457+ assert_eq!(merge_text("a\nb\nc", "A\nb\nc", "a\nb\nC").as_deref(), Some("A\nb\nC"));
458+ }
459+
460+ #[test]
461+ fn files_only_one_side_changed_are_taken_from_it() {
462+ let ours = [changed("src/a.rs", blob("1"), blob("2")), changed("gone.md", blob("3"), None)];
463+ let theirs = [changed("src/b.rs", blob("4"), blob("5"))];
464+ assert_eq!(
465+ plan(&ours, &theirs),
466+ Plan::Steps(vec![("src/a.rs".into(), Step::Take(blob("2"))), ("gone.md".into(), Step::Take(None))])
467+ );
468+ }
469+
470+ #[test]
471+ fn a_file_both_changed_is_merged_by_content() {
472+ let ours = [changed("a", blob("1"), blob("2")), changed("same", blob("1"), blob("9"))];
473+ let theirs = [changed("a", blob("1"), blob("3")), changed("same", blob("1"), blob("9"))];
474+ assert_eq!(
475+ plan(&ours, &theirs),
476+ Plan::Steps(vec![(
477+ "a".into(),
478+ Step::Content { kind: EntryKind::Blob, base: Some("1".into()), ours: "2".into(), theirs: "3".into() }
479+ )])
480+ );
481+ // One side made it executable, the other changed it.
482+ let ours = [changed("run", blob("1"), Some((EntryKind::Exec, "1".into())))];
483+ let theirs = [changed("run", blob("1"), blob("2"))];
484+ assert_eq!(
485+ plan(&ours, &theirs),
486+ Plan::Steps(vec![(
487+ "run".into(),
488+ Step::Content { kind: EntryKind::Exec, base: Some("1".into()), ours: "1".into(), theirs: "2".into() }
489+ )])
490+ );
491+ }
492+
493+ #[test]
494+ fn what_cannot_merge_says_so() {
495+ // A submodule moved two ways.
496+ let ours = [changed("vendor", Some((EntryKind::Gitlink, "1".into())), Some((EntryKind::Gitlink, "2".into())))];
497+ let theirs = [changed("vendor", Some((EntryKind::Gitlink, "1".into())), Some((EntryKind::Gitlink, "3".into())))];
498+ assert_eq!(plan(&ours, &theirs), Plan::Conflict(vec!["vendor".into()]));
499+ // Deleted on one side, changed on the other: maybe a rename.
500+ let ours = [changed("a", blob("1"), None)];
501+ let theirs = [changed("a", blob("1"), blob("2"))];
502+ assert!(matches!(plan(&ours, &theirs), Plan::Unsupported(_)));
503+ // A file against a directory.
504+ let ours = [changed("a", None, blob("1"))];
505+ let theirs = [changed("a/b", None, blob("2"))];
506+ assert!(matches!(plan(&ours, &theirs), Plan::Unsupported(_)));
507+ }
508+
509+ #[test]
510+ fn binary_files_are_told_apart_from_text() {
511+ assert_eq!(as_text(Some(b"text\n".to_vec())), Ok("text\n".to_owned()));
512+ assert_eq!(as_text(Some(vec![0, 1, 2])), Err(true));
513+ assert_eq!(as_text(None), Err(false));
514+ assert_eq!(as_text(Some(vec![b'a'; MAX_MERGE_BYTES + 1])), Err(false));
515+ }
516+
517+ /// A store of whole objects, made in the test: commits by hash, trees
518+ /// and blobs by their real ids.
519+ #[derive(Default)]
520+ struct Store {
521+ commits: HashMap<String, g1t_contracts::repos::Commit>,
522+ trees: HashMap<String, Vec<TreeEntry>>,
523+ blobs: HashMap<String, Vec<u8>>,
524+ }
525+
526+ impl Store {
527+ fn blob(&mut self, text: &str) -> String {
528+ let id = object_id(ObjectKind::Blob, text.as_bytes());
529+ self.blobs.insert(id.clone(), text.as_bytes().to_vec());
530+ id
531+ }
532+
533+ /// A tree of `files` (`path`, text), its subtrees made as needed.
534+ fn tree(&mut self, files: &[(&str, &str)]) -> String {
535+ let mut here: Vec<TreeEntry> = Vec::new();
536+ let mut dirs: std::collections::BTreeMap<&str, Vec<(&str, &str)>> = Default::default();
537+ for (path, text) in files {
538+ match path.split_once('/') {
539+ Some((dir, rest)) => dirs.entry(dir).or_default().push((rest, text)),
540+ None => {
541+ let hash = self.blob(text);
542+ here.push(TreeEntry { name: (*path).to_owned(), hash, kind: EntryKind::Blob });
543+ }
544+ }
545+ }
546+ for (dir, files) in dirs {
547+ let hash = self.tree(&files);
548+ here.push(TreeEntry { name: dir.to_owned(), hash, kind: EntryKind::Tree });
549+ }
550+ let id = object_id(ObjectKind::Tree, &crate::catch_up::encode_entries(&here));
551+ self.trees.insert(id.clone(), here);
552+ id
553+ }
554+
555+ fn commit(&mut self, hash: &str, parents: &[&str], files: &[(&str, &str)]) {
556+ let tree_hash = self.tree(files);
557+ self.commits.insert(
558+ hash.to_owned(),
559+ g1t_contracts::repos::Commit {
560+ hash: hash.to_owned(),
561+ tree_hash,
562+ message: hash.to_owned(),
563+ author: g1t_contracts::repos::Signature { name: "a".into(), email: "a@example.com".into() },
564+ parents: parents.iter().map(|&p| p.to_owned()).collect(),
565+ authored_at: String::new(),
566+ },
567+ );
568+ }
569+
570+ /// The files of a tree in `objects` or the store, by path.
571+ fn files(&self, tree: &str, written: &HashMap<String, Vec<TreeEntry>>, blobs: &HashMap<String, Vec<u8>>, prefix: &str) -> Vec<(String, String)> {
572+ let entries = written.get(tree).or_else(|| self.trees.get(tree)).expect("tree");
573+ let mut out = Vec::new();
574+ for entry in entries {
575+ let path = format!("{prefix}{}", entry.name);
576+ if entry.kind == EntryKind::Tree {
577+ out.extend(self.files(&entry.hash, written, blobs, &format!("{path}/")));
578+ } else {
579+ let bytes = blobs.get(&entry.hash).or_else(|| self.blobs.get(&entry.hash)).expect("blob");
580+ out.push((path, String::from_utf8(bytes.clone()).unwrap()));
581+ }
582+ }
583+ out
584+ }
585+ }
586+
587+ impl GitRepo for Store {
588+ async fn access(&self, _scope: Scope) -> Result<g1t_contracts::repos::GitAccess> {
589+ unimplemented!()
590+ }
591+ async fn branches(&self) -> Result<Vec<g1t_contracts::repos::Branch>> {
592+ Ok(Vec::new())
593+ }
594+ async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<g1t_contracts::repos::Commit>> {
595+ let mut out = Vec::new();
596+ let mut at = self.commits.get(git_ref);
597+ while let Some(commit) = at.filter(|_| (out.len() as u32) < limit) {
598+ out.push(commit.clone());
599+ at = commit.parents.first().and_then(|parent| self.commits.get(parent));
600+ }
601+ Ok(out)
602+ }
603+ async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> {
604+ Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone()))
605+ }
606+ async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
607+ Ok(self.trees.get(tree_hash).cloned())
608+ }
609+ async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>> {
610+ Ok(self.blobs.get(blob_hash).cloned())
611+ }
612+ async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
613+ Ok(None)
614+ }
615+ async fn fork(&self, _target_key: &str) -> Result<()> {
616+ Ok(())
617+ }
618+ }
619+
620+ fn block_on<F: std::future::Future>(future: F) -> F::Output {
621+ let mut future = std::pin::pin!(future);
622+ let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
623+ loop {
624+ if let std::task::Poll::Ready(output) = future.as_mut().poll(&mut cx) {
625+ return output;
626+ }
627+ }
628+ }
629+
630+ /// The merge's files, read from the objects it wrote and the store.
631+ fn merged_files(store: &Store, made: Made, base: &str, head: &str) -> Vec<(String, String)> {
632+ let Made::Merged { commit, objects } = made else { panic!("not merged") };
633+ let mut trees = HashMap::new();
634+ let mut blobs = HashMap::new();
635+ let mut tree = None;
636+ for (kind, bytes) in &objects {
637+ let id = object_id(*kind, bytes);
638+ match kind {
639+ ObjectKind::Tree => {
640+ let entries = g1t_scan::pack::parse_tree(bytes)
641+ .into_iter()
642+ .map(|item| TreeEntry {
643+ kind: if item.mode == "40000" { EntryKind::Tree } else { EntryKind::Blob },
644+ name: item.name,
645+ hash: item.id,
646+ })
647+ .collect();
648+ trees.insert(id, entries);
649+ }
650+ ObjectKind::Blob => {
651+ blobs.insert(id, bytes.clone());
652+ }
653+ ObjectKind::Commit => {
654+ assert_eq!(id, commit);
655+ let text = String::from_utf8(bytes.clone()).unwrap();
656+ assert!(text.contains(&format!("parent {base}\nparent {head}\n")), "{text}");
657+ assert!(text.contains(&format!("\n\nMerge {head} into {base}\n")), "{text}");
658+ tree = text.lines().next().and_then(|line| line.strip_prefix("tree ")).map(str::to_owned);
659+ }
660+ ObjectKind::Tag => unreachable!(),
661+ }
662+ }
663+ let mut files = store.files(&tree.expect("a commit"), &trees, &blobs, "");
664+ files.sort();
665+ files
666+ }
667+
668+ #[test]
669+ fn a_head_behind_its_base_merges_both_sides() {
670+ let mut store = Store::default();
671+ store.commit("m", &[], &[("README.md", "hi\n"), ("src/lib.rs", "a\nb\nc\nd\ne\n")]);
672+ store.commit("h", &["m"], &[("README.md", "hi\n"), ("src/lib.rs", "A\nb\nc\nd\ne\n"), ("src/new.rs", "new\n")]);
673+ store.commit("b", &["m"], &[("README.md", "hello\n"), ("src/lib.rs", "a\nb\nc\nd\nE\n")]);
674+ let made = block_on(make(&store, "b", "h")).unwrap();
675+ assert_eq!(
676+ merged_files(&store, made, "b", "h"),
677+ [
678+ ("README.md".to_owned(), "hello\n".to_owned()),
679+ ("src/lib.rs".to_owned(), "A\nb\nc\nd\nE\n".to_owned()),
680+ ("src/new.rs".to_owned(), "new\n".to_owned()),
681+ ]
682+ );
683+ }
684+
685+ #[test]
686+ fn a_head_up_to_date_merges_to_its_own_files() {
687+ let mut store = Store::default();
688+ store.commit("b", &[], &[("a", "1\n")]);
689+ store.commit("h", &["b"], &[("a", "2\n")]);
690+ let made = block_on(make(&store, "b", "h")).unwrap();
691+ assert_eq!(merged_files(&store, made, "b", "h"), [("a".to_owned(), "2\n".to_owned())]);
692+ }
693+
694+ #[test]
695+ fn the_same_head_and_base_always_make_the_same_commit() {
696+ let mut store = Store::default();
697+ store.commit("m", &[], &[("a", "1\n"), ("b", "x\n")]);
698+ store.commit("h", &["m"], &[("a", "2\n"), ("b", "x\n")]);
699+ store.commit("b", &["m"], &[("a", "1\n"), ("b", "y\n")]);
700+ store.commits.get_mut("h").unwrap().authored_at = "2026-10-12T03:00:00.000Z".into();
701+ store.commits.get_mut("b").unwrap().authored_at = "2026-10-12T04:00:00.000Z".into();
702+ let id = |made: Made| match made {
703+ Made::Merged { commit, objects } => (commit, String::from_utf8(objects.last().unwrap().1.clone()).unwrap()),
704+ _ => panic!("not merged"),
705+ };
706+ let (first, text) = id(block_on(make(&store, "b", "h")).unwrap());
707+ assert_eq!(id(block_on(make(&store, "b", "h")).unwrap()).0, first);
708+ // Dated as the later of the two.
709+ assert!(text.contains(" 1791777600 +0000\n"), "{text}");
710+ }
711+
712+ #[test]
713+ fn a_conflict_makes_no_merge() {
714+ let mut store = Store::default();
715+ store.commit("m", &[], &[("a", "1\n"), ("b", "x\n")]);
716+ store.commit("h", &["m"], &[("a", "2\n"), ("b", "x\n")]);
717+ store.commit("b", &["m"], &[("a", "3\n"), ("b", "y\n")]);
718+ let Made::Conflict(paths) = block_on(make(&store, "b", "h")).unwrap() else { panic!("no conflict") };
719+ assert_eq!(paths, ["a"]);
720+ }
721+
722+ #[test]
723+ fn the_merge_commit_says_what_github_s_does() {
724+ assert_eq!(message("abc", "def"), "Merge abc into def");
725+ assert_eq!(pull_merge_ref(7), "refs/pull/7/merge");
726+ assert_eq!(pull_head_ref(7), "refs/pull/7/head");
727+ }
728+}
+11−6
13831383 self.note(&repo.id, issue, (&a.actor.id, &a.actor.username), &text)
13841384 .await?;
13851385 }
1386+ // Its head and merge refs in the repository, before its events go out.
1387+ self.keep_pull_refs(&pull).await;
13861388 self.publish(
13871389 "pull.opened",
13881390 &repo.id,
19081910 )
19091911 .await?;
19101912 }
1911− // Whether it merges cleanly into the new base.
1912− if let Some(moved) = self.pull_by_id(&pull.id).await?
1913− && let Err(error) = self.assess_mergeability(&moved).await
1914− {
1915− worker::console_warn!("mergeability of {}: {error}", pull.id);
1913+ // Its merge ref, and whether it merges cleanly, against the new base.
1914+ if let Some(moved) = self.pull_by_id(&pull.id).await? {
1915+ self.keep_pull_refs(&moved).await;
1916+ if let Err(error) = self.assess_mergeability(&moved).await {
1917+ worker::console_warn!("mergeability of {}: {error}", pull.id);
1918+ }
19161919 }
19171920 Ok(Outcome::Ok(()))
19181921 }
20672070 pull.status = status;
20682071 pull.superseded_by = None;
20692072 pull.updated_at = now;
2070− // Whether it still merges cleanly, now that it is open again.
2073+ // Its refs in the repository, and whether it still merges cleanly,
2074+ // now that it is open again.
2075+ self.keep_pull_refs(&pull).await;
20712076 if let Err(error) = self.assess_mergeability(&pull).await {
20722077 worker::console_warn!("mergeability of {}: {error}", pull.id);
20732078 }
+36−0
239239 for id in ids {
240240 let assessed = async {
241241 if let Some(pull) = self.pull_by_id(&id).await? {
242+ // A pull request whose head moved: its refs in the
243+ // repository move with it, before its events go out.
244+ if moved.contains(&id) {
245+ self.keep_pull_refs(&pull).await;
246+ }
242247 self.assess_mergeability(&pull).await?;
243248 }
244249 Ok::<_, worker::Error>(())
249254 }
250255 }
251256
257+ /// Points `refs/pull/<n>/head` in the repository at the pull request's
258+ /// head, its objects copied from a fork or working copy, and
259+ /// `refs/pull/<n>/merge` at the head merged into its base, as GitHub
260+ /// keeps them for every open pull request (repos `pull_merge_ref`), so
261+ /// that both can be fetched from the repository itself. A failure is
262+ /// logged: it never holds up the push.
263+ pub(crate) async fn keep_pull_refs(&self, pull: &Pull) {
264+ let (Some(head), true) = (pull.head_commit.clone(), pull.status.is_active()) else {
265+ return;
266+ };
267+ let kept: Result<g1t_contracts::repos::PullMergeRef> = g1t_kit::call(
268+ &self.repos,
269+ "pull_merge_ref",
270+ &g1t_contracts::repos::PullMergeRefArgs {
271+ repo_id: pull.repo_id.clone(),
272+ source_id: pull.fork_repo_id.clone().unwrap_or_else(|| pull.repo_id.clone()),
273+ head,
274+ number: pull.number,
275+ target_branch: pull.base.clone().filter(|base| !base.is_empty()),
276+ },
277+ )
278+ .await;
279+ match kept {
280+ Ok(g1t_contracts::repos::PullMergeRef::Unavailable { reason }) => {
281+ worker::console_log!("refs of pull request {}: no merge commit: {reason}", pull.id);
282+ }
283+ Ok(_) => {}
284+ Err(error) => worker::console_warn!("refs of pull request {} not kept: {error}", pull.id),
285+ }
286+ }
287+
252288 /// The open pull requests into a branch of a repository (`None`: its
253289 /// default branch), most recently active first.
254290 async fn targeting(&self, repo_id: &str, base: Option<&str>) -> Result<Vec<String>> {