atproto git client
atgc plan branch-pulls.md
11 kB · 184 lines


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