Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

docs/v2/pull-requests-from-push.md

Pull requests from git push

A pull request can be opened by the push that makes its branch. Nothing else is needed: no console, no gunnar command, no second login.

git push -o pr.create origin my-branch
git push -o pr.create -o pr.title="Fix the thing" -o pr.target=main origin my-branch

Git prints the number and the address beside the push:

remote: pull request #3 opened: https://console.example.com/team/app/pulls/3
To ssh://git.example.com:2222/team/app.git
 * [new branch]      my-branch -> my-branch

It works the same over SSH and over smart HTTP, because both reach the server's one push path (gunnar-server/src/transport.rs, serve_receive_pack).

The options

Option Default What it does
pr.create opens a pull request from the pushed branch once the reference has moved
pr.title=<title> the subject of the head commit (the branch name if it has none) the pull request's title
pr.description=<text> empty the pull request's description
pr.target=<branch> the repository's default branch the branch it merges into; main and refs/heads/main are the same

Without pr.create the other three do nothing. There is no pr.draft, and no option approves or merges. An instance that refuses unknown push options (a push-option allowlist) still takes these four; any other unknown option stays refused, and so does a pr. name outside the four.

The rules

The dataflow

git push -o pr.create ──► receive-pack: commands, push options, pack
                                   │
                                   ▼
                         vet (PushFollowUp::vet)          refuse every command, move nothing:
                           read the pr.* options     ──►  no single branch, target = branch,
                           the one branch                 unknown pr. name
                           the target (or default)
                                   │ ok
                                   ▼
                         gates, then the references move (one CAS each)
                                   │
                                   ▼
                         after (PushFollowUp::after)
                           branch moved?          no ──► "no pull request opened: … was not updated"
                           Write grant?           no ──► "no pull request opened: … no Write grant …"
                           open one from branch?  yes ─► "pull request #n updated: <address>"
                           create (title, target) ok ──► "pull request #n opened: <address>"
                                                  err ─► "no pull request opened: <why>; …"
                                   │
                                   ▼
                         band 2: the plan notice, then these lines   (git prints "remote: …")
                         band 1: the report (ok / ng per reference)

vet runs after the pack is in and before any reference moves; after runs once they have moved and before the report is written. Both live behind the gunnar-wire trait PushFollowUp, so the wire knows nothing about pull requests. The server's implementation is gunnar-server/src/push_pull.rs: PushPulls holds the rules, and PullDoor is the trait it asks the server through (the default branch, the grant, the open pull request, the head's subject, the create). The unit tests drive PushPulls against a fake door; PlaneDoor is the real one, over the same control plane PullRequests.Create calls, and it publishes the same PullRequestOpened event.

Approving needs a passkey

The owner's rule (2026-11-07): approving a pull request needs a passkey, everywhere. Before it, an approval signed with the author's own registered SSH key counted at merge on any target without forbid_self_approval, so a stolen key could open a pull request with git push -o pr.create and approve it. Now an approval counts only as Gunnar's attestation of a fresh, user-verified touch of a person's own passkey, the same step-up merge uses.

Every door an approval can come through

