Fetch is a button, and moving the checkout builds nothing

Nothing here ever wrote a remote-tracking ref except the fetch inside a
pull, and a pull is reachable only when the branch you are already on is
behind -- so a branch pushed from another machine reached the picker as a
side effect of pulling something else, and a project already up to date
could never be moved onto a new branch at all.

POST /apps/{key}/fetch answers with the whole ref list, so the pickers
repopulate from after the fetch in one round trip. It is the only call
here that asks for --prune: making the picker say what the remote says is
its job, where a pull should change as little as it can. Still not on a
timer and not on opening the sheet, because a fetch mutates the checkout.

Picking a remote-only branch then had to work: `git checkout origin/topic`
detaches HEAD, since git's DWIM fires on the bare name, so the control
that says it is picking a branch produced the state picking a commit is
meant to produce -- and printed its detached-HEAD advice and succeeded.

Moving the checkout no longer builds. A pull is somebody taking new work;
a move is somebody looking, and charging a full build for a look starts a
minute of work an accidental tap cannot call back. What it leaves behind
is a component whose build no longer matches the checkout, so the build
button's word now follows the state: Rebuild only where every component
it covers is known current, Build otherwise.

The sheet loses its Checkout heading and its paragraph, both pickers sit
in a weighted row so a long branch name cannot wrap the label one letter
per line, and the failure text moved below them -- above, it shoved the
pickers down the screen as somebody reached for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 19:48:02 -04:00
1 parent f983db154d
commit 25c069909b
8 files changed
+507 -117

No files matched your search

+78 -15
View File
@@ -340,22 +340,37 @@ mutable at runtime from the phone.
remote deliberately, and a card that mentions it every time is nagging
about a choice somebody made.
- **A checkout can be moved from the phone, and it is the same act as a
pull.** The project settings sheet lists the checkout's branches and its
last fifty commits, and picking one moves the checkout and then builds
whatever that left behind. `BuildState::after_moving` is the half a pull
and a checkout share -- one copy, because the part that is easy to get
wrong is not the git command but handing the run over under a single
lock, so nothing can observe the moment between the move ending and the
builds it decided on starting. It reports through the project's own
state for the same reason: one progress path, not a second to keep in
step.
- **A checkout can be moved from the phone, and it is nearly the same act
as a pull.** The project settings sheet lists the checkout's branches and
its last fifty commits, and picking one moves the checkout.
`BuildState::after_moving` is the half a pull and a checkout share -- one
copy, because the part that is easy to get wrong is not the git command
but handing the run over under a single lock, so nothing can observe the
moment between the move ending and the builds it decided on starting. It
reports through the project's own state for the same reason: one progress
path, not a second to keep in step.
**Where they differ is that a move builds nothing** (`then_build` is
`None`, which is what `BuildAfterMoving` exists to say). A pull is
somebody taking new work, so building it is the point; moving the
checkout is somebody *looking* -- at another branch, at last week's
commit -- and charging a full build of every component for a look means
an accidental tap starts a minute of work with no way to cancel it, and
replaces outputs that were wanted. What it leaves behind is a component
whose build no longer matches the checkout, which the card already says
and already carries the button for. Iris asked for this on 2026-09-02.
Which is why **the build button's word follows the state**: `buildWord`
in `UpdateManifest.kt` says `Rebuild` only where every component it
covers is *known* current, and `Build` otherwise -- behind, never built,
or parked on a commit nobody measured the output against. One rule at
both scales, so the project row and a component row cannot come to
disagree about what pressing them means; the project's asks only about
the components it actually builds, since one with no command has no
output to be current with anything and counting it would pin that button
to `Build` for ever.
The two lists are read by `GET /apps/{key}/refs` when the sheet is
opened, never on the manifest -- both spawn git. They are **local reads
only**: what the remote has and this checkout has not fetched is not
offered, because Pull is what brings those in and the card already says
when there are some, so opening the sheet cannot stall on a round trip
or fail the way a remote check can.
opened, never on the manifest -- both spawn git, and both are **local
reads**, so opening the sheet cannot stall on a round trip or fail the
way a remote check can.
Two things about listing branches were wrong until a real checkout was
looked at, and neither shows up in a fixture built to pass:
`git branch --format` adds a **`(HEAD detached at abc123)` pseudo-entry**
@@ -367,6 +382,54 @@ mutable at runtime from the phone.
test that was meant to cover the second asserted the same wrong thing
and passed.
- **Fetch is a button, because nothing else here writes a
remote-tracking ref.** The only `git fetch` this server ran was the one
inside a pull, and a pull is reachable only when the branch you are
already on is behind -- so a branch pushed from another machine reached
the picker only as a side effect of pulling something else, and a project
already up to date could never be moved onto a new branch at all. The way
out was a terminal on the build machine, which is where the person
holding the phone is not. `POST /apps/{key}/fetch` is the fix, sitting
under the two pickers in the project settings sheet. Deliberately still
not on a timer and not on opening the sheet: a fetch mutates the checkout
and pulls down objects, so it stays something somebody pressed, and
`/refs` stays the local read described above.
Both pickers sit in a `SettingRow`, which weights the label *and* the
control rather than letting the control take what it likes: unweighted,
a pill showing `second-branch-from-elsewhere` squeezes "Branch" into a
three-character column that wraps one letter per line. The same rule the
component rows already follow, in the other place a row mixes text with
a control. The fetch failure and the "reading branches" note go
*below* both pickers for the same family of reason -- above them, each
appears and disappears mid-sheet and shoves the pickers down the screen
as somebody is reaching for one, and the failure ends up nowhere near
the button that produced it.
It answers with the **whole ref list** rather than an acknowledgement, so
the pickers repopulate from after the fetch in the same round trip --
`routes::read_refs` is the one place both routes build that list.
`--prune` is asked for here and nowhere else: this is the call whose job
is to make the picker say what the remote says, where a pull should
change as little as it can. The phone's read timeout for it is longer
than the server's own 30s hard stop for a remote command, so a slow
remote is reported in git's words rather than as the phone giving up on a
request that is still running. While it runs, both pickers are disabled
-- it is replacing the lists they are showing -- but uncommitted work is
*not* a reason to disable it, since a fetch touches no file in the
working tree. It is the one control in that sheet that still works on a
dirty checkout.
- **`git checkout origin/topic` detaches HEAD**, which is why
`git::local_branch_for` exists. Git's DWIM that starts a tracking branch
fires on the bare name `topic` and on nothing else, and the picker's
remote-only entries are named `origin/topic` because that is what they
are -- so picking a branch left the checkout on no branch, with no
upstream and so no Pull: the state picking a *commit* is meant to
produce, reached from the control that says it is picking a branch.
Nothing about it looks like a failure either, since `git checkout`
prints its detached-HEAD advice and succeeds. Found by running it
against a real clone once the Fetch button made remote-only branches
something you could actually reach.
- **The checkout guard is deliberately weaker than the pull guard, and
that is what stops it being a one-way door.** `git::checkout` refuses
only on **tracked** modifications (`--untracked-files=no`), where