Commit

Pull requests from branches

A pull request can now come from a branch pushed to the repository, the way engineers already work, as well as from a fork made for it, the way agents work. - repos: list branches by reading git's ref advertisement, since the storage binding does not list refs; compare a branch against the point where it left the default branch; land a branch with the same fast-forward and behind check as a fork - work: a pull request has a fork or a branch; one opened from a branch is ready for review at once, a branch has one open pull request at a time, and pushes to the branch move its head - api and mcp: create_pull_request takes branch and body - site: New pull request page with a branch picker; pull request pages show the branch - the work schema is still one migration; production was reset - docs, llms.txt, README and plan updated

syntaqxcommitted Parent0d8bd3aBrowse files
27 files+730−1460/27 viewed
+4−4
2626 - Public and private repositories, and git over HTTPS, including creating a
2727 repository by pushing to it.
2828 - Issues with labels, acceptance checks and comments.
29−- Pull requests, each in a copy-on-write fork, with a diff and a recorded
30− agent session. Several can be made for one issue.
29+- Pull requests with a diff and a recorded agent session: in a
30+ copy-on-write fork, which is how agents work, or from a branch pushed to
31+ the repository. Several can be made for one issue.
3132 - Merging: lands a pull request on `main`, closes its issue naming the pull
3233 request that resolved it, and closes the others for that issue as
3334 superseded. Refused when the pull request is behind, so no commit is lost.
3738 - An event bus: every state change is published, logged and delivered to
3839 subscribers.
3940
40−Not built yet: pull requests from branches, server-side merge commits,
41−review comments on lines, running acceptance checks, git over SSH. See the build order in the plan.
41+Not built yet: server-side merge commits, review comments on lines, running acceptance checks, git over SSH. See the build order in the plan.
4242
4343 ## Try it
4444
+20−4
1010 type WorkApi,
1111 fail,
1212 ok,
13+ pullComparison,
1314 } from "@g1t/contracts";
1415
1516 export interface ApiEnv {
335336 {
336337 name: "create_pull_request",
337338 description:
338− "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one.",
339+ "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once.",
339340 input: {
340341 type: "object",
341342 properties: {
345346 type: "string",
346347 description: "Defaults to the issue's title. Required when there is no issue.",
347348 },
349+ branch: {
350+ type: "string",
351+ description:
352+ "A branch already pushed to the repository that holds the change. Leave out to get a fork.",
353+ },
354+ body: {
355+ type: "string",
356+ description: "Markdown: what changed and why. Mainly for pull requests from a branch.",
357+ },
348358 agent: {
349359 type: "string",
350360 description: 'A label for the agent doing the work, e.g. "claude-code".',
356366 const opened = await env.WORK.openPull(user, path, {
357367 issue: input.issue == null ? undefined : Number(input.issue),
358368 title: text(input, "title"),
369+ body: text(input, "body"),
370+ branch: text(input, "branch") || undefined,
359371 agent: text(input, "agent") || "agent",
360372 runtime: "external",
361373 });
363375 const { fork } = opened.value;
364376 return ok({
365377 pull: opened.value,
378+ // Where to push. A pull request from a branch has no fork: push to
379+ // that branch of the repository.
366380 git: {
367− remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
381+ remote: fork
382+ ? `https://g1t.sh/${fork.namespace}/${fork.name}.git`
383+ : `https://g1t.sh/${path.namespace}/${path.name}.git`,
368384 username: user.username,
369385 password: "your g1t access token",
370386 },
453469 run: onRepo(async (env, path, viewer, input) => {
454470 const found = await env.WORK.getPull(path, Number(input.number), viewer);
455471 if (!found.ok) return found;
456− const { forkRepoId, mergeBase } = found.value.pull;
457− return env.REPOS.compare(forkRepoId, viewer, mergeBase);
472+ const { repoId, base, head } = pullComparison(found.value.pull);
473+ return env.REPOS.compare(repoId, viewer, base, head);
458474 }),
459475 },
460476 {
+16−5
33 description: Why a pull request on g1t gets its own fork, when a branch is the better choice, and what each costs.
44 ---
55
6−On most forges, a pull request comes from a branch of the repository. On
7−g1t, a pull request comes from a **fork**: a separate repository that starts
8−as a copy of yours. This page explains why, and what it costs.
6+On most forges, a pull request comes from a branch of the repository. g1t
7+has those too. But an agent's pull request comes from a **fork**: a separate
8+repository that starts as a copy of yours. This page explains why, what it
9+costs, and when to use which.
910
1011 ## The short version
1112
8081 already has write access. Nothing about isolation or scale is at stake, and
8182 a branch is the tool you already know.
8283
83−You can push branches to a g1t repository today. Opening a pull request
84−*from* one is being built; until then, every pull request has a fork.
84+Push the branch, open **Pull requests**, choose **New pull request** and
85+pick it. Or from the API, send `branch` when creating the pull request:
86+
87+```sh
88+git push origin my-change
89+curl -X POST https://api.g1t.sh/v1/repos/<workspace>/<repo>/pulls \
90+ -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
91+ -d '{"branch": "my-change", "title": "My change", "issue": 12}'
92+```
93+
94+A branch can have one open pull request at a time. Pushing to the branch
95+updates it.
8596
8697 ## How this adds up
8798
+17−11
1111 | | What it is |
1212 | --- | --- |
1313 | **Issue** | What should change: a bug, a feature, a question. |
14−| **Pull request** | A proposed change, in its own fork. Usually made for an issue. |
14+| **Pull request** | A proposed change, in its own fork or on a branch. Usually made for an issue. |
1515 | **Session** | The record of how a pull request was made: prompts, reasoning, tool calls. |
1616
1717 The part that is different from other forges: one issue routinely has
4949
5050 ## Pull requests
5151
52−A pull request is a proposed change. Opening one creates a **fork**: a
52+A pull request is a proposed change. There are two ways to make one.
53+
54+**In a fork.** This is how agents work. Opening the pull request creates a
5355 copy-on-write copy of the repository that belongs to that pull request
5456 alone. Its author clones the fork, commits and pushes to it. Nothing they do
55−can touch `main` or another pull request. [Forks and branches](/concepts/forks/)
56−explains why.
57+can touch `main` or another pull request. The fork lives at
58+`g1t.sh/pulls/<pull request id>.git` and is exactly as visible as the
59+repository it came from. The pull request starts as a draft.
60+
61+**From a branch.** This is the way you already know. Push a branch to the
62+repository, then open a pull request from it on the **Pull requests** tab.
63+It needs write access to the repository, and it is ready for review as soon
64+as it is opened.
5765
58−A pull request's fork lives at `g1t.sh/pulls/<pull request id>.git`. It is
59−exactly as visible as the repository it came from.
66+[Forks and branches](/concepts/forks/) explains when each is the better
67+choice.
6068
6169 | Status | Meaning |
6270 | --- | --- |
63−| `draft` | Still being worked on. Every pull request starts here. |
71+| `draft` | Still being worked on. A pull request with a fork starts here. |
6472 | `open` | Ready for review, with a description of what changed and why. |
6573 | `merged` | Landed on `main`. |
6674 | `closed` | Closed without merging. |
96104
97105 A pull request can only merge if it contains everything already on `main`.
98106 If something else landed first, merging is refused and the pull request is
99−**behind**. Its author pulls `main` into the fork, resolves any conflict,
100−pushes, and merges again. `main` never loses a commit this way, however many
107+**behind**. Its author pulls `main` into the fork or the branch, resolves any
108+conflict, pushes, and merges again. `main` never loses a commit this way, however many
101109 pull requests are in flight.
102110
103111 ## Sessions
126134 - **Merging in g1t.** Merging moves `main` forward to the pull request's
127135 head. When `main` has moved, the pull request has to pull it in first; g1t
128136 does not create merge commits or rebase for you yet.
129−- **Pull requests from branches.** Opening a pull request from a branch you
130− pushed to the repository itself. Today every pull request has a fork.
131137 - **Review comments on lines.** Comments are on the pull request as a whole.
132138 - **Checks.** Running an issue's acceptance checks automatically.
133139 - **Assignees and milestones.**
+1−1
6969 | `add_comment` | Comment on an issue or a pull request. |
7070 | `list_pull_requests` | Pull requests on a repository, open or closed. |
7171 | `get_pull_request` | A pull request's status, head commit, comments and issue. |
72−| `create_pull_request` | Open a draft pull request; creates a fork. |
72+| `create_pull_request` | Open a draft pull request with a fork, or one from a branch already pushed. |
7373 | `record_session` | Append prompts, messages and tool calls to the session. |
7474 | `read_session` | Read a pull request's recorded session. |
7575 | `mark_pull_request_ready` | Mark a draft ready for review, with a summary. |
+12−1
4545 else it looks
4646 exactly like a repository that does not exist, both on the site and to git.
4747
48+## Branches
49+
50+Push any branch to a repository you can write to, and open a
51+[pull request](/concepts/overview/#pull-requests) from it on the
52+repository's **Pull requests** tab.
53+
54+```sh
55+git switch -c my-change
56+git push origin my-change
57+```
58+
4859 ## Pull request forks
4960
50−Each [pull request](/concepts/overview/#pull-requests) has its own remote:
61+A pull request that was not opened from a branch has its own remote:
5162
5263 ```text
5364 https://g1t.sh/pulls/<pull request id>.git
+12−1
120120 | Method | Path | |
121121 | --- | --- | --- |
122122 | `GET` | `/v1/repos/{owner}/{name}/pulls?state=` | Pull requests, newest first. |
123−| `POST` | `/v1/repos/{owner}/{name}/pulls` | Open a draft. Body: `issue`, `title`, `agent`. |
123+| `POST` | `/v1/repos/{owner}/{name}/pulls` | Open one. Body: `issue`, `title`, `agent`, and for a branch `branch`, `body`. |
124124 | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}` | A pull request, its comments and its issue. |
125125 | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/changes` | The files it changes, with diffs. |
126126 | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/ready` | Mark ready for review. Body: `summary`. |
139139 `title` defaults to the issue's title, and is required when there is no
140140 `issue`.
141141
142+Send `branch` to open the pull request from a branch already pushed to the
143+repository. No fork is made, `git.remote` is the repository itself, and the
144+pull request is `open` at once:
145+
146+```json
147+{
148+ "pull": { "number": 15, "status": "open", "branch": "my-change", "fork": null },
149+ "git": { "remote": "https://g1t.sh/syntaqx/hello.git" }
150+}
151+```
152+
142153 Merging a pull request made for an issue closes the issue and records the
143154 pull request in the issue's `resolvedBy`. Other pull requests for that issue
144155 that are still a draft or open are closed with `supersededBy` set. Send
+1−0
2525 route("issues/new", "routes/repo/issue-new.tsx"),
2626 route("issues/:number", "routes/repo/issue.tsx"),
2727 route("pulls", "routes/repo/pulls.tsx"),
28+ route("pulls/new", "routes/repo/pull-new.tsx"),
2829 route("pull/:number", "routes/repo/pull.tsx"),
2930 ]),
3031 ] satisfies RouteConfig;
+10−0
341341 <div className="*:w-full">
342342 <Button type="submit">Open pull request</Button>
343343 </div>
344+ <p className="text-xs text-muted">
345+ Already pushed a branch?{" "}
346+ <Link
347+ to={`${base}/pulls/new?issue=${issue.number}`}
348+ className="text-fg underline underline-offset-4"
349+ >
350+ Open a pull request from it
351+ </Link>
352+ .
353+ </p>
344354 </Form>
345355 ) : (
346356 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
+126−0
1+import { GitBranch } from "lucide-react";
2+import { Form, redirect } from "react-router";
3+
4+import type { Route } from "./+types/pull-new";
5+import {
6+ Button,
7+ CopyLine,
8+ EmptyState,
9+ ErrorText,
10+ Field,
11+ Input,
12+ Textarea,
13+} from "../../components/ui";
14+import { repos, work } from "../../lib/services.server";
15+import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
16+
17+export function meta({ params }: Route.MetaArgs) {
18+ return [{ title: `New pull request · ${params.owner}/${params.repo} · g1t` }];
19+}
20+
21+export async function loader({ request, params, context }: Route.LoaderArgs) {
22+ const user = requireUser(context, request);
23+ const path = { namespace: params.owner, name: params.repo };
24+ const [repo, branches, open] = await Promise.all([
25+ repos.get(path, user),
26+ repos.branches(path, user),
27+ work.listPulls(path, user, "open"),
28+ ]);
29+ const { defaultBranch } = unwrap(repo);
30+ const all = unwrap(branches);
31+ const mainHead = all.find((branch) => branch.name === defaultBranch)?.hash;
32+ // A branch that already has an open pull request cannot have another.
33+ const taken = new Set(open.ok ? open.value.map((pull) => pull.branch) : []);
34+ const query = new URL(request.url).searchParams;
35+ return {
36+ defaultBranch,
37+ branches: all
38+ // A branch at the same commit as the default branch has nothing to merge.
39+ .filter((branch) => branch.name !== defaultBranch && branch.hash !== mainHead)
40+ .map((branch) => branch.name)
41+ .filter((name) => !taken.has(name)),
42+ selected: query.get("branch") ?? "",
43+ issue: query.get("issue") ?? "",
44+ };
45+}
46+
47+export async function action({ request, params, context }: Route.ActionArgs) {
48+ assertSameOrigin(request);
49+ const user = requireUser(context, request);
50+ const form = await request.formData();
51+ const issue = Number(form.get("issue"));
52+ const result = await work.openPull(
53+ user,
54+ { namespace: params.owner, name: params.repo },
55+ {
56+ branch: String(form.get("branch") ?? ""),
57+ title: String(form.get("title") ?? ""),
58+ body: String(form.get("body") ?? ""),
59+ issue: issue > 0 ? issue : undefined,
60+ agent: user.username,
61+ runtime: "external",
62+ },
63+ );
64+ if (!result.ok) return { error: result.error.message };
65+ throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
66+}
67+
68+export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) {
69+ const { defaultBranch, branches, selected, issue } = loaderData;
70+ const remote = `https://g1t.sh/${params.owner}/${params.repo}.git`;
71+
72+ if (branches.length === 0) {
73+ return (
74+ <div className="max-w-2xl">
75+ <EmptyState title="No branch to open a pull request from">
76+ Push a branch to this repository, then come back.
77+ </EmptyState>
78+ <div className="mt-4 space-y-2">
79+ <CopyLine prompt text="git switch -c my-change" />
80+ <CopyLine prompt text={`git push ${remote} my-change`} />
81+ </div>
82+ <p className="mt-4 text-sm text-muted">
83+ Agents do not need a branch. Open an issue and assign agents to it,
84+ and each gets a pull request with its own fork.
85+ </p>
86+ </div>
87+ );
88+ }
89+
90+ return (
91+ <Form method="post" className="max-w-2xl space-y-4">
92+ <div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
93+ <GitBranch size={15} className="text-faint" />
94+ <span className="text-muted">Merge</span>
95+ <select
96+ name="branch"
97+ defaultValue={branches.includes(selected) ? selected : branches[0]}
98+ aria-label="Branch to merge"
99+ className="rounded-md border border-line bg-bg px-2 py-1 font-mono text-sm"
100+ >
101+ {branches.map((name) => (
102+ <option key={name} value={name}>
103+ {name}
104+ </option>
105+ ))}
106+ </select>
107+ <span className="text-muted">into</span>
108+ <span className="font-mono">{defaultBranch}</span>
109+ </div>
110+ <Field label="Title">
111+ <Input name="title" required autoFocus maxLength={200} />
112+ </Field>
113+ <Field label="Description" hint="What changed and why. Markdown works.">
114+ <Textarea name="body" rows={8} />
115+ </Field>
116+ <Field
117+ label="Issue (optional)"
118+ hint="The number of the issue this resolves. Merging the pull request closes it."
119+ >
120+ <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" />
121+ </Field>
122+ <ErrorText>{actionData?.error}</ErrorText>
123+ <Button type="submit">Open pull request</Button>
124+ </Form>
125+ );
126+}
+46−16
22 Bot,
33 ChevronRight,
44 FileDiff,
5+ GitBranch,
56 GitCommitHorizontal,
67 GitMerge,
78 MessageSquare,
1314 import { useEffect } from "react";
1415 import { Form, Link, redirect, useRevalidator } from "react-router";
1516
16−import type { Comparison, SessionEntry } from "@g1t/contracts";
17+import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
1718
1819 import type { Route } from "./+types/pull";
1920 import { DiffView } from "../../components/diff-view";
6162 throw new Response("Pull request not found.", { status: 404 });
6263 }
6364 const { pull } = found.value;
65+ const range = pullComparison(pull);
6466 const [session, comparison] = await Promise.all([
6567 tab === "session" ? work.readSession(path, number, viewer) : null,
66− tab === "changes" ? repos.compare(pull.forkRepoId, viewer, pull.mergeBase) : null,
68+ tab === "changes"
69+ ? repos.compare(range.repoId, viewer, range.base, range.head)
70+ : null,
6771 ]);
6872 const member = (viewer?.workspaces ?? []).some(
6973 (membership) => membership.slug === params.owner,
215219 } = loaderData;
216220 const base = `/${params.owner}/${params.repo}`;
217221 const here = `${base}/pull/${pull.number}`;
218− const remote = `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`;
222+ const remote = pull.fork
223+ ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
224+ : `https://g1t.sh/${params.owner}/${params.repo}.git`;
219225 const active = pull.status === "draft" || pull.status === "open";
220226
221227 // Follow an agent at work without a manual reload.
241247 <Avatar name={pull.author.username} size={18} />
242248 <span>
243249 <span className="font-medium text-fg">{pull.author.username}</span>{" "}
244− {pull.status === "merged" ? "merged" : "wants to merge"} into{" "}
245− <span className="font-mono text-fg">{defaultBranch}</span>
250+ {pull.status === "merged" ? "merged" : "wants to merge"}
251+ {pull.branch && (
252+ <>
253+ {" "}
254+ <span className="font-mono text-fg">{pull.branch}</span>
255+ </>
256+ )}{" "}
257+ into <span className="font-mono text-fg">{defaultBranch}</span>
246258 </span>
247259 </span>
248− <span className="flex items-center gap-1.5 font-mono text-xs">
249− <Bot size={14} />
250− {pull.agent}
251− {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
252− </span>
260+ {/* A pull request from a branch was made by its author, not an agent. */}
261+ {!pull.branch && (
262+ <span className="flex items-center gap-1.5 font-mono text-xs">
263+ <Bot size={14} />
264+ {pull.agent}
265+ {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
266+ </span>
267+ )}
253268 </div>
254269
255270 {issue && (
407422
408423 <section>
409424 <h3 className="text-sm font-medium">Working copy</h3>
410− <p className="mt-1 text-xs text-muted">
411− This fork belongs to the pull request. Pushes to it show up here.
412− </p>
413− <div className="mt-2">
414− <CopyLine text={`git clone ${remote}`} />
415− </div>
425+ {pull.branch ? (
426+ <>
427+ <p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
428+ <GitBranch size={13} />
429+ Branch <span className="font-mono text-fg">{pull.branch}</span> of this
430+ repository. Pushes to it show up here.
431+ </p>
432+ <div className="mt-2">
433+ <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
434+ </div>
435+ </>
436+ ) : (
437+ <>
438+ <p className="mt-1 text-xs text-muted">
439+ This fork belongs to the pull request. Pushes to it show up here.
440+ </p>
441+ <div className="mt-2">
442+ <CopyLine text={`git clone ${remote}`} />
443+ </div>
444+ </>
445+ )}
416446 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
417447 <GitCommitHorizontal size={13} />
418448 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
+17−7
1−import { Bot } from "lucide-react";
1+import { Bot, GitBranch, Plus } from "lucide-react";
22 import { Link } from "react-router";
33
44 import type { Route } from "./+types/pulls";
5−import { EmptyState, TimeAgo } from "../../components/ui";
5+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
66 import { PullIcon, StateTabs } from "../../components/work";
77 import { work } from "../../lib/services.server";
88 import { getViewer, unwrap } from "../../lib/session.server";
2626 const base = `/${params.owner}/${params.repo}`;
2727 return (
2828 <div>
29− <StateTabs to={`${base}/pulls`} state={state} />
29+ <StateTabs
30+ to={`${base}/pulls`}
31+ state={state}
32+ action={
33+ <ButtonLink to={`${base}/pulls/new`}>
34+ <Plus size={15} />
35+ New pull request
36+ </ButtonLink>
37+ }
38+ />
3039 <div className="mt-4">
3140 {pulls.length === 0 ? (
3241 <EmptyState
3342 title={state === "open" ? "No open pull requests" : "No closed pull requests"}
3443 >
35− A pull request is a change proposed from its own fork. Open one
36− from an issue, or have an agent do it.
44+ A pull request proposes a change. Assign agents to an issue and
45+ each opens one in its own fork, or push a branch and open one
46+ yourself.
3747 </EmptyState>
3848 ) : (
3949 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
6373 </span>
6474 </span>
6575 <span className="mt-0.5 flex shrink-0 items-center gap-1 font-mono text-xs text-muted">
66− <Bot size={13} />
67− {pull.agent}
76+ {pull.branch ? <GitBranch size={13} /> : <Bot size={13} />}
77+ {pull.branch ?? pull.agent}
6878 </span>
6979 </Link>
7080 </li>
+5−3
101101 `agent` (a label such as `claude-code`). Without an issue, send `title`.
102102 The response has `pull.number` and `git.remote`, the pull request's own
103103 fork. Clone it, commit, and push to it with the token. It starts as a
104− draft.
104+ draft. If the change is already on a branch pushed to the repository,
105+ send `branch` (and `title`, `body`) instead: no fork is made and the pull
106+ request is ready at once.
105107 - **Record the session** as you work, so people can see why a change was
106108 made: `POST {repo}/pulls/{number}/session` with
107109 `{"entries": [{"kind": "message", "text": "…"}]}`. Kinds are `prompt`,
145147 - OAuth 2.1 for applications: metadata at
146148 `https://api.g1t.sh/.well-known/oauth-authorization-server`; authorization
147149 code with PKCE (S256), public clients, dynamic registration.
148−- Not available yet: pull requests from branches, merge commits made on
149− the server, running checks automatically.
150+- Not available yet: merge commits made on the server, running checks
151+ automatically.
150152
151153 ## More
152154
+43−8
218218 pub service: GitService,
219219 }
220220
221−/// `land`: moves the default branch of the repo a fork came from to the
222−/// fork's head. Refused with `conflict` when the fork is behind, since
223−/// that would discard commits. Returns `Outcome<Landed>`.
221+/// `land`: moves a repository's default branch to the head of a pull
222+/// request's source. Refused with `conflict` when the source is behind,
223+/// since that would discard commits. Returns `Outcome<Landed>`.
224224 #[derive(Debug, Serialize, Deserialize)]
225225 #[serde(rename_all = "camelCase")]
226226 pub struct LandArgs {
227− pub fork_id: String,
227+ /// The repository holding the commits: a pull request's fork, or the
228+ /// target itself when landing one of its own branches.
229+ pub source_id: String,
230+ /// The branch of the source to land. Required when the source is the
231+ /// target; a fork lands its default branch.
232+ #[serde(default)]
233+ pub branch: Option<String>,
228234 pub actor: User,
229235 }
230236
283289 pub truncated: bool,
284290 }
285291
286−/// `compare`: what a repository's head changes.
292+/// `compare`: what `head` changes relative to `base`.
287293 ///
288−/// With no `base`, a pull request's fork is compared against the point where it
289−/// and the repository it came from last agreed, and any other repository
290−/// against its head's parent. Returns `Outcome<Comparison>`.
294+/// `head` is a branch or a commit, and defaults to the default branch.
295+/// With no `base`, a fork is compared against the point where it and the
296+/// repository it came from last agreed; a branch against the point where it
297+/// left the default branch; and the default branch against its head's
298+/// parent. Returns `Outcome<Comparison>`.
291299 #[derive(Debug, Serialize, Deserialize)]
292300 #[serde(rename_all = "camelCase")]
293301 pub struct CompareArgs {
295303 pub viewer: Viewer,
296304 #[serde(default)]
297305 pub base: Option<String>,
306+ #[serde(default)]
307+ pub head: Option<String>,
308+}
309+
310+/// A branch and the commit it points to.
311+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
312+pub struct Branch {
313+ pub name: String,
314+ pub hash: String,
315+}
316+
317+/// `branches`: the repository's branches, default branch first.
318+/// Returns `Outcome<Vec<Branch>>`.
319+#[derive(Debug, Serialize, Deserialize)]
320+pub struct BranchesArgs {
321+ pub path: RepoPath,
322+ pub viewer: Viewer,
323+}
324+
325+/// `head`: the commit a branch points to, or null. For services reacting
326+/// to a push, which have no viewer; it reveals nothing but a commit hash.
327+/// Returns `Option<String>`.
328+#[derive(Debug, Serialize, Deserialize)]
329+#[serde(rename_all = "camelCase")]
330+pub struct HeadArgs {
331+ pub repo_id: String,
332+ pub branch: String,
298333 }
+17−5
112112 External,
113113 }
114114
115−/// A proposed change, made in its own fork by an agent or a person.
115+/// A proposed change. It is made either in a fork created for it, which is
116+/// how agents work, or on a branch pushed to the repository itself.
116117 #[derive(Clone, Debug, Serialize, Deserialize)]
117118 #[serde(rename_all = "camelCase")]
118119 pub struct Pull {
129130 pub agent: String,
130131 pub runtime: Runtime,
131132 pub status: PullStatus,
132− pub fork: RepoPath,
133+ /// The fork holding the change, unless it is on a branch.
134+ pub fork: Option<RepoPath>,
133135 /// The fork's repository id.
134− pub fork_repo_id: String,
136+ pub fork_repo_id: Option<String>,
137+ /// The branch of the repository holding the change, unless it is in a
138+ /// fork.
139+ pub branch: Option<String>,
135140 pub head_commit: Option<String>,
136141 /// For a merged pull request, what the branch pointed to before the
137142 /// merge. Comparing against it shows what the pull request changed.
304309 pub body: String,
305310 }
306311
307−/// `open_pull`: forks the repo and returns the draft pull request to push
308−/// to. Returns `Outcome<Pull>`.
312+/// `open_pull`. Without `branch`, forks the repo and returns a draft pull
313+/// request to push to. With it, opens a pull request, ready for review,
314+/// for a branch already pushed to the repo. Returns `Outcome<Pull>`.
309315 #[derive(Debug, Serialize, Deserialize)]
310316 pub struct OpenPullArgs {
311317 pub actor: User,
316322 /// Defaults to the issue's title; required without an issue.
317323 #[serde(default)]
318324 pub title: String,
325+ /// What changed and why. Usually set later, when a draft is marked ready.
326+ #[serde(default)]
327+ pub body: String,
328+ /// A branch of the repository that already holds the change.
329+ #[serde(default)]
330+ pub branch: Option<String>,
319331 #[serde(default)]
320332 pub agent: String,
321333 pub runtime: Runtime,
+5−6
6363 - **Labels need no setup.** A repository starts with `bug`, `feature`,
6464 `docs`, `chore` and `question`; any other name becomes a label the first
6565 time it is used, so an integration can tag what it files.
66−- **The developer path is unchanged.** Push, open a pull request, get review,
67− merge. Pull requests from a branch pushed to the repo itself are next in
68− the build order; today each one has a fork.
66+- **The developer path is unchanged.** Push a branch, open a pull request
67+ from it, get review, merge. Agents get a fork per pull request instead.
6968 - **Both paths meet at `main`.** The same landing rules apply to a person's
7069 pull request and an agent's.
7170
646645 over HTTPS; accounts with registration, email verification, password reset
647646 and device sign-in; an OAuth 2.1 server, so MCP clients sign in through the
648647 browser with no token to paste; workspaces with members; issues with labels, checks and
649−comments; pull requests in forks with diffs and sessions, several per
650−issue; merging with a behind check, which resolves the issue and supersedes
648+comments; pull requests in forks or from branches, with diffs and sessions,
649+several per issue; merging with a behind check, which resolves the issue and supersedes
651650 the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI
652651 and MCP server; event bus. Identity, repos and work are in Rust.
653652
654−1. Pull requests from branches pushed to the repository.
653+1. Branch protection, and deleting a branch once its pull request merges.
655654 2. Scopes on OAuth grants and access tokens.
656655 3. Port events and the API to Rust; event storage per the design above.
657656 4. CLI with Claude Code hooks to record sessions automatically.
+3−2
9191 call("fork_for_pull", { sourceId, pullId, actor }),
9292 gitAccess: (path, viewer, service) =>
9393 call("git_access", { path, viewer, service }),
94− land: (forkId, actor) => call("land", { forkId, actor }),
95− compare: (repoId, viewer, base) => call("compare", { repoId, viewer, base }),
94+ branches: (path, viewer) => call("branches", { path, viewer }),
95+ land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }),
96+ compare: (repoId, viewer, base, head) => call("compare", { repoId, viewer, base, head }),
9697 };
9798 }
9899
+16−7
9999 */
100100 gitAccess(path: RepoPath, viewer: Viewer, service: GitService): Promise<Result<GitAccess>>;
101101
102+ /** The repository's branches, default branch first. */
103+ branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>;
104+
102105 /**
103− * Moves the default branch of the repo a fork came from to the fork's
104− * head. Refused with "conflict" when the fork is behind, since that would
106+ * Moves a repository's default branch to the head of a pull request's
107+ * source: a fork (`sourceId` is the fork) or one of the repository's own
108+ * branches (`sourceId` is the repository, and `branch` is required).
109+ * Refused with "conflict" when the source is behind, since that would
105110 * discard commits.
106111 */
107− land(forkId: string, actor: User): Promise<Result<{ commit: string; previous: string | null }>>;
112+ land(sourceId: string, actor: User, branch?: string | null): Promise<Result<{ commit: string; previous: string | null }>>;
108113
109114 /**
110− * What a repository's head changes. A pull request's fork is compared with the
111− * last commit it shares with the repository it came from, unless `base`
112− * says otherwise.
115+ * What `head` changes relative to `base`. `head` is a branch or a commit
116+ * and defaults to the default branch. With no `base`, a fork is compared
117+ * with the last commit it shares with the repository it came from, and a
118+ * branch with the point where it left the default branch.
113119 */
114− compare(repoId: string, viewer: Viewer, base?: string | null): Promise<Result<Comparison>>;
120+ compare(repoId: string, viewer: Viewer, base?: string | null, head?: string | null): Promise<Result<Comparison>>;
115121 }
116122
123+/** A branch and the commit it points to. */
124+export type Branch = { name: string; hash: string };
125+
117126 export type DiffLine = {
118127 kind: "context" | "add" | "delete";
119128 /** Line number in the old file; null for added lines. */
+41−4
5353 /** Where the agent runs: on g1t's sandboxes, or in someone's own session. */
5454 export type Runtime = "hosted" | "external";
5555
56−/** A proposed change, made in its own fork by an agent or a person. */
56+/**
57+ * A proposed change. It is made either in a fork created for it, which is
58+ * how agents work, or on a branch pushed to the repository itself.
59+ */
5760 export type Pull = {
5861 id: string;
5962 repoId: string;
6871 agent: string;
6972 runtime: Runtime;
7073 status: PullStatus;
71− fork: RepoPath;
74+ /** The fork holding the change, unless it is on a branch. */
75+ fork: RepoPath | null;
7276 /** The fork's repository id. */
73− forkRepoId: string;
77+ forkRepoId: string | null;
78+ /** The branch of the repository holding the change, unless it is in a fork. */
79+ branch: string | null;
7480 headCommit: string | null;
7581 /**
7682 * For a merged pull request, what the branch pointed to before the merge.
148154 issue?: number;
149155 /** Defaults to the issue's title; required without an issue. */
150156 title?: string;
157+ /** What changed and why. Usually set later, when a draft is marked ready. */
158+ body?: string;
159+ /**
160+ * A branch of the repository that already holds the change. The pull
161+ * request is then ready for review at once and has no fork.
162+ */
163+ branch?: string;
151164 agent: string;
152165 runtime: Runtime;
153166 };
174187 /** On an issue or a pull request. */
175188 addComment(actor: User, repo: RepoPath, number: number, body: string): Promise<Result<Comment>>;
176189
177− /** Forks the repo and returns the draft pull request to push to. */
190+ /**
191+ * Opens a pull request: a draft with a fork to push to, or, given a
192+ * branch, one ready for review.
193+ */
178194 openPull(actor: User, repo: RepoPath, input: OpenPullInput): Promise<Result<Pull>>;
179195 /** Newest first. */
180196 listPulls(repo: RepoPath, viewer: Viewer, state?: State): Promise<Result<Pull[]>>;
195211 appendSession(actor: User, repo: RepoPath, number: number, entries: NewSessionEntry[]): Promise<Result<{ count: number }>>;
196212 readSession(repo: RepoPath, number: number, viewer: Viewer, afterSeq?: number): Promise<Result<SessionEntry[]>>;
197213 }
214+
215+/**
216+ * What to pass `ReposApi.compare` to see what a pull request changes.
217+ *
218+ * A fork is compared as a whole. A branch is compared by name while the
219+ * pull request is open, and by the commit it was merged or closed at
220+ * afterwards, so later pushes to the branch do not change the record.
221+ */
222+export function pullComparison(pull: Pull): {
223+ repoId: string;
224+ base: string | null;
225+ head: string | null;
226+} {
227+ if (pull.forkRepoId) return { repoId: pull.forkRepoId, base: pull.mergeBase, head: null };
228+ const settled = pull.status === "merged" || pull.status === "closed";
229+ return {
230+ repoId: pull.repoId,
231+ base: pull.mergeBase,
232+ head: (settled && pull.headCommit) || pull.branch,
233+ };
234+}
+4−4
11 //! Landing a pull request: moving a repository's branch forward to a commit
2−//! that so far exists only in a fork.
2+//! from a fork, or from another of its own branches.
33 //!
44 //! The Artifacts binding cannot write, so this speaks git's smart HTTP
55 //! protocol directly. It asks the fork for a pack holding exactly the
4343 }
4444
4545 /// The payloads of the pkt-lines in `bytes`, and the offset where they stop.
46−fn read_pkt_lines(bytes: &[u8]) -> (Vec<&[u8]>, usize) {
46+pub(crate) fn read_pkt_lines(bytes: &[u8]) -> (Vec<&[u8]>, usize) {
4747 let mut lines = Vec::new();
4848 let mut position = 0;
4949 while position + 4 <= bytes.len() {
8686 Some(&PACK_BAND) => pack.extend_from_slice(&line[1..]),
8787 Some(&ERROR_BAND) => {
8888 return Err(Error::RustError(format!(
89− "the fork refused the fetch: {}",
89+ "the source refused the fetch: {}",
9090 String::from_utf8_lossy(&line[1..])
9191 )));
9292 }
9696 }
9797 if !pack.starts_with(b"PACK") {
9898 return Err(Error::RustError(format!(
99− "the fork did not send a pack: {}",
99+ "the source did not send a pack: {}",
100100 String::from_utf8_lossy(&response)
101101 )));
102102 }
+77−22
88 mod diff;
99 mod git_http;
1010 mod land;
11+mod refs;
1112 mod registry;
1213 mod store;
1314
325326 Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?))
326327 }
327328
329+ /// The repository's branches, default branch first.
330+ async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> {
331+ let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
332+ return Ok(not_found());
333+ };
334+ let mut branches = self.store.open(&store_key(&repo)).await?.branches().await?;
335+ branches.sort_by_key(|branch| branch.name != repo.default_branch);
336+ Ok(Outcome::Ok(branches))
337+ }
338+
339+ async fn head(&self, a: HeadArgs) -> Result<Option<String>> {
340+ let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
341+ return Ok(None);
342+ };
343+ let git = self.store.open(&store_key(&repo)).await?;
344+ Ok(git
345+ .log(&a.branch, 1)
346+ .await?
347+ .into_iter()
348+ .next()
349+ .map(|commit| commit.hash))
350+ }
351+
328352 async fn fork_for_pull(&self, a: ForkArgs) -> Result<Outcome<Repo>> {
329353 let viewer = Some(a.actor.clone());
330354 let Some(source) = self
426450
427451 async fn land(&self, a: LandArgs) -> Result<Outcome<Landed>> {
428452 let actor: Viewer = Some(a.actor.clone());
429− let Some(fork) = self.registry.by_id(&a.fork_id).await? else {
453+ let Some(source) = self.registry.by_id(&a.source_id).await? else {
430454 return Ok(not_found());
431455 };
432− let target = match &fork.fork_of {
456+ // A fork lands on the repository it came from; a branch on its own.
457+ let target = match &source.fork_of {
433458 Some(id) => self.registry.by_id(id).await?,
434− None => None,
459+ None => Some(source.clone()),
435460 };
436461 let Some(target) = target.filter(|repo| can_read(repo, &actor)) else {
437462 return Ok(not_found());
447472 }
448473
449474 let branch = &target.default_branch;
450− let fork_git = self.store.open(&store_key(&fork)).await?;
475+ let from_fork = source.id != target.id;
476+ let source_branch = match a.branch {
477+ Some(name) if !from_fork && name == *branch => {
478+ return Ok(Outcome::fail(
479+ FailureCode::Invalid,
480+ format!("{branch} cannot be merged into itself."),
481+ ));
482+ }
483+ Some(name) => name,
484+ None if from_fork => branch.clone(),
485+ None => {
486+ return Ok(Outcome::fail(
487+ FailureCode::Invalid,
488+ "Say which branch to merge.",
489+ ));
490+ }
491+ };
492+
493+ let source_git = self.store.open(&store_key(&source)).await?;
451494 let target_git = self.store.open(&store_key(&target)).await?;
452− let history = fork_git.log(branch, MAX_ANCESTRY).await?;
495+ let history = source_git.log(&source_branch, MAX_ANCESTRY).await?;
453496 let Some(new) = history.first().map(|commit| commit.hash.clone()) else {
454497 return Ok(Outcome::fail(
455498 FailureCode::Conflict,
472515 // Moving the branch to a commit that does not descend from its
473516 // current head would discard whatever landed in between.
474517 if let Some(old) = &old
475− && !descends_from(&fork_git, &history, old).await?
518+ && !descends_from(&source_git, &history, old).await?
476519 {
520+ let remedy = if from_fork {
521+ format!("Pull {branch} into the pull request's fork, push, and merge again.")
522+ } else {
523+ format!("Merge {branch} into {source_branch}, push, and merge again.")
524+ };
477525 return Ok(Outcome::fail(
478526 FailureCode::Conflict,
479− format!(
480− "{branch} has moved since this pull request was opened. Pull {branch} into the pull request's fork, push, and merge again."
481− ),
527+ format!("{branch} has moved since this pull request was opened. {remedy}"),
482528 ));
483529 }
484530
485− let source_access = fork_git.access(Scope::Read).await?;
531+ // For a branch the objects are already in the target; sending them
532+ // again is harmless and keeps one way of moving a ref.
533+ let source_access = source_git.access(Scope::Read).await?;
486534 let target_access = target_git.access(Scope::Write).await?;
487535 let pushed =
488536 land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new)
511559 return Ok(not_found());
512560 };
513561 let git = self.store.open(&store_key(&repo)).await?;
514− let history = git.log(&repo.default_branch, MAX_ANCESTRY).await?;
562+ let head_ref = a.head.as_deref().unwrap_or(&repo.default_branch);
563+ let history = git.log(head_ref, MAX_ANCESTRY).await?;
515564 let Some(head) = history.first() else {
516565 return Ok(Outcome::fail(
517566 FailureCode::Conflict,
518− "This repository has no commits yet.",
567+ "There are no commits to compare.",
519568 ));
520569 };
521570
571+ // Where the head's history meets the default branch of `against`.
572+ let shared_with = async |against: &Repo| -> Result<Option<String>> {
573+ let against_git = self.store.open(&store_key(against)).await?;
574+ let shared: HashSet<String> = against_git
575+ .log(&against.default_branch, MAX_ANCESTRY)
576+ .await?
577+ .into_iter()
578+ .map(|commit| commit.hash)
579+ .collect();
580+ nearest_ancestor_in(&git, &history, &shared).await
581+ };
522582 let base = match (a.base, &repo.fork_of) {
523583 (Some(base), _) => Some(base),
524584 // A fork is compared with the last commit it shares with the
525585 // repository it came from.
526586 (None, Some(target_id)) => match self.registry.by_id(target_id).await? {
527− Some(target) => {
528− let target_git = self.store.open(&store_key(&target)).await?;
529− let shared: HashSet<String> = target_git
530− .log(&target.default_branch, MAX_ANCESTRY)
531− .await?
532− .into_iter()
533− .map(|commit| commit.hash)
534− .collect();
535− nearest_ancestor_in(&git, &history, &shared).await?
536− }
587+ Some(target) => shared_with(&target).await?,
537588 None => None,
538589 },
590+ // A branch, with the point where it left the default branch.
591+ (None, None) if head_ref != repo.default_branch => shared_with(&repo).await?,
539592 (None, None) => head.parents.first().cloned(),
540593 };
541594 let base_tree = match &base {
645698 "log" => reply(&repos.log(args(body)?).await?),
646699 "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?),
647700 "git_access" => reply(&repos.git_access(args(body)?).await?),
701+ "branches" => reply(&repos.branches(args(body)?).await?),
702+ "head" => reply(&repos.head(args(body)?).await?),
648703 "land" => reply(&repos.land(args(body)?).await?),
649704 "compare" => reply(&repos.compare(args(body)?).await?),
650705 _ => Response::error("Unknown method", 404),
+87−0
1+//! Listing a repository's branches.
2+//!
3+//! The Artifacts binding reads commits, trees and blobs but does not list
4+//! refs, so this asks the way a git client does: the ref advertisement of
5+//! the smart HTTP protocol.
6+
7+use g1t_contracts::repos::{Branch, GitAccess};
8+use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
9+
10+use crate::land::read_pkt_lines;
11+
12+const HEADS: &str = "refs/heads/";
13+
14+/// The branches in a ref advertisement, in the order advertised.
15+fn parse_advertisement(bytes: &[u8]) -> Vec<Branch> {
16+ let (lines, _) = read_pkt_lines(bytes);
17+ lines
18+ .into_iter()
19+ .filter_map(|line| {
20+ // `<hash> <ref>`, and on the first ref a NUL then capabilities.
21+ let line = line.split(|byte| *byte == 0).next()?;
22+ let line = std::str::from_utf8(line).ok()?.trim_end();
23+ let (hash, name) = line.split_once(' ')?;
24+ Some(Branch {
25+ name: name.strip_prefix(HEADS)?.to_owned(),
26+ hash: hash.to_owned(),
27+ })
28+ })
29+ .collect()
30+}
31+
32+pub async fn branches(access: &GitAccess) -> Result<Vec<Branch>> {
33+ let headers = Headers::new();
34+ headers.set("authorization", &format!("Bearer {}", access.token))?;
35+ let mut init = RequestInit::new();
36+ init.with_method(Method::Get).with_headers(headers);
37+ let request = Request::new_with_init(
38+ &format!("{}/info/refs?service=git-upload-pack", access.remote),
39+ &init,
40+ )?;
41+ let mut response = Fetch::Request(request).send().await?;
42+ let bytes = response.bytes().await?;
43+ if response.status_code() != 200 {
44+ return Err(Error::RustError(format!(
45+ "listing refs returned {}",
46+ response.status_code()
47+ )));
48+ }
49+ Ok(parse_advertisement(&bytes))
50+}
51+
52+#[cfg(test)]
53+mod tests {
54+ use super::parse_advertisement;
55+
56+ fn pkt(payload: &str) -> Vec<u8> {
57+ format!("{:04x}{payload}", payload.len() + 4).into_bytes()
58+ }
59+
60+ #[test]
61+ fn branches_are_read_from_an_advertisement() {
62+ let main = "c71546fcd893ef8b0f57388b65e620d759705dda";
63+ let shout = "4807077b296e6edbf410d55e72749d3e1170c291";
64+ let advertisement = [
65+ pkt("# service=git-upload-pack\n"),
66+ b"0000".to_vec(),
67+ pkt(&format!(
68+ "{main} HEAD\0side-band-64k symref=HEAD:refs/heads/main\n"
69+ )),
70+ pkt(&format!("{main} refs/heads/main\n")),
71+ pkt(&format!("{shout} refs/heads/shout\n")),
72+ pkt(&format!("{shout} refs/tags/v1.0.0\n")),
73+ b"0000".to_vec(),
74+ ]
75+ .concat();
76+ let branches = parse_advertisement(&advertisement);
77+ let names: Vec<&str> = branches.iter().map(|branch| branch.name.as_str()).collect();
78+ assert_eq!(names, ["main", "shout"]);
79+ assert_eq!(branches[1].hash, shout);
80+ }
81+
82+ #[test]
83+ fn an_empty_repository_has_no_branches() {
84+ let advertisement = [pkt("# service=git-upload-pack\n"), b"00000000".to_vec()].concat();
85+ assert!(parse_advertisement(&advertisement).is_empty());
86+ }
87+}
+7−1
33 //! The service depends on the [`GitStore`] and [`GitRepo`] ports;
44 //! [`ArtifactsStore`] is the adapter for Cloudflare Artifacts.
55
6−use g1t_contracts::repos::{Commit, EntryKind, GitAccess, Signature, TreeEntry};
6+use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry};
77 use g1t_contracts::time::rfc3339;
88 use g1t_kit::js;
99 use serde::Deserialize;
4040 pub trait GitRepo {
4141 /// A remote URL and short-lived credential for git itself.
4242 async fn access(&self, scope: Scope) -> Result<GitAccess>;
43+ /// Every branch and the commit it points to.
44+ async fn branches(&self) -> Result<Vec<Branch>>;
4345 /// Newest first along the first-parent chain; empty for an unknown ref.
4446 async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>>;
4547 /// The parents of a commit, or `None` if the commit does not exist.
169171 })
170172 }
171173
174+ async fn branches(&self) -> Result<Vec<Branch>> {
175+ crate::refs::branches(&self.access(Scope::Read).await?).await
176+ }
177+
172178 async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
173179 let options = js::to_js(&serde_json::json!({ "ref": git_ref, "limit": limit }))?;
174180 let commits: Vec<RawCommit> =
+3−1
182182 // The first failure is the answer; later ones mean some already run.
183183 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
184184 const pull = opened.value;
185+ // Opened without a branch, so it has a fork.
186+ const fork = pull.fork!;
185187 pulls.push(pull);
186188
187189 // The sandbox acts as the person who started it, through a token
202204 G1T_USER: actor.username,
203205 G1T_REPO: `${repo.namespace}/${repo.name}`,
204206 PULL_NUMBER: String(pull.number),
205− GIT_REMOTE: `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`,
207+ GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
206208 COMMIT_MESSAGE: issue.title,
207209 PROMPT: prompt,
208210 ...modelEnv(this.env, model),
+6−3
4343 agent TEXT NOT NULL,
4444 runtime TEXT NOT NULL,
4545 status TEXT NOT NULL DEFAULT 'draft',
46− fork_repo_id TEXT NOT NULL UNIQUE,
47− fork_namespace TEXT NOT NULL,
48− fork_name TEXT NOT NULL,
46+ -- Where the change is: a fork made for the pull request, or a branch of
47+ -- the repository itself.
48+ fork_repo_id TEXT UNIQUE,
49+ fork_namespace TEXT,
50+ fork_name TEXT,
51+ source_branch TEXT,
4952 head_commit TEXT,
5053 -- What the branch pointed to before a merged pull request landed.
5154 merge_base TEXT,
+117−23
99 use g1t_contracts::events::{
1010 CommentCreated, Delivered, IssueEvent, NewEvent, PullEvent, SessionAppended,
1111 };
12−use g1t_contracts::repos::{ForkArgs, GetArgs, LandArgs, Landed, Repo, RepoPath};
12+use g1t_contracts::repos::{ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, Repo, RepoPath};
1313 use g1t_contracts::time::rfc3339;
1414 use g1t_contracts::work::*;
1515 use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
2020 Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event,
2121 };
2222
23−use rows::{CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow};
23+use rows::{BranchRow, CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow};
2424
2525 const SOURCE: &str = "work";
2626 const MAX_ENTRY_BATCH: usize = 200;
636636
637637 let now = now_ms();
638638 let id = new_id("pr", now);
639− let fork: Outcome<Repo> = g1t_kit::call(
640− &self.repos,
641− "fork_for_pull",
642− &ForkArgs {
643− source_id: repo.id.clone(),
644− pull_id: id.clone(),
645− actor: a.actor.clone(),
646− },
647− )
648− .await?;
649− let fork = check!(fork);
639+ let branch = a
640+ .branch
641+ .as_deref()
642+ .map(str::trim)
643+ .filter(|branch| !branch.is_empty());
644+ // The change is on a branch already pushed to the repository, or
645+ // will be made in a fork created for this pull request.
646+ let (fork, head) = match branch {
647+ Some(branch) => {
648+ if branch == repo.default_branch {
649+ return Ok(Outcome::fail(
650+ FailureCode::Invalid,
651+ format!("Choose a branch other than {branch}."),
652+ ));
653+ }
654+ let head: Option<String> = g1t_kit::call(
655+ &self.repos,
656+ "head",
657+ &HeadArgs {
658+ repo_id: repo.id.clone(),
659+ branch: branch.to_owned(),
660+ },
661+ )
662+ .await?;
663+ let Some(head) = head else {
664+ return Ok(Outcome::fail(
665+ FailureCode::NotFound,
666+ format!("There is no branch named {branch}. Push it first."),
667+ ));
668+ };
669+ let existing = self
670+ .db
671+ .prepare(
672+ "SELECT number AS n FROM pulls
673+ WHERE repo_id = ? AND source_branch = ? AND status IN ('draft', 'open')",
674+ )
675+ .bind(&[repo.id.as_str().into(), branch.into()])?
676+ .first::<NumberRow>(None)
677+ .await?;
678+ if let Some(existing) = existing {
679+ return Ok(Outcome::fail(
680+ FailureCode::Conflict,
681+ format!("Pull request #{} is already open for {branch}.", existing.n),
682+ ));
683+ }
684+ (None, Some(head))
685+ }
686+ None => {
687+ let fork: Outcome<Repo> = g1t_kit::call(
688+ &self.repos,
689+ "fork_for_pull",
690+ &ForkArgs {
691+ source_id: repo.id.clone(),
692+ pull_id: id.clone(),
693+ actor: a.actor.clone(),
694+ },
695+ )
696+ .await?;
697+ (Some(check!(fork)), None)
698+ }
699+ };
700+ // A branch already holds the work, so its pull request is ready for
701+ // review from the start; one with a fork starts as a draft.
702+ let status = if branch.is_some() { "open" } else { "draft" };
703+ let body = Some(a.body.trim().to_owned()).filter(|body| !body.is_empty());
650704
651705 let number = self.next_number(&repo.id).await?;
652706 let timestamp = rfc3339(now);
653707 self.db
654708 .prepare(
655709 "INSERT INTO pulls
656− (id, repo_id, number, issue_id, issue_number, title, agent, runtime,
657− fork_repo_id, fork_namespace, fork_name, author_id, author_name,
658− created_at, updated_at)
659− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
710+ (id, repo_id, number, issue_id, issue_number, title, body, agent, runtime,
711+ status, fork_repo_id, fork_namespace, fork_name, source_branch, head_commit,
712+ author_id, author_name, created_at, updated_at)
713+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
660714 )
661715 .bind(&[
662716 id.as_str().into(),
665719 optional(&issue.as_ref().map(|issue| issue.id.clone())),
666720 optional_number(issue.as_ref().map(|issue| issue.number)),
667721 title.into(),
722+ optional(&body),
668723 agent.into(),
669724 runtime.into(),
670− fork.id.into(),
671− fork.namespace.into(),
672− fork.name.into(),
725+ status.into(),
726+ optional(&fork.as_ref().map(|fork| fork.id.clone())),
727+ optional(&fork.as_ref().map(|fork| fork.namespace.clone())),
728+ optional(&fork.as_ref().map(|fork| fork.name.clone())),
729+ optional(&branch.map(str::to_owned)),
730+ optional(&head),
673731 a.actor.id.as_str().into(),
674732 a.actor.username.as_str().into(),
675733 timestamp.as_str().into(),
837895 &self.repos,
838896 "land",
839897 &LandArgs {
840− fork_id: pull.fork_repo_id.clone(),
898+ // A pull request from a branch lands from the repository itself.
899+ source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
900+ branch: pull.branch.clone(),
841901 actor: a.actor.clone(),
842902 },
843903 )
10381098 ))
10391099 }
10401100
1041− /// A push to a pull request's fork moves that pull request's head.
1101+ /// A push moves the head of the pull requests it concerns: the one
1102+ /// whose fork was pushed to, or those from branches of the repository
1103+ /// that was.
10421104 async fn on_event(&self, event: &Delivered) -> Result<()> {
10431105 if event.kind != "git.push" {
10441106 return Ok(());
10471109 else {
10481110 return Ok(());
10491111 };
1112+ let now = rfc3339(now_ms());
10501113 self.db
10511114 .prepare(
10521115 "UPDATE pulls SET head_commit = ?, updated_at = ?
10531116 WHERE fork_repo_id = ? AND status IN ('draft', 'open')",
10541117 )
1055− .bind(&[after.into(), rfc3339(now_ms()).into(), repo_id.into()])?
1118+ .bind(&[after.into(), now.as_str().into(), repo_id.into()])?
10561119 .run()
10571120 .await?;
1121+
1122+ // The event does not say which branch moved, so each open pull
1123+ // request from a branch of this repository is checked.
1124+ let from_branches = self
1125+ .db
1126+ .prepare(
1127+ "SELECT id, source_branch, head_commit FROM pulls
1128+ WHERE repo_id = ? AND source_branch IS NOT NULL AND status IN ('draft', 'open')",
1129+ )
1130+ .bind(&[repo_id.into()])?
1131+ .all()
1132+ .await?
1133+ .results::<BranchRow>()?;
1134+ for row in from_branches {
1135+ let head: Option<String> = g1t_kit::call(
1136+ &self.repos,
1137+ "head",
1138+ &HeadArgs {
1139+ repo_id: repo_id.to_owned(),
1140+ branch: row.source_branch,
1141+ },
1142+ )
1143+ .await?;
1144+ if let Some(head) = head.filter(|head| Some(head) != row.head_commit.as_ref()) {
1145+ self.db
1146+ .prepare("UPDATE pulls SET head_commit = ?, updated_at = ? WHERE id = ?")
1147+ .bind(&[head.into(), now.as_str().into(), row.id.into()])?
1148+ .run()
1149+ .await?;
1150+ }
1151+ }
10581152 Ok(())
10591153 }
10601154 }
+17−7
7272 pub agent: String,
7373 pub runtime: Runtime,
7474 pub status: PullStatus,
75− pub fork_repo_id: String,
76− pub fork_namespace: String,
77− pub fork_name: String,
75+ pub fork_repo_id: Option<String>,
76+ pub fork_namespace: Option<String>,
77+ pub fork_name: Option<String>,
78+ pub source_branch: Option<String>,
7879 pub head_commit: Option<String>,
7980 pub merge_base: Option<String>,
8081 pub merged_by: Option<String>,
9899 agent: row.agent,
99100 runtime: row.runtime,
100101 status: row.status,
101− fork: RepoPath {
102− namespace: row.fork_namespace,
103− name: row.fork_name,
104− },
102+ fork: row
103+ .fork_namespace
104+ .zip(row.fork_name)
105+ .map(|(namespace, name)| RepoPath { namespace, name }),
105106 fork_repo_id: row.fork_repo_id,
107+ branch: row.source_branch,
106108 head_commit: row.head_commit,
107109 merge_base: row.merge_base,
108110 merged_by: row.merged_by,
158160 }
159161 }
160162
163+/// An open pull request made from a branch of its repository.
164+#[derive(Deserialize)]
165+pub struct BranchRow {
166+ pub id: String,
167+ pub source_branch: String,
168+ pub head_commit: Option<String>,
169+}
170+
161171 /// A single number selected as `n`.
162172 #[derive(Deserialize)]
163173 pub struct NumberRow {