Pull, Update, Remove -- and a card that says when its build is behind

Iris's call: three buttons on a project's row, where Pull takes the
commits and stops and Update goes the whole way -- pull, build what that
brought in, install every APK it produced. Splitting them is the same
reasoning that stopped a checkout building: bringing a checkout up to
date is cheap and something you do while looking, and the expensive half
belongs to the button that says it does it.

Update is composed from the two things the app already does rather than
given a route of its own, so each half reports where it already did. It
skips a component whose build just failed, and hands the installer one
APK at a time: an install intent is modal, so two fired together means
the second replaces the first and a component is silently never
installed. The rest wait in ComponentState.ReadyToInstall, offered again
by continuePendingInstalls -- the same machinery as a download waiting
for a wrongly-signed copy to be removed. Deriving "is the installer
free?" from the component states could not work, because handing a file
over clears the state it would have read; caught by pressing Update on a
project that builds two APKs.

The project-wide Rebuild goes with it, and ProjectState collapses to one
Working(what, status): Pull, Update and moving the checkout differ in
nothing but the label, and the old one said "Pulling and building" for
all three.

Then the two things behind "nothing was flagged as out of date, and Pull
is disabled" after switching ai-app onto another branch:

  - hasWorkWaiting is now one predicate for both the "Up to date" heading
    and whether Update can be pressed, and it counts a component whose
    build no longer matches the checkout. Without that a branch switch
    left the card filed under "Up to date" with only a small note in one
    component's row.

  - A moved checkout re-asks its remote (RemoteChecks::recheck, on top of
    a new Checks::forget). The cached answer was about the branch just
    left and was reported with checkPending false, so a branch one commit
    behind its upstream read as one with nothing to pull. Measured: git
    said [behind 1] while the card said newCommits false.

A component's install button says Install rather than Build where nothing
is built yet, since the button beside it now says Build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 20:56:44 -04:00
1 parent 9efe08e4ce
commit af605211e2
7 files changed
+515 -162

No files matched your search

+61
View File
@@ -551,6 +551,67 @@ mutable at runtime from the phone.
process marked its own errors -- and sequences with no meaning on a
phone are consumed rather than printed, so a cursor movement cannot
arrive looking like a corrupted log.
- **A project's row has three buttons -- Pull, Update, Remove -- and the
first two are the same act at two lengths.** Pull takes the commits and
stops (`?build=false` on the pull route, `BuildState::pull_only`);
Update pulls, builds what that brought in, and installs every APK it
produced. Iris asked for exactly this pair on 2026-09-02, replacing
"Pull & Build" beside a project-wide Rebuild. The point of splitting
them is that bringing a checkout up to date is cheap and something
somebody does while looking, where building and installing is the thing
they press when they want it on the phone -- the same reasoning that
stopped a checkout building.
Update is composed in the app out of the two things it already does
rather than given a route of its own: the pull-and-build reports in the
project's row exactly as Pull does, and each install reports in its own
component's row exactly as that component's own Update does. It skips a
component whose build just failed -- that row already says so, and
asking again would run the same failing command to say it twice -- and
it hands the installer **one** APK at a time. An install intent is
modal and takes the screen, so a project building two APKs would
otherwise have the second intent replace the first and one component
would silently never be installed; the rest wait in
`ComponentState.ReadyToInstall`, which `continuePendingInstalls`
offers when the screen comes back, through the same machinery and the
same two triggers as a download waiting for a wrongly-signed copy to be
removed.
A component's own install button says **Install** rather than "Build"
where nothing is built yet: with the right-hand button now saying
"Build" for a component that is not current, the two read as one word
meaning two things in the same row. It is named for what you end up
with, which is also the half it has that the Build beside it does not.
The project-wide Rebuild is gone with it (each component still has its
own), and with it `ProjectState.Rebuilding` -- the two remaining
project-wide states collapsed into one `Working(what, status)` that
carries its own words, because Pull, Update and moving the checkout
differ in nothing but the label and the old one said "Pulling and
building" for all three.
- **One predicate decides both the "Up to date" heading and whether
Update can be pressed** (`hasWorkWaiting` in `UpdaterScreen.kt`): they
are the same question, since Update is the button that clears
everything the heading is about, and a card filed under "Up to date"
above a pressable Update is the list disagreeing with itself. Three
ways to have work waiting -- commits on the remote, an APK newer than
the copy installed here, and **a component whose build no longer
matches the checkout**. The third was missing, so switching branches
from the phone left the card sitting under "Up to date" with only the
small note in one component's row to say otherwise. Iris found it by
switching ai-app onto another branch and seeing nothing flagged.
- **A moved checkout re-asks its remote, because the last answer was
about the branch it left.** `RemoteChecks::recheck` forgets the cached
answer and starts a fresh check, called from `move_checkout` where the
thing that invalidated it happens. Without the forgetting the card
answers for the previous branch with `checkPending` false -- so a
branch one commit behind its upstream reads as one with nothing to
pull, and Pull is disabled about it with nothing on screen saying the
answer predates the move. Measured, not argued: git said `[behind 1]`
while the card said `newCommits: false`. `Checks::forget` is the
primitive, and it deliberately leaves `in_flight` alone for the reason
`update` does -- a worker already running still owns the right to write
its answer.
- **There are three sizes of refresh, and using the wrong one is what
makes the list feel like it has a mind of its own.** `refresh()` drops
the list to a spinner and asks every remote -- it belongs to arriving,