id: branch-pulls
title: A pull publishes the branch it claims
status: open
repos: [atgc]
dependsOn: [pull-requests]
exitCriterion: >
Every pull whose record carries a source has that branch really on the
knot, on the round the record's newest patch describes. #
branch-pulls #
source: {branch} is a claim the appview believes and never checks. atgc used
to write it over a branch that had never been pushed, which is what made the
tree link on a pull page describe code nobody could fetch.
The default now pushes the branch to the target's knot and asks
sh.tangled.repo.compare what it holds, and --patch-only keeps the old local
format-patch and writes no source at all — because a source is exactly
the claim that a branch is there. Rounds republish under a
--force-with-lease read off the last round's own patch.
What is left is the third shape (a pull from a fork), the branch a
--patch-only pull came off, and retargeting one in place. The entry that
asked whether stack create should keep writing source is not repeated here:
it was answered by pushing the branch, the same way the flat pull was, and
src/cmd/stack/write.rs opens with the reasoning.
What it needs #
Done #
-
**There is no patchless pull**, and there still isn't: `lexicons/pulls/pull.json` requires `rounds` and every round requires `patchBlob`, so the patch is gzipped, uploaded and put in the round exactly as before. What changed is who computes it and what the record is then entitled to claim. The default pushes the branch to the target's knot over SSH, `GET`s `sh.tangled.repo.compare?repo=<repoDid>&rev1=<target>&rev2=<source>` — a public query, no scope and no session — refuses an empty comparison as the appview does, and writes `source: {branch}` over a branch that is now really there. `--patch-only` keeps the old local `git format-patch` and writes **no** `source`, because a `source` is exactly the claim that a branch is on the knot and the appview never checks it. Two things learned in the doing, both cheap to get wrong. The knot serializes `types.RepoFormatPatchResponse` with Go's names, not the lexicon's: the mailbox arrives as `patch` and the per-commit list as `format_patch`, and every field is `omitempty`. And a scenario can take a real push without a git transport on the mock knot — the checkout carries `url.<bare>.pushInsteadOf`, which rewrites the push target and leaves `git remote get-url` alone, so the repo DID is still resolvable out of the remote path. Plain `insteadOf` would not have worked; `get-url` applies it -
The claim was never the wrong *shape*: `newStack` in `appview/pulls/create.go` gives every member of its own stacks the same `pullSource` while storing one commit's `fp.Raw` as that member's patch. A member's `source` says where the stack came from; its patch says which commit of it. So there was nothing to fix in the record — only the push that makes it true, exactly as for the flat pull. Dropping `source` was never open, which the read settled in one pass. `resubmitCheck` in `appview/pulls/single.go` compares the *top* member's sha (the stack is stored top-first) against the branch's live head; `resubmitBranch` routes the web's resubmit through `repo.compare` and re-splits the range by change-id — it dispatches on `IsBranchBased` before it ever looks at the stack, so a sourceless member degrades to a paste-a-patch box for one commit; `compose.go` will not compose a stacked pull that is patch-based at all (`isStacked := mode == "stack" && !isPatchBased`). And closer to home, `read::for_branch` matches on `source.branch`, which is how `stack view`, `stack resubmit` and `stack merge` find a chain — a sourceless stack is one atgc could never pick up again. Hence no `--patch-only` here: a push that fails refuses and points at `pr create --patch-only`. The compare is still asked for, demoted from patch source to proof: an empty answer refuses as `handleBranchBasedPull` does, a count that disagrees with the plan is a note, and `sourceRev` needs nothing written at all — `PullSubmissionFromRecord` derives it from the blob's last patch, so the top member's sha becomes the branch head for free. One thing learned that nothing above predicted: `knotserver/git/diff.go` adds the `Change-Id:` header to a knot's format-patch only from a commit object's jj `change-id`, never from a `Change-Id:` trailer. So a `--add-change-ids` branch makes a stack the *website* cannot resubmit — the appview finds no ids in what the knot hands it. `stack create` says so when the compare shows it, and `stack resubmit` is unaffected because it injects the headers itself -
The three awkward cases, decided. A `--patch-only` pull stays patch-only forever: a round that grew a `source` would start claiming a branch the pull's earlier rounds never published, and the appview believes `source` without checking it, so the pull would look branch-based back to its first round. Reopening with `pr create` is the way to change shape, and it is honest about it. A fork-based pull (`source.repo` set, which only the web UI opens) is refused: the compare has to run on the fork's knot, which is the same thing that keeps `pr create` from opening one. And a round raised from a branch other than the one the record names is refused rather than resolved either way round — pushing the checked-out branch under the recorded name moves a branch nobody asked about, pushing the recorded name from here sends commits the round never read — which is also the answer for a source branch that no longer exists locally: fetch it from the knot and check it out