Door What it does with an approval
The console's Approve button sends the approval unsigned from the person's own session; Gunnar attests it when the session stepped up, and answers [stepup] otherwise, which the console answers with a touch in place
Forge.Append signed with an SSH key, from any caller (the gunnar control review approve command does this) refused, PermissionDenied, with the reason below
Forge.Append unsigned over a key credential or a token (no person's session) refused with the same reason
Forge.Append from a machine principal (CI, a bot, a deploy key) refused: a machine principal (CI, a bot, a deploy key) can never approve: …
the plane's own doors, reached without a connection (forge_append, forge_append_attested) the same two refusals again
gunnar forge approve on the box, which writes the log directly refuses and writes nothing
a line already in the forge log (imported, or written by hand) does not count at merge: PullRequests.Merge, Forge.GetReview and gunnar forge show all read one rule, gunnar_core::forge::approval_verdict

The reason a key or a token gets, word for word:

[stepup] an approval needs a fresh, user-verified touch of your own passkey: approve in the
console, where gunnar attests the touch. An approval signed with an SSH key or sent with a
token does not count, so a stolen key can propose a change but never let it through

Comments, withdrawals of an approval (Rejected) and issue events keep taking a key's signature: none of them lets a change through.

The rule

For the latest approval e of commit c by actor a on a pull request, with G Gunnar's forge-attestation key and K(f) the registered key with fingerprint f and its owner:

counts(e) ⇔ person(a) ∧ attested(e) ∧ verify(e, G)

verdict(e) = NO EVENT                            when there is no e
           = MACHINE                             when ¬person(a)
           = attested for session step-up at t   when attested(e) ∧ verify(e, G)      (counts)
           = SSH KEY, NO PASSKEY                 when ¬attested(e) ∧ K(e.key) = (k, a) ∧ verify(e, k)
           = KEY OF b, NOT a                     when ¬attested(e) ∧ K(e.key) = (k, b), b ≠ a
           = UNKNOWN KEY | BAD (…)               otherwise

person(a) is true for a User principal and for a person the identity core knows that the key table does not; it is false for a machine, a deploy key or a group, and when the table cannot be read. The merge then asks its usual questions of the approvals that count: one names the tip, forbid_self_approval drops the author's own (with a passkey too), and required_approvals counts distinct actors.

 Forge.Append (approval)
   │ machine caller?                    yes ─► refused: a machine can never approve
   │ signed, or no person's session?    yes ─► refused: [stepup] an approval needs … passkey …
   │ session stepped up?                no ──► refused: [stepup] Forge.Append needs a fresh touch …
   ▼
 forge_append_attested: actor a person? no ──► refused: a machine can never approve
   │ Gunnar signs it with G, "attested session-step-up t" inside the signature
   ▼
 forge log ──► fold (latest approval per actor) ──► approval_verdict per approval
                                                          │ counts
                                                          ▼
 PullRequests.Merge: names the tip? ─► forbid_self_approval ─► required_approvals ─► move the ref

A worked example

Repository team/app, no protection on main. Mallory holds tester's stolen SSH key.

Step What happens
git push -o pr.create origin topic with the stolen key pull request #1 opened; author tester
gunnar control review approve team/app 1 --commit <topic> -i stolen refused: [stepup] an approval needs a fresh, user-verified touch of your own passkey: approve in the console …; the forge log is unchanged
the same approval written into the forge log by hand Forge.GetReview shows tester → <topic> [SSH KEY, NO PASSKEY (an approval needs a passkey touch)], verified: false
PullRequests.Merge #1 refused: no verified approval names <topic>; main does not move
tester presses Approve in the console and touches their passkey tester → <topic> [attested for session step-up at 2026-11-07T19:00:00Z], verified: true
PullRequests.Merge #1 merges, because main has no forbid_self_approval; with it on, a second person's touch is needed

Merging needs a passkey

The same owner's rule, "approve and merge stay behind the passkey" (2026-11-07). Before it, gunnar control merge signed with a key merged an approved pull request with no touch. Now PullRequests.Merge takes a merge only from a person's session that stepped up, and a machine principal never merges. The merge's other rules (a verified approval names the tip, forbid_self_approval, required_approvals, branch protection) are unchanged and come after this one.

Door What it does with a merge
The console's Merge button sends the merge from the person's own session; Gunnar takes it when the session stepped up, and answers [stepup] otherwise, which the console answers with a touch in place
The console with no passkey session (open on loopback, the break-glass token) sends nothing and refuses: the console's own key cannot merge
PullRequests.Merge with an SSH key credential or a token (gunnar control merge does this) refused, PermissionDenied: [stepup] a merge needs a fresh, user-verified touch of your own passkey: merge in the console, …
PullRequests.Merge from a session that has not stepped up refused: [stepup] PullRequests.Merge needs a fresh touch of your own passkey …
PullRequests.Merge from a machine principal refused: a machine principal (CI, a bot, a deploy key) can never merge: …
the plane's own door, merge_pull, reached without a connection refused without a touch in MergeOptions::passkey_touch_at_unix_ms, and refused for a machine principal with one

PullRequests.CheckMerge, which only asks whether a merge would go through, needs no touch.

 PullRequests.Merge
   │ Write on the repository?          no ──► refused (the grant)
   │ machine caller?                   yes ─► refused: a machine can never merge
   │ a person's session?               no ──► refused: [stepup] a merge needs … passkey …
   │ stepped up?                       no ──► refused: [stepup] PullRequests.Merge needs a fresh touch …
   ▼ options.touched(t)                 t = the session's step-up time
 merge_pull: touch? person?            no ──► the same two refusals
   ▼
 the review gate, the policy, protection, the merge engine ──► move the ref

In the worked example above, Mallory's gunnar control merge team/app 1 with the stolen key is refused with [stepup] a merge needs … whether or not #1 is approved, and main does not move. Tester's Merge in the console after a touch goes through.

The address

The line's address is the console's pull-request page, <origin>/<namespace>/<repo>/pulls/<n>. The origin is the first origin of the first --web-relying-party (<rp_id>=<origin>); a server started without one prints the path alone (/team/app/pulls/3).

A worked example

Repository team/app, default branch refs/heads/main, alice holds Write.

Push Line Record
git push -o pr.create origin fix (head subject "Fix the thing") pull request #1 opened: …/team/app/pulls/1 #1 open, author alice, "Fix the thing", refs/heads/fix → refs/heads/main
one more commit, git push -o pr.create origin fix pull request #1 updated: …/team/app/pulls/1 still #1, no #2
git push -o pr.create -o pr.target=nowhere origin other no pull request opened: refs/heads/nowhere in team/app not found; the push itself landed other moved, no pull request
git push -o pr.create origin a b ! [remote rejected] a -> a (pr.create opens a pull request from one branch, …) neither a nor b moved
git push -o pr.create origin main ! [remote rejected] main -> main (pr.create from refs/heads/main: the default branch is refs/heads/main, the branch itself; …) main did not move

The tests