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
13 files+1047−200/13 viewed
| 1414 | 1414 | always the default branch: see | |
| 1415 | 1415 | [pull requests into other branches](/guides/base-branches/). | |
| 1416 | 1416 | ||
| 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 | + | ||
| 1417 | 1462 | A pull request from outside the workspace may wait for approval before | |
| 1418 | 1463 | its workflows run: see [pull requests from outside](#pull-requests-from-outside). | |
| 1419 | 1464 | ||
| ⋯ | |||
| 1466 | 1511 | ## Checks | |
| 1467 | 1512 | ||
| 1468 | 1513 | 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 | |
| 1470 | 1515 | person or an agent, and its runs report a check named after the workflow: | |
| 1471 | 1516 | a workflow with `name: CI` reports `CI`, with the status context | |
| 1472 | 1517 | `CI / pull_request` (the workflow's name and the event). Each of its jobs | |
| 208 | 208 | -d "{\"name\": \"coverage\", \"head_sha\": \"$GITHUB_SHA\", \"conclusion\": \"neutral\", \"output\": {\"title\": \"81% covered\", \"summary\": \"Up 2% from main.\"}}" | |
| 209 | 209 | ``` | |
| 210 | 210 | ||
| 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 | + | ||
| 211 | 216 | A pull request's runs from someone without the Write role get no token | |
| 212 | 217 | that can write, so they cannot report checks. | |
| 213 | 218 |
| 324 | 324 | earlier, | |
| 325 | 325 | canRerunWorkflows, | |
| 326 | 326 | settingsUrl, | |
| 327 | + | conflicting = false, | |
| 327 | 328 | error, | |
| 328 | 329 | loading = false, | |
| 329 | 330 | settled = false, | |
| ⋯ | |||
| 341 | 342 | canRerunWorkflows: boolean; | |
| 342 | 343 | /** Where the required checks are chosen, for those who may. */ | |
| 343 | 344 | 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; | |
| 344 | 350 | error?: string | null; | |
| 345 | 351 | /** The workflow runs' jobs are still being read: their rows wait, busy. */ | |
| 346 | 352 | loading?: boolean; | |
| ⋯ | |||
| 350 | 356 | */ | |
| 351 | 357 | settled?: boolean; | |
| 352 | 358 | }) { | |
| 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; | |
| 354 | 361 | ||
| 355 | 362 | // Every workflow row, so they can be counted the same way they are shown. | |
| 356 | 363 | const workflow: Standing[] = statuses.flatMap((status) => { | |
| ⋯ | |||
| 374 | 381 | ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running` | |
| 375 | 382 | : total > 0 | |
| 376 | 383 | ? "All checks have passed" | |
| 377 | − | : "Checks"; | |
| 384 | + | : held | |
| 385 | + | ? "Workflows have not run" | |
| 386 | + | : "Checks"; | |
| 378 | 387 | const others = [ | |
| 379 | 388 | failed > 0 && `${failed} failed`, | |
| 380 | 389 | pending > 0 && `${pending} running`, | |
| ⋯ | |||
| 409 | 418 | <CircleX size={16} className="text-danger" /> | |
| 410 | 419 | ) : standing === "running" ? ( | |
| 411 | 420 | <LoaderCircle size={16} className="animate-spin text-warn" /> | |
| 421 | + | ) : held && total === 0 ? ( | |
| 422 | + | <CircleAlert size={16} className="text-warn" /> | |
| 412 | 423 | ) : ( | |
| 413 | 424 | <CircleCheck size={16} className="text-success" /> | |
| 414 | 425 | )} | |
| ⋯ | |||
| 416 | 427 | <div className="min-w-0 grow"> | |
| 417 | 428 | <p className="text-sm font-medium">{headline}</p> | |
| 418 | 429 | {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 | + | )} | |
| 419 | 437 | {!summary && !settled && settingsUrl && ( | |
| 420 | 438 | <p className="mt-0.5 text-xs text-muted"> | |
| 421 | 439 | <Link to={settingsUrl} className="text-fg hover:underline"> | |
| 677 | 677 | earlier={earlierChecks} | |
| 678 | 678 | canRerunWorkflows={canMerge} | |
| 679 | 679 | settingsUrl={canProtect ? `${base}/settings/branches` : null} | |
| 680 | + | conflicting={mergeable === "conflicting" && !noChecks} | |
| 680 | 681 | error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null} | |
| 681 | 682 | loading={loading} | |
| 682 | 683 | /> |
| 905 | 905 | }, | |
| 906 | 906 | } | |
| 907 | 907 | ||
| 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 | + | ||
| 908 | 958 | /// `head`: the commit a branch points to, or null. For services reacting | |
| 909 | 959 | /// to a push, which have no viewer; it reveals nothing but a commit hash. | |
| 910 | 960 | /// Returns `Option<String>`. |
| 86 | 86 | /// Why the run waits for someone with the Write role to approve it | |
| 87 | 87 | /// before anything starts: a pull request from outside (protection.rs). | |
| 88 | 88 | 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>, | |
| 89 | 93 | } | |
| 90 | 94 | ||
| 91 | 95 | #[derive(Clone, Deserialize)] | |
| ⋯ | |||
| 679 | 683 | info.event_name.as_str().into(), | |
| 680 | 684 | optional(new.action.as_deref()), | |
| 681 | 685 | info.git_ref.as_str().into(), | |
| 682 | − | info.sha.as_str().into(), | |
| 686 | + | new.head_sha.as_deref().unwrap_or(&info.sha).into(), | |
| 683 | 687 | new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 684 | 688 | if approval.is_some() { "action_required" } else { "queued" }.into(), | |
| 685 | 689 | optional(new.actor.as_deref()), | |
| ⋯ | |||
| 2359 | 2363 | debug_logging(&mut variables, &mut runner); | |
| 2360 | 2364 | } | |
| 2361 | 2365 | ||
| 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). | |
| 2363 | 2369 | 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 => { | |
| 2365 | 2371 | let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call( | |
| 2366 | 2372 | &self.work, | |
| 2367 | 2373 | "get_pull", | |
| ⋯ | |||
| 2414 | 2420 | "checkout": { | |
| 2415 | 2421 | "repository": run.repo, | |
| 2416 | 2422 | "url": clone_url, | |
| 2417 | − | "sha": run.sha, | |
| 2423 | + | "sha": info.sha, | |
| 2418 | 2424 | "ref": run.git_ref, | |
| 2419 | 2425 | "token": token, | |
| 2420 | 2426 | }, | |
| 392 | 392 | .filter(|call| call["role"] == "callee") | |
| 393 | 393 | .and_then(|call| call.get("origin").cloned()) | |
| 394 | 394 | .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 })); | |
| 396 | 396 | (origin, local.split('@').next().unwrap_or(local).to_owned()) | |
| 397 | 397 | } | |
| 398 | 398 | Some(used) => { |
| 8 | 8 | use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun}; | |
| 9 | 9 | use g1t_contracts::events::{Event, caused_by_job}; | |
| 10 | 10 | 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}; | |
| 12 | 12 | use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs}; | |
| 13 | 13 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 14 | 14 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 47 | 47 | /// Why its runs wait for approval first: a pull request from outside, | |
| 48 | 48 | /// by the repository's approval policy (protection.rs). | |
| 49 | 49 | 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>, | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | 56 | /// Whether a deployment's `ref` is a commit's full hash rather than a | |
| ⋯ | |||
| 75 | 79 | matches!(event_name, "create" | "delete") | |
| 76 | 80 | } | |
| 77 | 81 | ||
| 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 | + | ||
| 78 | 104 | /// Whether an `*.assigned` event added nobody: g1t says so for a change | |
| 79 | 105 | /// that only took people off. | |
| 80 | 106 | fn nobody_added(data: &Value) -> bool { | |
| ⋯ | |||
| 213 | 239 | .await | |
| 214 | 240 | } | |
| 215 | 241 | ||
| 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 | + | ||
| 216 | 261 | /// Whether `name` is one of the repository's branches. | |
| 217 | 262 | async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> { | |
| 218 | 263 | let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call( | |
| ⋯ | |||
| 253 | 298 | title, | |
| 254 | 299 | trusted: true, | |
| 255 | 300 | approval: None, | |
| 301 | + | merge: None, | |
| 256 | 302 | }; | |
| 257 | 303 | let view = |number: u32| ViewArgs { | |
| 258 | 304 | repo: path.clone(), | |
| ⋯ | |||
| 288 | 334 | title, | |
| 289 | 335 | trusted: true, | |
| 290 | 336 | approval: None, | |
| 337 | + | merge: None, | |
| 291 | 338 | }) | |
| 292 | 339 | } | |
| 293 | 340 | // A branch or tag was made: its own commit, as on GitHub. | |
| ⋯ | |||
| 323 | 370 | title: format!("Created {ref_type} {name}"), | |
| 324 | 371 | trusted: true, | |
| 325 | 372 | approval: None, | |
| 373 | + | merge: None, | |
| 326 | 374 | }) | |
| 327 | 375 | } | |
| 328 | 376 | // A branch or tag was deleted: on the default branch's latest | |
| ⋯ | |||
| 408 | 456 | (None, None, Some(head)) => head.clone(), | |
| 409 | 457 | (None, None, None) => return Ok(None), | |
| 410 | 458 | }; | |
| 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()), | |
| 414 | 486 | }; | |
| 415 | 487 | Some(Subject { | |
| 416 | 488 | source, | |
| 417 | − | source_ref: Some(sha.clone()), | |
| 489 | + | source_ref: Some(source_ref), | |
| 490 | + | merge, | |
| 418 | 491 | git_ref: format!("refs/pull/{}/merge", pull.number), | |
| 419 | 492 | sha, | |
| 420 | 493 | head_ref: Some(head_ref), | |
| ⋯ | |||
| 562 | 635 | title: format!("Release {name} {}", action.unwrap_or("changed")), | |
| 563 | 636 | trusted: true, | |
| 564 | 637 | approval: None, | |
| 638 | + | merge: None, | |
| 565 | 639 | }) | |
| 566 | 640 | } | |
| 567 | 641 | // A deployment, or a new status of one: at the commit deployed, | |
| ⋯ | |||
| 608 | 682 | title, | |
| 609 | 683 | trusted: true, | |
| 610 | 684 | approval: None, | |
| 685 | + | merge: None, | |
| 611 | 686 | }) | |
| 612 | 687 | } | |
| 613 | 688 | _ => None, | |
| ⋯ | |||
| 791 | 866 | actor: Some(sender.to_owned()), | |
| 792 | 867 | trusted: subject.trusted, | |
| 793 | 868 | approval: subject.approval.clone().or(held), | |
| 869 | + | head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()), | |
| 794 | 870 | }) | |
| 795 | 871 | .await?; | |
| 796 | 872 | } | |
| ⋯ | |||
| 855 | 931 | event_name: event_name.to_owned(), | |
| 856 | 932 | event: subject.payload.clone(), | |
| 857 | 933 | git_ref: subject.git_ref.clone(), | |
| 858 | − | sha: subject.sha.clone(), | |
| 934 | + | sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()), | |
| 859 | 935 | head_ref: subject.head_ref.clone(), | |
| 860 | 936 | base_ref: subject.base_ref.clone(), | |
| 861 | 937 | actor: sender.to_owned(), | |
| ⋯ | |||
| 935 | 1011 | title: format!("Scheduled: {cron}"), | |
| 936 | 1012 | trusted: true, | |
| 937 | 1013 | approval: None, | |
| 1014 | + | merge: None, | |
| 938 | 1015 | }; | |
| 939 | 1016 | subject.filter_ref = subject.git_ref.clone(); | |
| 940 | 1017 | let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None); | |
| ⋯ | |||
| 953 | 1030 | actor: None, | |
| 954 | 1031 | trusted: true, | |
| 955 | 1032 | approval: None, | |
| 1033 | + | head_sha: None, | |
| 956 | 1034 | }) | |
| 957 | 1035 | .await?; | |
| 958 | 1036 | } | |
| ⋯ | |||
| 1052 | 1130 | title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username), | |
| 1053 | 1131 | trusted: true, | |
| 1054 | 1132 | approval: None, | |
| 1133 | + | merge: None, | |
| 1055 | 1134 | }; | |
| 1056 | 1135 | let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id)); | |
| 1057 | 1136 | let created = self | |
| ⋯ | |||
| 1070 | 1149 | actor: Some(a.actor.username.clone()), | |
| 1071 | 1150 | trusted: true, | |
| 1072 | 1151 | approval: None, | |
| 1152 | + | head_sha: None, | |
| 1073 | 1153 | }) | |
| 1074 | 1154 | .await?; | |
| 1075 | 1155 | match created { | |
| ⋯ | |||
| 1142 | 1222 | title: event_type.clone(), | |
| 1143 | 1223 | trusted: true, | |
| 1144 | 1224 | approval: None, | |
| 1225 | + | merge: None, | |
| 1145 | 1226 | }; | |
| 1146 | 1227 | let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id)); | |
| 1147 | 1228 | let created = self | |
| ⋯ | |||
| 1160 | 1241 | actor: Some(a.actor.username.clone()), | |
| 1161 | 1242 | trusted: true, | |
| 1162 | 1243 | approval: None, | |
| 1244 | + | head_sha: None, | |
| 1163 | 1245 | }) | |
| 1164 | 1246 | .await?; | |
| 1165 | 1247 | if created.is_some() { | |
| ⋯ | |||
| 1238 | 1320 | title: format!("Merge queue: #{}{ahead}", a.number), | |
| 1239 | 1321 | trusted: true, | |
| 1240 | 1322 | approval: None, | |
| 1323 | + | merge: None, | |
| 1241 | 1324 | }; | |
| 1242 | 1325 | let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None); | |
| 1243 | 1326 | let created = self | |
| ⋯ | |||
| 1256 | 1339 | actor: None, | |
| 1257 | 1340 | trusted: true, | |
| 1258 | 1341 | approval: None, | |
| 1342 | + | head_sha: None, | |
| 1259 | 1343 | }) | |
| 1260 | 1344 | .await?; | |
| 1261 | 1345 | if created.is_some() { | |
| ⋯ | |||
| 1368 | 1452 | } | |
| 1369 | 1453 | use g1t_contracts::{Membership, PrincipalKind}; | |
| 1370 | 1454 | ||
| 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 | + | ||
| 1371 | 1473 | fn repo() -> Repo { | |
| 1372 | 1474 | serde_json::from_value(json!({ | |
| 1373 | 1475 | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true, | |
| 24 | 24 | path: String, | |
| 25 | 25 | old: Option<String>, | |
| 26 | 26 | new: Option<String>, | |
| 27 | + | old_kind: Option<EntryKind>, | |
| 28 | + | new_kind: Option<EntryKind>, | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | 31 | async fn entries<R: GitRepo>(repo: &R, tree: Option<&str>) -> Result<BTreeMap<String, TreeEntry>> { | |
| ⋯ | |||
| 86 | 88 | .filter(|entry| entry.kind != EntryKind::Tree) | |
| 87 | 89 | .map(|entry| entry.hash.clone()) | |
| 88 | 90 | }; | |
| 91 | + | let file_kind = |entry: Option<&TreeEntry>| entry.map(|entry| entry.kind).filter(|kind| *kind != EntryKind::Tree); | |
| 89 | 92 | let (old_dir, new_dir) = (subtree(before), subtree(after)); | |
| 90 | 93 | if old_dir.is_some() || new_dir.is_some() { | |
| 91 | 94 | next.push((format!("{path}/"), old_dir, new_dir)); | |
| ⋯ | |||
| 100 | 103 | path, | |
| 101 | 104 | old: old_file, | |
| 102 | 105 | new: new_file, | |
| 106 | + | old_kind: file_kind(before), | |
| 107 | + | new_kind: file_kind(after), | |
| 103 | 108 | }); | |
| 104 | 109 | } | |
| 105 | 110 | } | |
| ⋯ | |||
| 122 | 127 | Ok((changes.into_iter().map(|change| change.path).collect(), truncated)) | |
| 123 | 128 | } | |
| 124 | 129 | ||
| 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 | + | ||
| 125 | 153 | /// The text of a blob, or `None` if it is binary, too large or missing. | |
| 126 | 154 | async fn text<R: GitRepo>(repo: &R, hash: Option<&str>) -> Result<Option<String>> { | |
| 127 | 155 | let Some(hash) = hash else { | |
| 47 | 47 | mod signatures; | |
| 48 | 48 | mod stats; | |
| 49 | 49 | mod store; | |
| 50 | + | mod test_merge; | |
| 50 | 51 | mod transfer; | |
| 51 | 52 | mod workflow_gate; | |
| 52 | 53 | ||
| ⋯ | |||
| 2666 | 2667 | "divergence" => reply(&repos.divergence(args(body)?).await?), | |
| 2667 | 2668 | "land" => reply(&repos.land(args(body)?).await?), | |
| 2668 | 2669 | "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?), | |
| 2669 | 2672 | "delete_branch" => reply(&repos.delete_branch(args(body)?).await?), | |
| 2670 | 2673 | "commit_file" => reply(&repos.commit_file(args(body)?).await?), | |
| 2671 | 2674 | "compare" => reply(&repos.compare(args(body)?).await?), | |
| 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 | + | } |
| 1383 | 1383 | self.note(&repo.id, issue, (&a.actor.id, &a.actor.username), &text) | |
| 1384 | 1384 | .await?; | |
| 1385 | 1385 | } | |
| 1386 | + | // Its head and merge refs in the repository, before its events go out. | |
| 1387 | + | self.keep_pull_refs(&pull).await; | |
| 1386 | 1388 | self.publish( | |
| 1387 | 1389 | "pull.opened", | |
| 1388 | 1390 | &repo.id, | |
| ⋯ | |||
| 1908 | 1910 | ) | |
| 1909 | 1911 | .await?; | |
| 1910 | 1912 | } | |
| 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 | + | } | |
| 1916 | 1919 | } | |
| 1917 | 1920 | Ok(Outcome::Ok(())) | |
| 1918 | 1921 | } | |
| ⋯ | |||
| 2067 | 2070 | pull.status = status; | |
| 2068 | 2071 | pull.superseded_by = None; | |
| 2069 | 2072 | 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; | |
| 2071 | 2076 | if let Err(error) = self.assess_mergeability(&pull).await { | |
| 2072 | 2077 | worker::console_warn!("mergeability of {}: {error}", pull.id); | |
| 2073 | 2078 | } | |
| 239 | 239 | for id in ids { | |
| 240 | 240 | let assessed = async { | |
| 241 | 241 | 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 | + | } | |
| 242 | 247 | self.assess_mergeability(&pull).await?; | |
| 243 | 248 | } | |
| 244 | 249 | Ok::<_, worker::Error>(()) | |
| ⋯ | |||
| 249 | 254 | } | |
| 250 | 255 | } | |
| 251 | 256 | ||
| 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 | + | ||
| 252 | 288 | /// The open pull requests into a branch of a repository (`None`: its | |
| 253 | 289 | /// default branch), most recently active first. | |
| 254 | 290 | async fn targeting(&self, repo_id: &str, base: Option<&str>) -> Result<Vec<String>> { | |