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
- Write on the repository, the same grant
PullRequests.Createasks for. A principal narrowed to a list of methods needsPullRequests.Createon it. The author is the pusher's principal, so the review policy'sforbid_self_approvalsees the right person. - Approve and merge stay behind the passkey. The push opens the pull request and stops there: no option approves or merges, and a pull request nobody approved does not merge. An approval counts only when it was made with a fresh, user-verified touch of a person's own passkey (
[stepup]), on every branch, and a merge needs a fresh touch too, so a stolen SSH key can propose a change and never let it through. See Approving needs a passkey and Merging needs a passkey. - No duplicate. A branch that already has an open pull request gets that one back. The pull request follows its branch, so the push has already updated it, and the line says
pull request #3 updated: …. - The push stands when the pull request cannot be made. A target that does not exist, a grant that is missing, a store that cannot be read: the references have moved, the push succeeds, and the line says why (
remote: no pull request opened: …; the push itself landed). - A request that cannot mean anything is refused before anything is written.
pr.createon a push that names no single branch (two branches, only a tag, or the branch's deletion), a target that is the branch itself, and an unknownpr.name. Every reference of the push is refused with the reason, and none of them moves.
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
- Unit tests with fakes:
gunnar-serverpush_pull::tests(12: create with the defaults and with every option, update without a duplicate, no single branch, target = branch, unknown options, a failure that keeps the push, no Write, nopr.create, a branch that did not move, the address),gunnar-wiretests/follow_up.rs(a refusal moves nothing, the line rides band 2 after the move) andgunnar-policytests/push_options.rs(the four pass an allowlist,pr.draftdoes not). - Integration tests with a real server and stock
git:gunnar-clitests/push_pull_request.rs(9): create and update over SSH and over smart HTTP, the refusals, a failure that keeps the push, no Write (a Read-only push is refused, a narrowed principal's push lands with no pull request), self-approval still refused, and approve and merge still behind the passkey. - Approving needs a passkey. Unit tests with fakes:
gunnar-coreforge::tests(an approval signed with the actor's own SSH key does not count, a passkey-touched one does, a machine's never does, and the verdicts that never counted), andgunnar-grpctests/forge_approve_passkey.rs(5, overMemoryControlPlane: an SSH-signed approval and an unsigned one with no session are refused by name, a session without a fresh touch gets[stepup], a touched one reaches the attested door, a machine never approves). Integration tests with a real server:gunnar-clitests/approve_needs_passkey.rs(4: a stolen key opens a pull request withpush -o pr.createand cannot approve it through to merge; a passkey-touched approval merges; a machine principal cannot approve;forbid_self_approvalstill holds with a passkey) andtests/forge_online_cli.rs(thegunnar control review approvecommand is refused and names the console and the passkey). - Merging needs a passkey. Unit tests with fakes:
gunnar-grpctests/pulls_merge_passkey.rs(4, overMemoryControlPlane: a merge with a key or a token is refused by name, a session without a touch gets[stepup], a touched one reaches the plane, a machine never merges). Integration tests with a real server:gunnar-clitests/approve_needs_passkey.rs(2 more: a key-signed merge is refused overPullRequests.Mergeand at the plane's door and a passkey-touched one goes through; a machine principal cannot merge) andtests/forge_control_cli.rs(the real binary'sgunnar control mergeover the real gRPC port is refused before and after the approval, andmainstays where it was). The other merge tests merge with a touch.