flagon-io/g1t

public

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

Commit

Catching up with main takes seconds when the two sides touched different files

The repos service merges the base branch into a pull request's branch itself when their changed paths are disjoint: it rebuilds only the trees on the changed paths, writes one merge commit, pushes a whole-object pack with a compare-and-swap on the branch, and emits the usual git.push so checks, mergeability and the lifecycle move on. Anything else (both sides touched a file, known conflicts, no merge base, huge changes) still goes to a sandbox, and the merge box now shows that run's live step, why it went there, and ends in done, failed with Try again, or a timeout. A mode-only change (chmod +x) now counts as a change in diffs.

syntaqxcommitted Parent198e37cBrowse files
21 files+1371−660/21 viewed
+8−5
191191 If something else landed first, merging is refused and the pull request is
192192 **behind**. Its page says so before you try.
193193
194−**Catch up with main** fixes that. A g1t agent merges `main` into the pull
195−request in a sandbox. If the merge is clean, it is pushed as it is. If it
196−conflicts, the agent is given the conflicted files and what the pull request
197−is for, resolves them, and pushes the result. Either way the session records
198−what was done, and the checks run again on the result. You can also do it by
194+**Catch up with main** fixes that. When the pull request and `main` changed
195+different files, g1t merges `main` in itself and pushes the merge in a few
196+seconds. When they changed some of the same files, a g1t agent merges `main`
197+into the pull request in a sandbox: if the merge is clean, it is pushed as it
198+is; if it conflicts, the agent is given the conflicted files and what the
199+pull request is for, resolves them, and pushes the result, and the session
200+records what was done. Either way the checks run again on the result
201+([how catching up works](/guides/pull-requests/#catching-up)). You can also do it by
199202 hand: pull `main` into the fork or the branch, resolve, and push. `main` never loses a commit this way, however many
200203 pull requests are in flight.
201204
+4−1
220220 has an agent read the change and post comments on lines, a summary and a
221221 verdict.
222222 - **Catch up.** When `main` has moved under a pull request, **Catch up with
223− main** has an agent merge it in and resolve any conflict.
223+ main** merges it in. When the two changed different files g1t does that
224+ itself in seconds, with no agent; otherwise an agent merges it in a
225+ sandbox and resolves any conflict
226+ ([catching up](/guides/pull-requests/#catching-up)).
224227
225228 Both run in sandboxes of their own.
226229
+5−1
108108
109109 A pull request that is already known to conflict with `main` is not added
110110 to the queue: [its merge box](/guides/pull-requests/#conflicts) says which
111−files conflict and how to resolve them first.
111+files conflict and how to resolve them first. One that is only behind `main`
112+does not need to catch up to join the queue, since the queue tests it on
113+top of `main`. Where the repository requires pull requests to be up to
114+date, [catch it up](/guides/pull-requests/#catching-up) first: when it and
115+`main` changed different files that takes a few seconds and no agent.
112116
113117 ## When an entry fails
114118
+30−0
109109 still merges: merging brings it up to date first, unless the repository
110110 requires pull requests to be up to date.
111111
112+## Catching up
113+
114+When the target branch has moved, the merge box says **main has moved since
115+this was made**. Whoever can push to the pull request (whoever opened it,
116+for one in its own fork; any member of the workspace, for a branch) can
117+press **Catch up with main now**:
118+
119+1. **When the two changed different files**, g1t merges `main` in itself,
120+ in a few seconds. The merge commit is named **Merge main into
121+ *branch***, has the pull request's head and `main`'s head as its
122+ parents, and is authored and pushed as you. The box then says **Brought
123+ up to date with main**, and the checks and workflows run again on the
124+ new commit, as after any push.
125+2. **When both changed some of the same files**, a sandbox merges `main` in
126+ with git, and a [g1t agent](/guides/g1t-agents/) resolves any conflict.
127+ The box says what is happening (**g1t-agent is resolving conflicts with
128+ main** when the merge is known to conflict) with the run's live step and
129+ how long it has taken. It usually takes about a minute. When the result
130+ is pushed, the box shows the pull request up to date; if the run fails,
131+ or nothing has been pushed after five minutes, the box says so and
132+ offers **Try again**. Nothing is pushed by a run that fails.
133+
134+Either way the merge is pushed only if the pull request's branch is still
135+where it was when the catch-up started. If someone pushed to it meanwhile,
136+the catch-up stops with nothing lost, and you can press it again.
137+
138+The second case needs g1t agents enabled for the workspace and is
139+[charged](/guides/usage-and-billing/#what-is-charged) as agent work; the
140+first is not.
141+
112142 A [g1t agent's](/guides/g1t-agents/) pull request that is found to conflict
113143 is sent back to resolve it by itself, before it is ready.
114144
+109−7
1818 Terminal,
1919 TriangleAlert,
2020 } from "lucide-react";
21−import { useMemo, useState } from "react";
21+import { useEffect, useMemo, useState } from "react";
2222 import { Form, Link } from "react-router";
2323
24−import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, Pull } from "@g1t/contracts";
24+import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, PullBranchUpdate, Pull } from "@g1t/contracts";
2525
26+import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
2627 import { duration } from "./actions";
28+import { Elapsed, useRuns } from "./agents";
2729 import { Button, CopyLine, ErrorText, TimeAgo } from "./ui";
2830 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
2931
549551 changesUrl,
550552 canResolve,
551553 resolving,
552− onResolve,
553554 error,
554555 }: {
555556 conflicts: string[];
561562 changesUrl: string;
562563 /** Whether the viewer may have the g1t agent resolve them. */
563564 canResolve: boolean;
565+ /** Whether asking for it is on its way. */
564566 resolving: boolean;
565− onResolve: () => void;
566567 error?: string | null;
567568 }) {
568569 const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts);
595596 )}
596597 <div className="mt-3 flex flex-wrap items-center gap-2">
597598 {canResolve && (
598− <Form method="post" onSubmit={onResolve}>
599+ <Form method="post">
599600 <input type="hidden" name="action" value="update" />
600601 <Button variant="primary" type="submit" disabled={resolving}>
601− <Sparkles size={14} />
602− {resolving ? "The g1t agent is resolving them…" : "Resolve with g1t agent"}
602+ {resolving ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
603+ {resolving ? "Starting g1t-agent…" : "Resolve with g1t agent"}
603604 </Button>
604605 </Form>
605606 )}
697698 }
698699 return null;
699700 }
701+
702+/**
703+ * A catch-up handed to a sandbox, while it lasts: who is doing what, the
704+ * run's live step, and how long it has taken. It ends with the pull request
705+ * up to date (and this disappears), or with a plain failure and a way to
706+ * try again. It never spins on with no end.
707+ */
708+export function CatchUpProgress({
709+ owner,
710+ repo,
711+ number,
712+ defaultBranch,
713+ behind,
714+ update,
715+ startedAt,
716+ retrying,
717+}: {
718+ owner: string;
719+ repo: string;
720+ number: number;
721+ defaultBranch: string;
722+ behind: boolean;
723+ update: Extract<PullBranchUpdate, { outcome: "needs_agent" }>;
724+ /** When it was asked for, in ms since the epoch. */
725+ startedAt: number;
726+ /** Whether a retry is on its way. */
727+ retrying: boolean;
728+}) {
729+ // The page revalidates while this is working, which reloads the runs too.
730+ const data = useRuns(owner, repo, { number: String(number), limit: "5" });
731+ const [now, setNow] = useState(() => Date.now());
732+ useEffect(() => {
733+ const timer = setInterval(() => setNow(Date.now()), 5000);
734+ return () => clearInterval(timer);
735+ }, []);
736+ const run = catchUpRun(data?.runs ?? [], startedAt);
737+ const phase = catchUpPhase({ behind, run, startedAt, now });
738+ if (phase === "done") return null;
739+ const session = `/${owner}/${repo}/sessions/${number}`;
740+ if (phase !== "working") {
741+ return (
742+ <div className="flex gap-3 px-4 py-3 text-sm">
743+ <TriangleAlert size={16} className={`mt-0.5 shrink-0 ${phase === "failed" ? "text-danger" : "text-warn"}`} />
744+ <div className="min-w-0">
745+ <p className="font-medium">
746+ {phase === "failed"
747+ ? `Catching up with ${defaultBranch} failed`
748+ : `Catching up with ${defaultBranch} is taking longer than it should`}
749+ </p>
750+ <p className="mt-0.5 text-muted">
751+ {phase === "failed"
752+ ? `${run?.error ? `${run.error} ` : ""}Nothing was pushed, so the pull request is as it was.`
753+ : "It usually takes about a minute. It may still finish; this page updates if it does."}{" "}
754+ <Link to={session} className="text-fg hover:underline">
755+ See the session
756+ </Link>
757+ .
758+ </p>
759+ <Form method="post" className="mt-2">
760+ <input type="hidden" name="action" value="update" />
761+ <Button variant="quiet" type="submit" disabled={retrying}>
762+ {retrying ? <LoaderCircle size={14} className="animate-spin" /> : <RotateCw size={14} />}
763+ {retrying ? "Trying again…" : "Try again"}
764+ </Button>
765+ </Form>
766+ </div>
767+ </div>
768+ );
769+ }
770+ return (
771+ <div className="flex gap-3 px-4 py-3 text-sm">
772+ <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-merged" />
773+ <div className="min-w-0 grow">
774+ <p className="font-medium">{catchUpTitle(update.reason, defaultBranch)}</p>
775+ <p className="mt-0.5 text-muted">
776+ {catchUpWhy(update, defaultBranch)} This usually takes about a minute; the pull request updates here when it
777+ is pushed.
778+ </p>
779+ {update.paths.length > 0 && (
780+ <p className="mt-1 truncate font-mono text-xs text-faint" title={update.paths.join(", ")}>
781+ {update.paths.slice(0, 5).join(", ")}
782+ {update.paths.length > 5 && ` and ${update.paths.length - 5} more`}
783+ </p>
784+ )}
785+ <p
786+ className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line"
787+ title={run?.step ?? undefined}
788+ >
789+ <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
790+ {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")}
791+ </p>
792+ <p className="mt-2 flex flex-wrap items-center gap-x-3 text-xs text-muted">
793+ <Elapsed from={new Date(startedAt).toISOString()} />
794+ <Link to={session} className="hover:text-fg">
795+ Watch the session
796+ </Link>
797+ </p>
798+ </div>
799+ </div>
800+ );
801+}
+68−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AgentRun } from "@g1t/contracts";
5+
6+import { CATCH_UP_TIMEOUT_MS, catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "./catch-up.ts";
7+
8+const START = Date.parse("2026-10-05T12:00:00Z");
9+
10+function run(kind: AgentRun["kind"], status: AgentRun["status"], createdAt: string): AgentRun {
11+ return {
12+ id: `run-${kind}-${createdAt}`,
13+ repo: { namespace: "acme", name: "app" },
14+ number: 7,
15+ title: null,
16+ kind,
17+ agent: "g1t-agent",
18+ model: null,
19+ status,
20+ step: null,
21+ steps: [],
22+ stepCount: 0,
23+ startedBy: "octo",
24+ error: null,
25+ costUsd: null,
26+ turns: null,
27+ createdAt,
28+ startedAt: null,
29+ finishedAt: null,
30+ updatedAt: createdAt,
31+ };
32+}
33+
34+test("the update run for a request is the newest one since it was made", () => {
35+ const runs = [
36+ run("update", "failed", "2026-10-05T11:00:00Z"),
37+ run("review", "running", "2026-10-05T12:00:05Z"),
38+ run("update", "running", "2026-10-05T12:00:03Z"),
39+ ];
40+ assert.equal(catchUpRun(runs, START)?.createdAt, "2026-10-05T12:00:03Z");
41+ assert.equal(catchUpRun(runs.slice(0, 2), START), null);
42+});
43+
44+test("it is done as soon as the pull request is no longer behind", () => {
45+ assert.equal(catchUpPhase({ behind: false, run: null, startedAt: START, now: START + 1000 }), "done");
46+ const failed = run("update", "failed", "2026-10-05T12:00:03Z");
47+ assert.equal(catchUpPhase({ behind: false, run: failed, startedAt: START, now: START }), "done");
48+});
49+
50+test("a run that ended while still behind failed", () => {
51+ for (const status of ["failed", "stopped"] as const) {
52+ const ended = run("update", status, "2026-10-05T12:00:03Z");
53+ assert.equal(catchUpPhase({ behind: true, run: ended, startedAt: START, now: START + 60_000 }), "failed");
54+ }
55+});
56+
57+test("it never waits forever", () => {
58+ const going = run("update", "running", "2026-10-05T12:00:03Z");
59+ assert.equal(catchUpPhase({ behind: true, run: going, startedAt: START, now: START + 60_000 }), "working");
60+ assert.equal(catchUpPhase({ behind: true, run: null, startedAt: START, now: START + CATCH_UP_TIMEOUT_MS + 1 }), "timed_out");
61+});
62+
63+test("the box says who is doing what", () => {
64+ assert.equal(catchUpTitle("conflicting", "main"), "g1t-agent is resolving conflicts with main");
65+ assert.equal(catchUpTitle("overlap", "main"), "g1t-agent is merging main into this pull request");
66+ assert.match(catchUpWhy({ reason: "overlap", paths: ["a.rs"] }, "main"), /both changed one file/);
67+ assert.match(catchUpWhy({ reason: "conflicting", paths: ["a.rs", "b.rs"] }, "main"), /conflicts in 2 files/);
68+});
+72−0
1+/**
2+ * Where a pull request's catch-up stands once it has been handed to a
3+ * sandbox: still going, done, failed, or taking far longer than it should.
4+ * Pure, so it is tested on its own; it imports only types.
5+ */
6+import type { AgentRun, PullBranchUpdate } from "@g1t/contracts";
7+
8+/** A sandbox catch-up usually takes about a minute; past this, say so. */
9+export const CATCH_UP_TIMEOUT_MS = 5 * 60_000;
10+/** Runs created this long before the request was made still count as its run. */
11+const CLOCK_SLACK_MS = 30_000;
12+
13+export type CatchUpPhase = "working" | "done" | "failed" | "timed_out";
14+
15+/**
16+ * The `update` run started for a request made at `startedAt`, newest
17+ * first, if it has shown up yet.
18+ */
19+export function catchUpRun(runs: AgentRun[], startedAt: number): AgentRun | null {
20+ return (
21+ runs
22+ .filter((run) => run.kind === "update" && Date.parse(run.createdAt) >= startedAt - CLOCK_SLACK_MS)
23+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null
24+ );
25+}
26+
27+/**
28+ * Where it stands. It is done as soon as the pull request is no longer
29+ * behind, whatever the run says; failed when its run ended without that;
30+ * timed out when nothing has settled it for too long.
31+ */
32+export function catchUpPhase({
33+ behind,
34+ run,
35+ startedAt,
36+ now,
37+}: {
38+ behind: boolean;
39+ run: AgentRun | null;
40+ startedAt: number;
41+ now: number;
42+}): CatchUpPhase {
43+ if (!behind) return "done";
44+ if (run && (run.status === "failed" || run.status === "stopped")) return "failed";
45+ // Succeeded, but the page has not seen the push yet: give it the same time.
46+ if (now - startedAt > CATCH_UP_TIMEOUT_MS) return "timed_out";
47+ return "working";
48+}
49+
50+type NeedsAgent = Extract<PullBranchUpdate, { outcome: "needs_agent" }>;
51+
52+/** What the box says while a sandbox brings the pull request up to date. */
53+export function catchUpTitle(reason: NeedsAgent["reason"], defaultBranch: string): string {
54+ return reason === "conflicting"
55+ ? `g1t-agent is resolving conflicts with ${defaultBranch}`
56+ : `g1t-agent is merging ${defaultBranch} into this pull request`;
57+}
58+
59+/** Why it went to a sandbox, in a sentence. */
60+export function catchUpWhy(update: Pick<NeedsAgent, "reason" | "paths">, defaultBranch: string): string {
61+ const files = update.paths.length === 1 ? "one file" : `${update.paths.length} files`;
62+ switch (update.reason) {
63+ case "conflicting":
64+ return `Merging ${defaultBranch} conflicts in ${files}, so g1t-agent resolves them in a sandbox and pushes the result.`;
65+ case "overlap":
66+ return update.paths.length > 0
67+ ? `This pull request and ${defaultBranch} both changed ${files}, so they are merged with git in a sandbox. g1t-agent resolves any conflicts.`
68+ : `They are merged with git in a sandbox. g1t-agent resolves any conflicts.`;
69+ default:
70+ return `They are merged with git in a sandbox. g1t-agent resolves any conflicts.`;
71+ }
72+}
+7−0
1414
1515 export const CHANGELOG: ChangelogEntry[] = [
1616 {
17+ date: "2026-10-05",
18+ title: "Catch up in seconds",
19+ about:
20+ "Catch up with main merges it in at once when the two changed different files. When they overlap, the merge box shows g1t-agent at work, step by step.",
21+ href: "https://docs.g1t.sh/guides/pull-requests/#catching-up",
22+ },
23+ {
1724 date: "2026-10-04",
1825 title: "Agents and memory",
1926 about: "Watch every agent run live, stop or message it, and see what agents learned about each project.",
+70−26
2323 User,
2424 Wrench,
2525 } from "lucide-react";
26−import { useEffect, useState } from "react";
27−import { Form, Link, redirect, useRevalidator } from "react-router";
26+import { useEffect } from "react";
27+import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
2828
2929 import {
3030 type Comparison,
6565 TimelineItem,
6666 verdicts,
6767 } from "../../components/work";
68−import { ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box";
68+import { CatchUpProgress, ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box";
69+import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
6970 import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server";
7071 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
7172
219220 });
220221 if (!asked.ok) return { error: asked.error.message, action };
221222 }
223+ // Catching up: merged and pushed in seconds when the two sides changed
224+ // different files; otherwise handed to a sandbox, which takes a minute.
225+ if (action === "update") {
226+ const caught = await work.catchUpPull(user, path, number);
227+ if (!caught.ok) return { action, error: caught.error.message };
228+ const update = caught.value;
229+ if (update.outcome !== "needs_agent") {
230+ return {
231+ action,
232+ updated: { commit: update.commit, already: update.outcome === "up_to_date", at: Date.now() },
233+ };
234+ }
235+ const started = await env.RUNNER.update(user, path, number);
236+ if (!started.ok) return { action, error: started.error.message };
237+ return { action, agent: { update, startedAt: Date.now() } };
238+ }
222239 // A workflow run's failed jobs, run again. Who may is the actions service's call.
223240 if (action === "rerun-workflow") {
224241 const rerun = await actions.rerun(user, path, String(form.get("run") ?? ""), true);
238255 ? await work.closePull(user, path, number)
239256 : action === "recheck"
240257 ? await env.RUNNER.recheck(user, path, number)
241− : action === "update"
242− ? await env.RUNNER.update(user, path, number)
243258 : action === "agent-review"
244259 ? await env.RUNNER.review(user, path, number)
245260 : action === "reviewers"
464479 const reviewerNames = [
465480 ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]),
466481 ];
467− const [submitted, setSubmitted] = useState<string | null>(null);
468− const catchingUp = submitted === "update" && behind;
482+ // A catch-up: the click shows at once; the answer says whether it is
483+ // done already or a sandbox is on it.
484+ const navigation = useNavigation();
485+ const catchUpPending = navigation.state !== "idle" && navigation.formData?.get("action") === "update";
486+ const catchUp = actionData?.action === "update" ? actionData : null;
487+ const caughtUp = catchUp && "updated" in catchUp ? catchUp.updated : null;
488+ const agentCatchUp = catchUp && "agent" in catchUp ? catchUp.agent : null;
489+ // Pushed already: followed until the pull request's head is the merge,
490+ // so its checks show starting again, for a minute at most.
491+ const settling =
492+ caughtUp != null && !caughtUp.already && pull.headCommit !== caughtUp.commit && Date.now() - caughtUp.at < 60_000;
493+ // Followed until it is pushed, or for so long, never longer.
494+ const catchingUp =
495+ agentCatchUp != null && behind && Date.now() - agentCatchUp.startedAt < CATCH_UP_TIMEOUT_MS + REFRESH_MS;
469496 // g1t is taking a step of its own accord, so the page will change.
470497 const moving =
471498 lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you";
472499 // Whether it merges cleanly is being worked out, so the box will change.
473500 const probing = active && mergeable === "checking";
474501 const conflicting = active && mergeable === "conflicting";
475− const resolving = submitted === "update" && conflicting;
476502 useEffect(() => {
477− if (!working && !checking && !reviewPending && !catchingUp && !moving && !landing && !probing && !resolving) return;
503+ if (!working && !checking && !reviewPending && !catchingUp && !settling && !moving && !landing && !probing) return;
478504 const timer = setInterval(() => {
479505 if (document.visibilityState === "visible") revalidator.revalidate();
480506 }, REFRESH_MS);
481507 return () => clearInterval(timer);
482− }, [working, checking, reviewPending, catchingUp, moving, landing, probing, resolving, revalidator]);
508+ }, [working, checking, reviewPending, catchingUp, settling, moving, landing, probing, revalidator]);
483509 // Why the merge button cannot be pressed, if it cannot.
484510 const mergeBlocked = conflicting
485511 ? "Resolve the conflicts first."
854880 {defaultBranch} has moved, so g1t is bringing this up to date first. It
855881 lands as soon as that is done. Watch it in the Session tab.
856882 </StatusRow>
883+ ) : agentCatchUp && behind ? (
884+ <CatchUpProgress
885+ owner={params.owner}
886+ repo={params.repo}
887+ number={pull.number}
888+ defaultBranch={defaultBranch}
889+ behind={behind}
890+ update={agentCatchUp.update}
891+ startedAt={agentCatchUp.startedAt}
892+ retrying={catchUpPending}
893+ />
857894 ) : conflicting ? (
858895 <ConflictsSection
859896 conflicts={conflicts}
863900 defaultBranch={defaultBranch}
864901 changesUrl={here + "?tab=changes"}
865902 canResolve={canUpdate && agentsEnabled}
866− resolving={resolving}
867− onResolve={() => setSubmitted("update")}
868− error={actionData?.action === "update" ? actionData.error : null}
903+ resolving={catchUpPending}
904+ error={catchUp && "error" in catchUp ? catchUp.error : null}
869905 />
870906 ) : probing ? (
871907 <MergeabilityRow mergeable={mergeable} defaultBranch={defaultBranch} />
878914 {requireUpToDate
879915 ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge."
880916 : "That does not stop it merging: it is brought up to date as part of the merge."}
881− {canUpdate && agentsEnabled && (
882− <Form
883− method="post"
884− className="mt-2"
885− onSubmit={() => setSubmitted("update")}
886− >
917+ {canUpdate && (
918+ <Form method="post" className="mt-2">
887919 <input type="hidden" name="action" value="update" />
888− <Button variant="quiet" type="submit" disabled={catchingUp}>
889− {catchingUp ? "Catching up…" : `Catch up with ${defaultBranch} now`}
920+ <Button variant="quiet" type="submit" disabled={catchUpPending}>
921+ {catchUpPending && <Loader size={14} className="animate-spin" />}
922+ {catchUpPending ? `Merging ${defaultBranch} in…` : `Catch up with ${defaultBranch} now`}
890923 </Button>
891924 </Form>
892925 )}
893− {actionData?.action === "update" && (
894− <ErrorText>{actionData.error}</ErrorText>
895− )}
926+ {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>}
896927 </StatusRow>
897928 ) : (
898929 pull.headCommit && (
899930 <StatusRow
900931 icon={<CircleCheck size={16} className="text-accent" />}
901− title={`Up to date with ${defaultBranch}`}
902− />
932+ title={
933+ (caughtUp && !caughtUp.already) || agentCatchUp
934+ ? `Brought up to date with ${defaultBranch}`
935+ : `Up to date with ${defaultBranch}`
936+ }
937+ >
938+ {caughtUp && !caughtUp.already && (
939+ <>
940+ Merged <span className="font-mono">{defaultBranch}</span> in as{" "}
941+ <span className="font-mono text-fg">{caughtUp.commit.slice(0, 7)}</span>. Its checks run
942+ again on the new commit.
943+ </>
944+ )}
945+ {agentCatchUp && "g1t-agent merged it in and pushed the result. Its checks run again on the new commit."}
946+ </StatusRow>
903947 )
904948 )}
905949 {stalled && !lifecycle && (
+75−0
476476 pub truncated: bool,
477477 }
478478
479+/// `update_pull_branch`: brings a pull request's source up to date with the
480+/// default branch it would merge into, without a sandbox, when that can be
481+/// done safely: merges the default branch's head into the source's head and
482+/// pushes the merge commit to the source's branch, as `actor`, only if the
483+/// branch has not moved meanwhile. It applies only when the two sides
484+/// changed different files since they last agreed; otherwise the answer is
485+/// [`PullBranchUpdate::NeedsAgent`] and nothing is pushed. Refused unless
486+/// `actor` may push to the source. Returns `Outcome<PullBranchUpdate>`.
487+#[derive(Debug, Serialize, Deserialize)]
488+#[serde(rename_all = "camelCase")]
489+pub struct UpdatePullBranchArgs {
490+ /// The pull request's fork, or the repository itself for a branch.
491+ pub source_id: String,
492+ /// The branch of the source. A fork is updated on its default branch.
493+ #[serde(default)]
494+ pub branch: Option<String>,
495+ /// The pull request's number, to name it in the merge commit's message
496+ /// when its branch has the same name as the default branch.
497+ pub number: u32,
498+ /// Who asked: the merge commit's author and committer, and the pusher.
499+ pub actor: User,
500+}
501+
502+/// Why an update has to be left to a sandbox.
503+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
504+#[serde(rename_all = "snake_case")]
505+pub enum NeedsAgentReason {
506+ /// Both sides changed some of the same files; merging them needs a
507+ /// real merge, which may or may not conflict.
508+ Overlap,
509+ /// Merging is known to conflict.
510+ Conflicting,
511+ /// The update could not be worked out here, such as when the two sides
512+ /// share no history g1t can see, or the change is too large to list.
513+ Unsupported,
514+}
515+
516+/// What came of `update_pull_branch` (or the work service's `catch_up_pull`).
517+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
518+#[serde(tag = "outcome", rename_all = "snake_case")]
519+pub enum PullBranchUpdate {
520+ /// The merge commit was pushed: the branch moved from `previous` to
521+ /// `commit`.
522+ Updated { commit: String, previous: String },
523+ /// The source already holds the default branch's head.
524+ UpToDate { commit: String },
525+ /// Nothing was pushed; a sandbox has to merge it. `paths` are the
526+ /// files both sides changed, or that conflict, when known.
527+ NeedsAgent {
528+ reason: NeedsAgentReason,
529+ detail: String,
530+ paths: Vec<String>,
531+ },
532+}
533+
479534 /// `head`: the commit a branch points to, or null. For services reacting
480535 /// to a push, which have no viewer; it reveals nothing but a commit hash.
481536 /// Returns `Option<String>`.
637692 assert!(clean_topics(&many).is_err());
638693 }
639694 }
695+
696+#[cfg(test)]
697+mod tests {
698+ use super::*;
699+
700+ #[test]
701+ fn a_pull_branch_update_reads_as_the_web_expects() {
702+ let update = PullBranchUpdate::NeedsAgent {
703+ reason: NeedsAgentReason::Overlap,
704+ detail: "both".into(),
705+ paths: vec!["a.rs".into()],
706+ };
707+ assert_eq!(
708+ serde_json::to_value(&update).unwrap(),
709+ serde_json::json!({ "outcome": "needs_agent", "reason": "overlap", "detail": "both", "paths": ["a.rs"] })
710+ );
711+ let done = PullBranchUpdate::UpToDate { commit: "c".into() };
712+ assert_eq!(serde_json::to_value(&done).unwrap(), serde_json::json!({ "outcome": "up_to_date", "commit": "c" }));
713+ }
714+}
+7−0
949949 }
950950
951951 /// `ready_pull`, `close_pull` and `merge_pull`. Each returns `Outcome<Pull>`.
952+///
953+/// Also `catch_up_pull`: brings the pull request up to date with the
954+/// default branch without a sandbox where that is safe, as the repos
955+/// service's `update_pull_branch` does, after checking that `actor` may
956+/// update it: whoever opened it for a fork, any member for a branch.
957+/// Returns `Outcome<repos::PullBranchUpdate>`; on `needs_agent` nothing was
958+/// pushed and the runner's `update` is the way on.
952959 #[derive(Debug, Serialize, Deserialize)]
953960 #[serde(rename_all = "camelCase")]
954961 pub struct PullActionArgs {
+90−20
44 //! A pushed pack is usually thin: some objects are deltas against objects
55 //! the repository already has. Those are left pending until the caller
66 //! supplies their bases with [`Pack::supply`].
7+//!
8+//! Writing is the small part g1t needs for a merge it makes itself: a pack
9+//! of whole objects ([`write_pack`]), or one fetched from elsewhere with a
10+//! few objects added ([`extend_pack`]).
711
812 use std::collections::HashMap;
913
3438 })
3539 }
3640
41+ /// The type code a pack gives objects of this kind.
42+ fn code(self) -> u8 {
43+ match self {
44+ ObjectKind::Commit => 1,
45+ ObjectKind::Tree => 2,
46+ ObjectKind::Blob => 3,
47+ ObjectKind::Tag => 4,
48+ }
49+ }
50+
3751 fn name(self) -> &'static str {
3852 match self {
3953 ObjectKind::Commit => "commit",
121135 }
122136 }
123137
138+/// An entry's header in a pack: its type and its inflated size.
139+fn header(code: u8, size: usize) -> Vec<u8> {
140+ let mut out = Vec::new();
141+ let mut byte = (code << 4) | (size & 15) as u8;
142+ let mut rest = size >> 4;
143+ while rest > 0 {
144+ out.push(byte | 0x80);
145+ byte = (rest & 0x7f) as u8;
146+ rest >>= 7;
147+ }
148+ out.push(byte);
149+ out
150+}
151+
152+fn write_entries(pack: &mut Vec<u8>, objects: &[(ObjectKind, Vec<u8>)]) {
153+ for (kind, data) in objects {
154+ pack.extend(header(kind.code(), data.len()));
155+ pack.extend(miniz_oxide::deflate::compress_to_vec_zlib(data, 6));
156+ }
157+}
158+
159+fn seal(mut pack: Vec<u8>) -> Vec<u8> {
160+ let checksum = Sha1::digest(&pack);
161+ pack.extend_from_slice(&checksum);
162+ pack
163+}
164+
165+/// A version 2 pack holding `objects` whole, with no deltas: what git's
166+/// receive-pack takes, when the objects are few and new.
167+pub fn write_pack(objects: &[(ObjectKind, Vec<u8>)]) -> Vec<u8> {
168+ let mut pack = b"PACK".to_vec();
169+ pack.extend_from_slice(&2u32.to_be_bytes());
170+ pack.extend_from_slice(&(objects.len() as u32).to_be_bytes());
171+ write_entries(&mut pack, objects);
172+ seal(pack)
173+}
174+
175+/// `pack` with `objects` added after its own, as one pack. Its entries keep
176+/// their offsets, since the header stays the same length, so its deltas
177+/// still find their bases. `pack` must not be thin.
178+pub fn extend_pack(pack: &[u8], objects: &[(ObjectKind, Vec<u8>)]) -> Result<Vec<u8>, String> {
179+ if pack.len() < 32 || &pack[..4] != b"PACK" {
180+ return Err("not a pack".into());
181+ }
182+ let (body, trailer) = pack.split_at(pack.len() - 20);
183+ if Sha1::digest(body).as_slice() != trailer {
184+ return Err("the pack's checksum does not match".into());
185+ }
186+ let count = u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]]) as usize;
187+ let total = u32::try_from(count + objects.len()).map_err(|_| "the pack is too large")?;
188+ let mut out = body.to_vec();
189+ out[8..12].copy_from_slice(&total.to_be_bytes());
190+ write_entries(&mut out, objects);
191+ Ok(seal(out))
192+}
193+
124194 /// Applies a git delta to its base.
125195 pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>, String> {
126196 let mut at = 0;
413483 pub(crate) mod tests {
414484 use super::*;
415485 use miniz_oxide::deflate::compress_to_vec_zlib;
416−
417− fn header(code: u8, size: usize) -> Vec<u8> {
418− let mut out = Vec::new();
419− let mut byte = (code << 4) | (size & 15) as u8;
420− let mut rest = size >> 4;
421− while rest > 0 {
422− out.push(byte | 0x80);
423− byte = (rest & 0x7f) as u8;
424− rest >>= 7;
425− }
426− out.push(byte);
427− out
428− }
429486
430487 /// A pack of whole objects, plus ref-deltas given as (base id, delta).
431488 pub fn build_pack(objects: &[(ObjectKind, Vec<u8>)], ref_deltas: &[(String, Vec<u8>)]) -> Vec<u8> {
433490 pack.extend_from_slice(&2u32.to_be_bytes());
434491 pack.extend_from_slice(&((objects.len() + ref_deltas.len()) as u32).to_be_bytes());
435492 for (kind, data) in objects {
436− let code = match kind {
437− ObjectKind::Commit => 1,
438− ObjectKind::Tree => 2,
439− ObjectKind::Blob => 3,
440− ObjectKind::Tag => 4,
441− };
442− pack.extend(header(code, data.len()));
493+ pack.extend(header(kind.code(), data.len()));
443494 pack.extend(compress_to_vec_zlib(data, 6));
444495 }
445496 for (base, delta) in ref_deltas {
532583 assert_eq!(pack_start(&commands), None);
533584 assert!(Pack::parse(b"nope").is_err());
534585 }
586+
587+ #[test]
588+ fn written_packs_are_sealed_and_extend() {
589+ let blob = b"hello
590+".to_vec();
591+ let pack = write_pack(&[(ObjectKind::Blob, blob.clone())]);
592+ let (body, trailer) = pack.split_at(pack.len() - 20);
593+ assert_eq!(Sha1::digest(body).as_slice(), trailer);
594+ let more = extend_pack(&pack, &[(ObjectKind::Blob, b"more
595+".to_vec())]).unwrap();
596+ assert_eq!(&more[8..12], &2u32.to_be_bytes());
597+ let read = Pack::parse(&more).unwrap();
598+ assert!(read.blob("ce013625030ba8dba906f756967f9e9ca394464a").is_some());
599+ assert!(read.blob(&object_id(ObjectKind::Blob, b"more
600+")).is_some());
601+ let mut broken = pack.clone();
602+ broken[12] ^= 1;
603+ assert!(extend_pack(&broken, &[]).is_err());
604+ }
535605 }
+1−0
197197 getPull: (repo, number, viewer) => call("get_pull", { repo, number, viewer }),
198198 updatePull: (actor, repo, number, changes) =>
199199 call("update_pull", { actor, repo, number, ...changes }),
200+ catchUpPull: (actor, repo, number) => call("catch_up_pull", { actor, repo, number }),
200201 readyPull: (actor, repo, number, summary) =>
201202 call("ready_pull", { actor, repo, number, summary }),
202203 closePull: (actor, repo, number) => call("close_pull", { actor, repo, number }),
+21−0
176176 /** A branch and the commit it points to. */
177177 export type Branch = { name: string; hash: string };
178178
179+/**
180+ * What came of bringing a pull request up to date with the default branch
181+ * without a sandbox. `needs_agent` pushed nothing: the runner's `update`
182+ * merges it in a sandbox, with an agent if it conflicts.
183+ */
184+export type PullBranchUpdate =
185+ | { outcome: "updated"; commit: string; previous: string }
186+ | { outcome: "up_to_date"; commit: string }
187+ | {
188+ outcome: "needs_agent";
189+ /**
190+ * `overlap`: both sides changed some of the same files. `conflicting`:
191+ * merging is known to conflict. `unsupported`: it could not be worked
192+ * out without git, such as for a very large change.
193+ */
194+ reason: "overlap" | "conflicting" | "unsupported";
195+ detail: string;
196+ /** The files both changed, or that conflict, when known. */
197+ paths: string[];
198+ };
199+
179200 export type DiffLine = {
180201 kind: "context" | "add" | "delete";
181202 /** Line number in the old file; null for added lines. */
+9−1
11 import type { User, Viewer } from "./identity";
2−import type { RepoPath } from "./repos";
2+import type { PullBranchUpdate, RepoPath } from "./repos";
33 import type { Result } from "./result";
44
55 /**
665665 number: number,
666666 changes: { assignees?: string[]; reviewers?: string[] },
667667 ): Promise<Result<Pull>>;
668+ /**
669+ * Brings a pull request up to date with the default branch in seconds,
670+ * without a sandbox, when the two changed different files: the merge
671+ * commit is pushed to its branch as `actor`, who must be whoever opened
672+ * it (for a fork) or a member (for a branch). Otherwise `needs_agent`, and
673+ * nothing is pushed: the runner's `update` is the way on.
674+ */
675+ catchUpPull(actor: User, repo: RepoPath, number: number): Promise<Result<PullBranchUpdate>>;
668676 /** Marks a draft ready for review and sets its description. */
669677 readyPull(actor: User, repo: RepoPath, number: number, summary: string): Promise<Result<Pull>>;
670678 closePull(actor: User, repo: RepoPath, number: number): Promise<Result<Pull>>;
+729−0
1+//! Bringing a pull request up to date with the branch it would merge into,
2+//! without a sandbox, when that is safe.
3+//!
4+//! When the pull request and the default branch changed different files
5+//! since they last agreed, the merge cannot conflict, and its result is
6+//! known without merging any file: the default branch's tree, with the
7+//! files the pull request changed taken from the pull request. Only the
8+//! trees on the way to those files change. They are rebuilt here, with one
9+//! merge commit on top whose parents are the pull request's head and the
10+//! default branch's head, written as a pack of whole objects and pushed to
11+//! the pull request's branch, if it is still where it was.
12+//!
13+//! When both sides changed a file, the merge needs git itself (and maybe an
14+//! agent), so the answer is that a sandbox is needed, and nothing is pushed.
15+
16+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
17+
18+use futures_util::future::{try_join, try_join_all};
19+use g1t_contracts::audit::{AuditActor, NewAuditEntry, Surface};
20+use g1t_contracts::credentials::Decision;
21+use g1t_contracts::repos::{
22+ EntryKind, NeedsAgentReason, PullBranchUpdate, RepoPath, TreeEntry, UpdatePullBranchArgs,
23+};
24+use g1t_contracts::{FailureCode, Outcome};
25+use g1t_kit::now_ms;
26+use g1t_scan::pack::{ObjectKind, TreeItem, encode_tree, extend_pack, object_id, write_pack};
27+use worker::Result;
28+
29+use crate::registry::{can_read, can_write, store_key};
30+use crate::store::{GitRepo, GitStore, Scope};
31+use crate::{MAX_ANCESTRY, Repos, UNVERIFIED, descends_from, diff, land, nearest_ancestor_in, not_found};
32+
33+/// The mode git writes for an entry of each kind.
34+fn mode(kind: EntryKind) -> &'static str {
35+ match kind {
36+ EntryKind::Tree => "40000",
37+ EntryKind::Blob => "100644",
38+ EntryKind::Exec => "100755",
39+ EntryKind::Symlink => "120000",
40+ EntryKind::Gitlink => "160000",
41+ }
42+}
43+
44+/// Git orders a tree's entries by name, comparing a subtree's name as if
45+/// it ended in `/`.
46+fn sort_key(entry: &TreeEntry) -> Vec<u8> {
47+ let mut key = entry.name.as_bytes().to_vec();
48+ if entry.kind == EntryKind::Tree {
49+ key.push(b'/');
50+ }
51+ key
52+}
53+
54+/// A tree object's bytes, its entries in git's order.
55+pub(crate) fn encode_entries(entries: &[TreeEntry]) -> Vec<u8> {
56+ let mut sorted: Vec<&TreeEntry> = entries.iter().collect();
57+ sorted.sort_by_key(|entry| sort_key(entry));
58+ let items: Vec<TreeItem> = sorted
59+ .into_iter()
60+ .map(|entry| TreeItem {
61+ mode: mode(entry.kind).to_owned(),
62+ name: entry.name.clone(),
63+ id: entry.hash.clone(),
64+ })
65+ .collect();
66+ encode_tree(&items)
67+}
68+
69+/// The directories above a path, nearest the root first: `a/b/c` is in
70+/// `a` and `a/b`.
71+fn ancestors(path: &str) -> impl Iterator<Item = &str> {
72+ path.match_indices('/').map(move |(at, _)| &path[..at])
73+}
74+
75+/// The paths at which the two sides' changes meet, so that the merge is not
76+/// a matter of taking each side's files: a file both changed, or a file on
77+/// one side where the other has a directory (a file `a` against `a/b`).
78+pub(crate) fn overlapping(ours: &[String], theirs: &[String]) -> Vec<String> {
79+ let their_files: HashSet<&str> = theirs.iter().map(String::as_str).collect();
80+ let their_dirs: HashSet<&str> = theirs.iter().flat_map(|path| ancestors(path)).collect();
81+ let mut met: BTreeSet<String> = BTreeSet::new();
82+ for path in ours {
83+ if their_files.contains(path.as_str()) || their_dirs.contains(path.as_str()) {
84+ met.insert(path.clone());
85+ }
86+ for dir in ancestors(path) {
87+ if their_files.contains(dir) {
88+ met.insert(dir.to_owned());
89+ }
90+ }
91+ }
92+ met.into_iter().collect()
93+}
94+
95+/// One file the pull request changed, as it is on the pull request: its
96+/// kind and blob, or `None` when it deleted it.
97+#[derive(Clone, Debug)]
98+pub(crate) struct Change {
99+ pub path: String,
100+ pub entry: Option<(EntryKind, String)>,
101+}
102+
103+/// The merged tree's id, and the tree objects written for it.
104+#[derive(Debug)]
105+pub(crate) struct Merged {
106+ pub tree: String,
107+ pub objects: Vec<Vec<u8>>,
108+}
109+
110+enum Node {
111+ Leaf(EntryKind, String),
112+ /// A subtree left as it is.
113+ Subtree(String),
114+ /// A subtree being changed.
115+ Dir(Dir),
116+}
117+
118+#[derive(Default)]
119+struct Dir {
120+ entries: BTreeMap<String, Node>,
121+}
122+
123+/// A tree, ready to change. Its entries are checked to write back to
124+/// exactly its id: a tree holding something this cannot write, such as an
125+/// unusual file mode, is refused rather than changed.
126+fn load(id: &str, trees: &HashMap<String, Vec<TreeEntry>>) -> std::result::Result<Dir, String> {
127+ let entries = trees
128+ .get(id)
129+ .ok_or_else(|| format!("tree {id} was not read"))?;
130+ if object_id(ObjectKind::Tree, &encode_entries(entries)) != id {
131+ return Err(format!("tree {id} holds entries g1t cannot write back exactly"));
132+ }
133+ Ok(Dir {
134+ entries: entries
135+ .iter()
136+ .map(|entry| {
137+ let node = match entry.kind {
138+ EntryKind::Tree => Node::Subtree(entry.hash.clone()),
139+ kind => Node::Leaf(kind, entry.hash.clone()),
140+ };
141+ (entry.name.clone(), node)
142+ })
143+ .collect(),
144+ })
145+}
146+
147+/// The directory at `node`, read if it was not yet.
148+fn open<'a>(
149+ node: &'a mut Node,
150+ trees: &HashMap<String, Vec<TreeEntry>>,
151+) -> std::result::Result<&'a mut Dir, String> {
152+ if let Node::Subtree(id) = node {
153+ *node = Node::Dir(load(id, trees)?);
154+ }
155+ match node {
156+ Node::Dir(dir) => Ok(dir),
157+ _ => Err("a file is where a directory was expected".to_owned()),
158+ }
159+}
160+
161+/// Removes the file at `parts`, and any directory that leaves empty.
162+fn remove(
163+ dir: &mut Dir,
164+ parts: &[&str],
165+ trees: &HashMap<String, Vec<TreeEntry>>,
166+) -> std::result::Result<(), String> {
167+ let (name, rest) = parts.split_first().ok_or("an empty path")?;
168+ if rest.is_empty() {
169+ return match dir.entries.get(*name) {
170+ Some(Node::Leaf(..)) => {
171+ dir.entries.remove(*name);
172+ Ok(())
173+ }
174+ Some(_) => Err(format!("{name} is a directory, not a file")),
175+ None => Err(format!("{name} is not there to remove")),
176+ };
177+ }
178+ let child = dir
179+ .entries
180+ .get_mut(*name)
181+ .ok_or_else(|| format!("{name} is not there"))?;
182+ let inner = open(child, trees)?;
183+ remove(inner, rest, trees)?;
184+ if inner.entries.is_empty() {
185+ dir.entries.remove(*name);
186+ }
187+ Ok(())
188+}
189+
190+/// Puts a file at `parts`, making the directories it needs.
191+fn insert(
192+ dir: &mut Dir,
193+ parts: &[&str],
194+ kind: EntryKind,
195+ hash: &str,
196+ trees: &HashMap<String, Vec<TreeEntry>>,
197+) -> std::result::Result<(), String> {
198+ let (name, rest) = parts.split_first().ok_or("an empty path")?;
199+ if rest.is_empty() {
200+ if matches!(dir.entries.get(*name), Some(Node::Subtree(_) | Node::Dir(_))) {
201+ return Err(format!("{name} is a directory, not a file"));
202+ }
203+ dir.entries.insert((*name).to_owned(), Node::Leaf(kind, hash.to_owned()));
204+ return Ok(());
205+ }
206+ let child = dir
207+ .entries
208+ .entry((*name).to_owned())
209+ .or_insert_with(|| Node::Dir(Dir::default()));
210+ insert(open(child, trees)?, rest, kind, hash, trees)
211+}
212+
213+/// Writes a changed directory and those in it; returns its id.
214+fn write(dir: Dir, objects: &mut Vec<Vec<u8>>) -> String {
215+ let mut entries = Vec::with_capacity(dir.entries.len());
216+ for (name, node) in dir.entries {
217+ let (kind, hash) = match node {
218+ Node::Leaf(kind, hash) => (kind, hash),
219+ Node::Subtree(hash) => (EntryKind::Tree, hash),
220+ // Git keeps no empty directories.
221+ Node::Dir(inner) if inner.entries.is_empty() => continue,
222+ Node::Dir(inner) => (EntryKind::Tree, write(inner, objects)),
223+ };
224+ entries.push(TreeEntry { name, hash, kind });
225+ }
226+ let bytes = encode_entries(&entries);
227+ let id = object_id(ObjectKind::Tree, &bytes);
228+ objects.push(bytes);
229+ id
230+}
231+
232+/// The tree of `base_root` with `changes` applied: deletions first, so a
233+/// file can take the place of a directory the pull request emptied.
234+/// `trees` holds every tree of the base on the way to a changed path.
235+pub(crate) fn merge_tree(
236+ base_root: &str,
237+ trees: &HashMap<String, Vec<TreeEntry>>,
238+ changes: &[Change],
239+) -> std::result::Result<Merged, String> {
240+ let mut root = load(base_root, trees)?;
241+ for change in changes.iter().filter(|change| change.entry.is_none()) {
242+ let parts: Vec<&str> = change.path.split('/').collect();
243+ remove(&mut root, &parts, trees).map_err(|why| format!("{}: {why}", change.path))?;
244+ }
245+ for change in changes {
246+ if let Some((kind, hash)) = &change.entry {
247+ let parts: Vec<&str> = change.path.split('/').collect();
248+ insert(&mut root, &parts, *kind, hash, trees)
249+ .map_err(|why| format!("{}: {why}", change.path))?;
250+ }
251+ }
252+ let mut objects = Vec::new();
253+ let tree = write(root, &mut objects);
254+ // A subtree that came out as it was is already stored.
255+ let mut seen = HashSet::new();
256+ objects.retain(|bytes| seen.insert(object_id(ObjectKind::Tree, bytes)));
257+ Ok(Merged { tree, objects })
258+}
259+
260+/// Who a commit is by, and when.
261+pub(crate) struct Signature<'a> {
262+ pub name: &'a str,
263+ pub email: &'a str,
264+ /// Seconds since the epoch, in UTC.
265+ pub seconds: u64,
266+}
267+
268+/// A commit object's bytes, authored and committed by `by`.
269+pub(crate) fn commit_object(tree: &str, parents: &[&str], by: &Signature, message: &str) -> Vec<u8> {
270+ let mut out = format!("tree {tree}\n");
271+ for parent in parents {
272+ out.push_str(&format!("parent {parent}\n"));
273+ }
274+ // Git takes everything up to `<` as the name.
275+ let name: String = by.name.chars().filter(|c| !matches!(c, '<' | '>' | '\n')).collect();
276+ let email: String = by.email.chars().filter(|c| !matches!(c, '<' | '>' | '\n')).collect();
277+ let line = format!("{name} <{email}> {} +0000", by.seconds);
278+ out.push_str(&format!("author {line}\ncommitter {line}\n\n{message}\n"));
279+ out.into_bytes()
280+}
281+
282+/// What the merge commit says.
283+pub(crate) fn merge_message(base: &str, branch: &str, number: u32) -> String {
284+ if branch == base {
285+ // A fork carries its change on a branch named like the default.
286+ format!("Merge {base} into pull request #{number}")
287+ } else {
288+ format!("Merge {base} into {branch}")
289+ }
290+}
291+
292+/// The trees at `dirs` (and the root, `""`) under `root`, by path: each
293+/// one's id and entries. A directory that is not there is left out. Each
294+/// level is read at once.
295+async fn read_dirs<R: GitRepo>(
296+ repo: &R,
297+ root: &str,
298+ dirs: &BTreeSet<String>,
299+) -> Result<HashMap<String, (String, Vec<TreeEntry>)>> {
300+ let mut found: HashMap<String, (String, Vec<TreeEntry>)> = HashMap::new();
301+ if let Some(entries) = repo.read_tree(root).await? {
302+ found.insert(String::new(), (root.to_owned(), entries));
303+ }
304+ let depth = |path: &str| path.matches('/').count();
305+ let deepest = dirs.iter().map(|dir| depth(dir)).max().unwrap_or(0);
306+ for level in 0..=deepest {
307+ let wanted: Vec<(String, String)> = dirs
308+ .iter()
309+ .filter(|dir| !dir.is_empty() && depth(dir) == level)
310+ .filter_map(|dir| {
311+ let (parent, name) = dir.rsplit_once('/').unwrap_or(("", dir.as_str()));
312+ let (_, entries) = found.get(parent)?;
313+ entries
314+ .iter()
315+ .find(|entry| entry.name == name && entry.kind == EntryKind::Tree)
316+ .map(|entry| (dir.clone(), entry.hash.clone()))
317+ })
318+ .collect();
319+ let read = try_join_all(wanted.iter().map(|(_, id)| repo.read_tree(id))).await?;
320+ for ((dir, id), entries) in wanted.into_iter().zip(read) {
321+ if let Some(entries) = entries {
322+ found.insert(dir, (id, entries));
323+ }
324+ }
325+ }
326+ Ok(found)
327+}
328+
329+fn needs_agent(reason: NeedsAgentReason, detail: impl Into<String>, paths: Vec<String>) -> Outcome<PullBranchUpdate> {
330+ Outcome::Ok(PullBranchUpdate::NeedsAgent {
331+ reason,
332+ detail: detail.into(),
333+ paths,
334+ })
335+}
336+
337+impl<S: GitStore> Repos<S> {
338+ pub(crate) async fn update_pull_branch(&self, a: UpdatePullBranchArgs) -> Result<Outcome<PullBranchUpdate>> {
339+ let actor = Some(a.actor.clone());
340+ let Some(source) = self.registry.by_id(&a.source_id).await? else {
341+ return Ok(not_found());
342+ };
343+ let target = match &source.fork_of {
344+ Some(id) => self.registry.by_id(id).await?,
345+ None => Some(source.clone()),
346+ };
347+ let Some(target) = target.filter(|repo| can_read(repo, &actor)) else {
348+ return Ok(not_found());
349+ };
350+ // The merge is pushed as the person asking, so they must be able to push.
351+ if !can_write(&source, &actor) {
352+ return Ok(Outcome::fail(
353+ FailureCode::Forbidden,
354+ if source.fork_of.is_some() {
355+ "Only whoever opened this pull request can update it."
356+ } else {
357+ "Only members of the workspace can update this pull request."
358+ },
359+ ));
360+ }
361+ if !a.actor.verified {
362+ return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
363+ }
364+ let from_fork = source.id != target.id;
365+ let base_branch = target.default_branch.clone();
366+ let branch = a.branch.clone().unwrap_or_else(|| base_branch.clone());
367+ if !from_fork && branch == base_branch {
368+ return Ok(Outcome::fail(
369+ FailureCode::Invalid,
370+ format!("{base_branch} cannot be merged into itself."),
371+ ));
372+ }
373+
374+ let source_git = self.store.open(&store_key(&source)).await?;
375+ let target_git = self.store.open(&store_key(&target)).await?;
376+ let (history, target_history) = try_join(
377+ source_git.log(&branch, MAX_ANCESTRY),
378+ target_git.log(&base_branch, MAX_ANCESTRY),
379+ )
380+ .await?;
381+ let (Some(head), Some(base)) = (history.first(), target_history.first()) else {
382+ return Ok(Outcome::fail(
383+ FailureCode::Conflict,
384+ "This pull request has no commits to bring up to date.",
385+ ));
386+ };
387+ if descends_from(&source_git, &history, &base.hash).await? {
388+ return Ok(Outcome::Ok(PullBranchUpdate::UpToDate {
389+ commit: head.hash.clone(),
390+ }));
391+ }
392+ let shared: HashSet<String> = target_history.iter().map(|commit| commit.hash.clone()).collect();
393+ let merge_base = nearest_ancestor_in(&source_git, &history, &shared).await?;
394+ let Some((merge_base, merge_base_tree)) = merge_base.and_then(|hash| {
395+ target_history
396+ .iter()
397+ .find(|commit| commit.hash == hash)
398+ .map(|commit| (hash, commit.tree_hash.clone()))
399+ }) else {
400+ return Ok(needs_agent(
401+ NeedsAgentReason::Unsupported,
402+ format!("g1t could not find where this pull request left {base_branch}."),
403+ Vec::new(),
404+ ));
405+ };
406+
407+ let ((ours, ours_cut), (theirs, theirs_cut)) = try_join(
408+ diff::changed_paths(&source_git, Some(&merge_base_tree), &head.tree_hash),
409+ diff::changed_paths(&target_git, Some(&merge_base_tree), &base.tree_hash),
410+ )
411+ .await?;
412+ if ours_cut || theirs_cut {
413+ return Ok(needs_agent(
414+ NeedsAgentReason::Unsupported,
415+ "The change is too large to merge without a sandbox.",
416+ Vec::new(),
417+ ));
418+ }
419+ let met = overlapping(&ours, &theirs);
420+ if !met.is_empty() {
421+ return Ok(needs_agent(
422+ NeedsAgentReason::Overlap,
423+ format!("This pull request and {base_branch} both changed some of the same files."),
424+ met,
425+ ));
426+ }
427+
428+ // The trees on the way to each changed file, on both sides.
429+ let dirs: BTreeSet<String> = ours
430+ .iter()
431+ .flat_map(|path| ancestors(path).map(str::to_owned))
432+ .collect();
433+ let (on_pull, on_base) = try_join(
434+ read_dirs(&source_git, &head.tree_hash, &dirs),
435+ read_dirs(&target_git, &base.tree_hash, &dirs),
436+ )
437+ .await?;
438+ let changes: Vec<Change> = ours
439+ .iter()
440+ .map(|path| {
441+ let (parent, name) = path.rsplit_once('/').unwrap_or(("", path.as_str()));
442+ let entry = on_pull.get(parent).and_then(|(_, entries)| {
443+ entries
444+ .iter()
445+ .find(|entry| entry.name == name && entry.kind != EntryKind::Tree)
446+ .map(|entry| (entry.kind, entry.hash.clone()))
447+ });
448+ Change {
449+ path: path.clone(),
450+ entry,
451+ }
452+ })
453+ .collect();
454+ let trees: HashMap<String, Vec<TreeEntry>> = on_base.into_values().collect();
455+ let merged = match merge_tree(&base.tree_hash, &trees, &changes) {
456+ Ok(merged) => merged,
457+ Err(why) => {
458+ return Ok(needs_agent(
459+ NeedsAgentReason::Unsupported,
460+ format!("g1t could not merge this itself: {why}."),
461+ Vec::new(),
462+ ));
463+ }
464+ };
465+
466+ let email = format!("{}@users.g1t.sh", a.actor.username);
467+ let commit = commit_object(
468+ &merged.tree,
469+ &[&head.hash, &base.hash],
470+ &Signature {
471+ name: &a.actor.username,
472+ email: &email,
473+ seconds: now_ms() / 1000,
474+ },
475+ &merge_message(&base_branch, &branch, a.number),
476+ );
477+ let commit_id = object_id(ObjectKind::Commit, &commit);
478+ let mut objects: Vec<(ObjectKind, Vec<u8>)> = merged
479+ .objects
480+ .into_iter()
481+ .map(|bytes| (ObjectKind::Tree, bytes))
482+ .collect();
483+ objects.push((ObjectKind::Commit, commit));
484+
485+ // A fork lacks what the default branch gained since it was made:
486+ // those objects come from the repository, with the merge after them.
487+ let pack = if from_fork {
488+ let target_access = target_git.access(Scope::Read).await?;
489+ let fetched = land::fetch_pack(&target_access, &base.hash, Some(&merge_base)).await?;
490+ extend_pack(&fetched, &objects).map_err(worker::Error::RustError)?
491+ } else {
492+ write_pack(&objects)
493+ };
494+ let source_access = source_git.access(Scope::Write).await?;
495+ // Only if the branch is still where it was: a push that landed
496+ // meanwhile is kept, and this is refused.
497+ let pushed = land::push_pack(&source_access, &branch, Some(&head.hash), &commit_id, pack).await?;
498+ let git_ref = format!("refs/heads/{branch}");
499+ let path = RepoPath {
500+ namespace: source.namespace.clone(),
501+ name: source.name.clone(),
502+ };
503+ let mut target_entry = self.audit_target(&path).await?;
504+ target_entry.git_ref = Some(git_ref.clone());
505+ let mut entry = NewAuditEntry::new(
506+ AuditActor::of(&a.actor),
507+ "git.push",
508+ Surface::Git,
509+ target_entry,
510+ &Decision::allow("person"),
511+ g1t_contracts::new_id("req", now_ms()),
512+ );
513+ if let Err(reason) = pushed {
514+ entry.result = Some("conflict".to_owned());
515+ entry.message = Some(reason);
516+ self.record_git(entry).await;
517+ return Ok(Outcome::fail(
518+ FailureCode::Conflict,
519+ format!("{branch} moved while it was being brought up to date. Nothing was lost; try again."),
520+ ));
521+ }
522+ entry.result = Some("ok".to_owned());
523+ self.record_git(entry).await;
524+ // As any push does: the pull request's head moves, its checks run
525+ // again, and whether it merges cleanly is worked out anew.
526+ self.publish_push(&source, &git_ref, Some(&head.hash), &commit_id, Some(a.actor.id.clone()))
527+ .await?;
528+ Ok(Outcome::Ok(PullBranchUpdate::Updated {
529+ commit: commit_id,
530+ previous: head.hash.clone(),
531+ }))
532+ }
533+}
534+
535+#[cfg(test)]
536+mod tests {
537+ use super::*;
538+
539+ fn entry(name: &str, kind: EntryKind, hash: &str) -> TreeEntry {
540+ TreeEntry {
541+ name: name.to_owned(),
542+ hash: hash.to_owned(),
543+ kind,
544+ }
545+ }
546+
547+ fn paths(list: &[&str]) -> Vec<String> {
548+ list.iter().map(|path| (*path).to_owned()).collect()
549+ }
550+
551+ #[test]
552+ fn different_files_do_not_meet() {
553+ assert!(overlapping(&paths(&["src/a.rs", "README.md"]), &paths(&["src/b.rs", "docs/x.md"])).is_empty());
554+ }
555+
556+ #[test]
557+ fn the_same_file_meets() {
558+ assert_eq!(overlapping(&paths(&["src/a.rs", "b"]), &paths(&["src/a.rs"])), ["src/a.rs"]);
559+ }
560+
561+ #[test]
562+ fn a_file_against_a_directory_meets() {
563+ // Ours made `a` a file where theirs put files under `a/`.
564+ assert_eq!(overlapping(&paths(&["a"]), &paths(&["a/b"])), ["a"]);
565+ // And the other way round.
566+ assert_eq!(overlapping(&paths(&["a/b/c"]), &paths(&["a/b"])), ["a/b"]);
567+ // A shared prefix of a name is not a directory.
568+ assert!(overlapping(&paths(&["ab"]), &paths(&["a/b"])).is_empty());
569+ }
570+
571+ #[test]
572+ fn modes_are_written_as_git_writes_them() {
573+ assert_eq!(mode(EntryKind::Tree), "40000");
574+ assert_eq!(mode(EntryKind::Blob), "100644");
575+ assert_eq!(mode(EntryKind::Exec), "100755");
576+ assert_eq!(mode(EntryKind::Symlink), "120000");
577+ assert_eq!(mode(EntryKind::Gitlink), "160000");
578+ }
579+
580+ // Ids below come from git itself (`git mktree --missing`,
581+ // `git hash-object`, `git commit-tree`) in a scratch repository.
582+ const EMPTY: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
583+ const HELLO: &str = "ce013625030ba8dba906f756967f9e9ca394464a";
584+ const SUBMODULE: &str = "1111111111111111111111111111111111111111";
585+
586+ #[test]
587+ fn entries_sort_as_git_sorts_them() {
588+ // `a` as a directory sorts after `a.b` and `a-c` but before `a0`:
589+ // it compares as `a/`.
590+ let entries = vec![
591+ entry("a0", EntryKind::Exec, HELLO),
592+ entry("a", EntryKind::Tree, "4b825dc642cb6eb9a060e54bf8d69288fbee4904"),
593+ entry("a.b", EntryKind::Blob, EMPTY),
594+ entry("a-c", EntryKind::Symlink, HELLO),
595+ entry("vendor", EntryKind::Gitlink, SUBMODULE),
596+ ];
597+ let id = object_id(ObjectKind::Tree, &encode_entries(&entries));
598+ assert_eq!(id, "59710ac869a643ad7e179b924af6fb3009b84859");
599+ }
600+
601+ #[test]
602+ fn the_empty_tree_is_gits() {
603+ assert_eq!(
604+ object_id(ObjectKind::Tree, &encode_entries(&[])),
605+ "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
606+ );
607+ }
608+
609+ /// A base with `README.md`, `bin/run` (executable), `docs/old.md`,
610+ /// `src/lib.rs` and a submodule `vendor/dep`.
611+ fn base() -> (String, HashMap<String, Vec<TreeEntry>>) {
612+ let mut trees = HashMap::new();
613+ let bin = vec![entry("run", EntryKind::Exec, HELLO)];
614+ let docs = vec![entry("old.md", EntryKind::Blob, HELLO)];
615+ let src = vec![entry("lib.rs", EntryKind::Blob, EMPTY)];
616+ let vendor = vec![entry("dep", EntryKind::Gitlink, SUBMODULE)];
617+ let mut id = |entries: Vec<TreeEntry>| {
618+ let id = object_id(ObjectKind::Tree, &encode_entries(&entries));
619+ trees.insert(id.clone(), entries);
620+ id
621+ };
622+ let root = vec![
623+ entry("README.md", EntryKind::Blob, HELLO),
624+ entry("bin", EntryKind::Tree, &id(bin)),
625+ entry("docs", EntryKind::Tree, &id(docs)),
626+ entry("src", EntryKind::Tree, &id(src)),
627+ entry("vendor", EntryKind::Tree, &id(vendor)),
628+ ];
629+ let root = id(root);
630+ (root, trees)
631+ }
632+
633+ #[test]
634+ fn the_base_tree_matches_git() {
635+ assert_eq!(base().0, "cdbaeedd6c31387975e65e67f9d453589d3c73d1");
636+ }
637+
638+ #[test]
639+ fn changes_are_applied_to_the_base() {
640+ let (root, trees) = base();
641+ let changes = vec![
642+ // Added in a new directory.
643+ Change { path: "src/net/http.rs".into(), entry: Some((EntryKind::Blob, HELLO.into())) },
644+ // Made executable: the same blob.
645+ Change { path: "src/lib.rs".into(), entry: Some((EntryKind::Exec, EMPTY.into())) },
646+ // Deleted, leaving its directory empty.
647+ Change { path: "docs/old.md".into(), entry: None },
648+ // A symlink added at the top.
649+ Change { path: "latest".into(), entry: Some((EntryKind::Symlink, HELLO.into())) },
650+ ];
651+ let merged = merge_tree(&root, &trees, &changes).unwrap();
652+ assert_eq!(merged.tree, "3097aeb8d4a86f097eca63da84e432ebeb456158");
653+ // The root, src and src/net; docs is gone, bin and vendor untouched.
654+ assert_eq!(merged.objects.len(), 3);
655+ }
656+
657+ #[test]
658+ fn a_file_can_replace_a_directory_the_pull_request_emptied() {
659+ let (root, trees) = base();
660+ let changes = vec![
661+ Change { path: "docs".into(), entry: Some((EntryKind::Blob, HELLO.into())) },
662+ Change { path: "docs/old.md".into(), entry: None },
663+ ];
664+ let merged = merge_tree(&root, &trees, &changes).unwrap();
665+ assert_eq!(merged.tree, "088fc1bb6d44a71fcd0c33d4022e7bb1628b7202");
666+ }
667+
668+ #[test]
669+ fn a_tree_that_cannot_be_written_back_is_refused() {
670+ let (root, mut trees) = base();
671+ // Entries that do not hash to the id they are filed under, as a
672+ // tree with a mode g1t does not know would not.
673+ let entries = trees.remove(&root).unwrap();
674+ trees.insert(root.clone(), entries[1..].to_vec());
675+ let changes = vec![Change { path: "x".into(), entry: Some((EntryKind::Blob, HELLO.into())) }];
676+ assert!(merge_tree(&root, &trees, &changes).is_err());
677+ }
678+
679+ #[test]
680+ fn a_file_is_not_put_where_a_directory_still_is() {
681+ let (root, trees) = base();
682+ let changes = vec![Change { path: "src".into(), entry: Some((EntryKind::Blob, HELLO.into())) }];
683+ assert!(merge_tree(&root, &trees, &changes).is_err());
684+ }
685+
686+ #[test]
687+ fn the_merge_commit_matches_git() {
688+ let commit = commit_object(
689+ "cdbaeedd6c31387975e65e67f9d453589d3c73d1",
690+ &["74257edb70e8dc5d2f31af4b76fe898608f829da", "110bda84f45f58e44d3699be9efe91276381b43d"],
691+ &Signature { name: "octo", email: "octo@users.g1t.sh", seconds: 1_700_000_000 },
692+ &merge_message("main", "feature", 7),
693+ );
694+ assert_eq!(object_id(ObjectKind::Commit, &commit), "f8d35e9d454b11a179215ee1283ac71b7216f024");
695+ }
696+
697+ #[test]
698+ fn a_fork_on_the_default_branch_is_named_by_number() {
699+ assert_eq!(merge_message("main", "main", 12), "Merge main into pull request #12");
700+ assert_eq!(merge_message("main", "fix-login", 12), "Merge main into fix-login");
701+ }
702+
703+ #[test]
704+ fn a_pack_of_the_merge_reads_back() {
705+ let (root, trees) = base();
706+ let changes = vec![Change { path: "src/new.rs".into(), entry: Some((EntryKind::Blob, HELLO.into())) }];
707+ let merged = merge_tree(&root, &trees, &changes).unwrap();
708+ let mut objects: Vec<(ObjectKind, Vec<u8>)> =
709+ merged.objects.iter().map(|bytes| (ObjectKind::Tree, bytes.clone())).collect();
710+ let pack = write_pack(&objects);
711+ let read = g1t_scan::pack::Pack::parse(&pack).unwrap();
712+ assert!(read.tree(&merged.tree).is_some());
713+
714+ // Extended with a commit, it still reads, and its checksum holds.
715+ let commit = commit_object(
716+ &merged.tree,
717+ &[&root],
718+ &Signature { name: "octo", email: "octo@users.g1t.sh", seconds: 1 },
719+ "m",
720+ );
721+ let commit_id = object_id(ObjectKind::Commit, &commit);
722+ objects.clear();
723+ objects.push((ObjectKind::Commit, commit));
724+ let extended = extend_pack(&pack, &objects).unwrap();
725+ let read = g1t_scan::pack::Pack::parse(&extended).unwrap();
726+ assert!(read.tree(&merged.tree).is_some());
727+ assert_eq!(read.commits(), [commit_id]);
728+ }
729+}
+2−1
7171 let names: std::collections::BTreeSet<&String> = old.keys().chain(new.keys()).collect();
7272 for name in names {
7373 let (before, after) = (old.get(name), new.get(name));
74− if before.map(|e| &e.hash) == after.map(|e| &e.hash) {
74+ // A file made executable keeps its hash, and is still a change.
75+ if before.map(|e| (&e.hash, e.kind)) == after.map(|e| (&e.hash, e.kind)) {
7576 continue;
7677 }
7778 let path = format!("{prefix}{name}");
+1−1
6767
6868 /// A pack holding everything reachable from `want` that is not reachable
6969 /// from `have`.
70−async fn fetch_pack(source: &GitAccess, want: &str, have: Option<&str>) -> Result<Vec<u8>> {
70+pub(crate) async fn fetch_pack(source: &GitAccess, want: &str, have: Option<&str>) -> Result<Vec<u8>> {
7171 // Side-band framing puts the pack in its own channel, so its exact bytes
7272 // can be recovered. Without it the response ends in a stray flush packet
7373 // that a receiver rejects as junk after the pack.
+2−0
66 //! request is treated as git's smart HTTP protocol.
77
88 mod blame;
9+mod catch_up;
910 mod diff;
1011 mod git_http;
1112 mod import;
11611162 "behind" => reply(&repos.behind(args(body)?).await?),
11621163 "divergence" => reply(&repos.divergence(args(body)?).await?),
11631164 "land" => reply(&repos.land(args(body)?).await?),
1165+ "update_pull_branch" => reply(&repos.update_pull_branch(args(body)?).await?),
11641166 "delete_branch" => reply(&repos.delete_branch(args(body)?).await?),
11651167 "compare" => reply(&repos.compare(args(body)?).await?),
11661168 "scan_history" => reply(&repos.scan_history(args(body)?).await?),
+2−2
8383 impl<S: GitStore> Repos<S> {
8484 /// Where a git request's entry belongs: the repository a fork came
8585 /// from, so that a pull request's pushes are in its workspace's log.
86− async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> {
86+ pub(crate) async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> {
8787 let mut repo = path.clone();
8888 if let Some(found) = self.registry.by_path(path).await?
8989 && let Some(source) = found.fork_of.as_deref()
101101 })
102102 }
103103
104− async fn record_git(&self, entry: NewAuditEntry) {
104+ pub(crate) async fn record_git(&self, entry: NewAuditEntry) {
105105 let recorded: Result<u32> = g1t_kit::call(
106106 &self.events,
107107 "audit_record",
+59−1
2525 CommentCreated, Event, IssueEvent, NewEvent, Publish, PullEvent, SessionAppended,
2626 };
2727 use g1t_contracts::identity::UsernameArgs;
28−use g1t_contracts::repos::{ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, Repo, RepoPath};
28+use g1t_contracts::repos::{
29+ ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, Repo, RepoPath,
30+ UpdatePullBranchArgs,
31+};
2932 use g1t_contracts::time::rfc3339;
3033 use g1t_contracts::work::*;
3134 use futures_util::future::{try_join, try_join3, try_join_all};
11461149 Ok(Outcome::Ok(pull))
11471150 }
11481151
1152+ /// Brings a pull request up to date with the default branch without a
1153+ /// sandbox, where the repos service can do that safely. Whoever could
1154+ /// have pushed the merge themselves may ask: whoever opened it, for a
1155+ /// fork; any member, for a branch of the repository. When it needs a
1156+ /// real merge, says so, naming the conflicting files if a probe found
1157+ /// them, and pushes nothing.
1158+ async fn catch_up_pull(&self, a: PullActionArgs) -> Result<Outcome<PullBranchUpdate>> {
1159+ let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
1160+ if !pull.status.is_active() {
1161+ return Ok(Outcome::fail(
1162+ FailureCode::Conflict,
1163+ format!("This pull request is already {}.", pull.status.as_str()),
1164+ ));
1165+ }
1166+ let allowed = if pull.fork_repo_id.is_some() {
1167+ pull.author.id == a.actor.id
1168+ } else {
1169+ a.actor.is_member(&repo.namespace)
1170+ };
1171+ if !allowed {
1172+ return Ok(Outcome::fail(
1173+ FailureCode::Forbidden,
1174+ if pull.fork_repo_id.is_some() {
1175+ "Only whoever opened this pull request can update it."
1176+ } else {
1177+ "Only members of the workspace can update this pull request."
1178+ },
1179+ ));
1180+ }
1181+ let updated: Outcome<PullBranchUpdate> = g1t_kit::call(
1182+ &self.repos,
1183+ "update_pull_branch",
1184+ &UpdatePullBranchArgs {
1185+ source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
1186+ branch: pull.branch.clone(),
1187+ number: pull.number,
1188+ actor: a.actor,
1189+ },
1190+ )
1191+ .await?;
1192+ // A probe that found conflicts says more than "both changed it".
1193+ if let Outcome::Ok(PullBranchUpdate::NeedsAgent { .. }) = &updated
1194+ && let Some(files) = self.conflicting_files(&pull).await?
1195+ && !files.is_empty()
1196+ {
1197+ return Ok(Outcome::Ok(PullBranchUpdate::NeedsAgent {
1198+ reason: NeedsAgentReason::Conflicting,
1199+ detail: "Merging it conflicts.".to_owned(),
1200+ paths: files,
1201+ }));
1202+ }
1203+ Ok(updated)
1204+ }
1205+
11491206 async fn update_pull(&self, a: UpdatePullArgs) -> Result<Outcome<Pull>> {
11501207 let pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
11511208 let assignees = match a.assignees {
18591916 "list_pulls" => reply(&work.list_pulls(args(body)?).await?),
18601917 "get_pull" => reply(&work.get_pull(args(body)?).await?),
18611918 "update_pull" => reply(&work.update_pull(args(body)?).await?),
1919+ "catch_up_pull" => reply(&work.catch_up_pull(args(body)?).await?),
18621920 "ready_pull" => reply(&work.ready_pull(args(body)?).await?),
18631921 "close_pull" => reply(&work.close_pull(args(body)?).await?),
18641922 "merge_pull" => reply(&work.merge_pull(args(body)?).await?),