Offer the rollback, and call it Downgrade

Picking an old commit from the phone detaches HEAD, so every rollback is
parked by construction -- and a parked checkout withheld the commit
comparison entirely. The card answered the move with "up to date" and
nothing to press, which left a rollback unfinishable from the phone.

The withholding was right for one direction only. A checkout parked past
what was built is somebody looking, and nagging about it is nagging about
a decision. A checkout parked *behind* it is somebody rolling back, and
there the point of the move is to get that commit built and installed.

So `git::is_ancestor` asks which of the two commits is the later one --
one extra process, and only where they already differ -- and
`Freshness::BuiltAhead` says the build is. It is stale like `Behind`, so
the card leaves "Up to date" and both Update buttons come alive;
`updateWord` then says Downgrade, at both scales and by one rule, the way
`buildWord` does. Nothing else about the press differs, which is what was
asked for. The row says "newer than the checkout" rather than "out of
date", which is the wrong direction.

Two things fell out of it. `UpdateButton` asks about the direction before
comparing file times, since what is on disk stays older than the phone's
copy right up until the press rebuilds it -- otherwise a card with a
rollback waiting says "Reinstall". And a project-wide Update now does the
build-and-install half alone when there is nothing to pull *from* -- a
parked checkout, a branch tracking nothing, or `gitPull` off -- rather
than failing on a pull it was never going to make, which is what a
downgrade press used to hit.

Verified end to end against a throwaway checkout driven through a scratch
server: `current` on the built commit, `builtAhead` once parked back on
the one before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuRzBWJuEGaWmMhDuX3X3p
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 22:57:55 -04:00
1 parent c41c36f25d
commit 283b971186
5 files changed
+287 -52

No files matched your search

@@ -1140,12 +1140,24 @@ private fun AppListScreen(
*/
fun startProjectUpdate(entry: ManifestEntry, force: Boolean = false) {
scope.launch {
// A checkout with nothing to pull *from* is the second half
// alone: build what is there and install it. That covers a
// branch tracking nothing and a project told never to be moved
// from a phone -- and it is the whole of what a rollback
// wants, since picking an old commit detaches HEAD. Pulling
// regardless is how this answered a parked checkout with
// "branch HEAD tracks no upstream", from the button the card
// had just enabled to offer the downgrade.
val built =
followBuild(
entry,
start = { pullProject(entry.key, force) },
progress = { ProjectState.Working("Updating", it) },
) ?: return@launch
if (entry.canPull) {
followBuild(
entry,
start = { pullProject(entry.key, force) },
progress = { ProjectState.Working("Updating", it) },
) ?: return@launch
} else {
null
}
val fresh =
(manifestState as? ManifestState.Loaded)?.manifest?.entries?.firstOrNull {
it.key == entry.key
@@ -1162,7 +1174,7 @@ private fun AppListScreen(
// Not the one whose build just failed: its row already
// says so, and asking for it again would run the same
// failing command a second time to say it twice.
if (built.component(component.name)?.error != null) continue
if (built?.component(component.name)?.error != null) continue
val handedOver =
updateComponent(fresh, component.name, offerInstall = !installerTaken)
installerTaken = installerTaken || handedOver
@@ -2189,6 +2201,7 @@ private fun AppCard(
needsBuild = entry.needsBuild,
installed = installed != null,
upToDate = upToDate,
freshness = component.freshness,
state = componentState,
projectState = projectState,
onUpdate = { onUpdate(entry, component.name) },
@@ -2302,7 +2315,7 @@ private fun AppCard(
!projectBusy && hasWorkWaiting(entry, installedTimes, chosenVariants),
colors = ActionTone.Primary.colors(),
) {
Text("Update")
Text(updateWord(entry.components.map { it.freshness }))
}
}
if (awaitingApproval) {
@@ -2861,6 +2874,12 @@ private fun UpdateButton(
/** Whether this phone has the app at all, which decides "Install". */
installed: Boolean,
upToDate: Boolean,
/**
* This component's build against the build machine's checkout, which is what tells an update
* from a downgrade. The freshness word itself rather than a flag, so there is one place that
* knows which values mean which.
*/
freshness: String,
/** What this component is doing, which is what decides whether the button can be pressed. */
state: ComponentState?,
/**
@@ -2902,11 +2921,19 @@ private fun UpdateButton(
// newer one -- the same thing Start and Install are elsewhere, and
// coloured to match them.
!installed -> "Install" to ActionTone.Go
// Before the "already have this" case below, and deliberately:
// what is on disk here is older than this phone's copy right up
// until the press rebuilds it at the checkout's commit, so
// comparing the two files answers "Reinstall" to a card that has
// a rollback waiting. Coloured like Update, because it is Update
// -- the direction is the word, and going back to last week's
// build is a thing somebody chose rather than one to warn about.
freshness == "builtAhead" -> updateWord(listOf(freshness)) to ActionTone.Primary
// Reinstalling replaces a build with the same build -- the same "are
// you sure that's what you meant" as a Restart, and coloured to
// match it.
upToDate -> "Reinstall" to ActionTone.Caution
else -> "Update" to ActionTone.Primary
else -> updateWord(listOf(freshness)) to ActionTone.Primary
}
// Disabled while something is running, rather than replaced by the bar
// that reports it. A control that disappears takes the reader's
@@ -3131,9 +3158,11 @@ private fun componentBuild(
* it talks to. Building in order used to make that impossible by never reaching the later component
* at all; building in parallel buys the time back and gives that up, so it is said instead.
*
* Only when *this* component is current and another is behind. Both behind is the ordinary state of
* a project nobody has built yet, and those two still match each other — warning about it would
* fire on every card with work waiting, which is how a warning stops being read.
* Only when *this* component is current and another is on a different commit -- behind the
* checkout, or ahead of it because somebody moved the checkout back. Both on the same wrong commit
* is the ordinary state of a project nobody has built yet, and those two still match each other —
* warning about it would fire on every card with work waiting, which is how a warning stops being
* read.
*
* Said rather than prevented, and the button is left alone: sometimes the mismatch is exactly what
* somebody wants to install, and hiding the control would not stop them so much as leave them
@@ -3142,17 +3171,17 @@ private fun componentBuild(
@Composable
private fun MismatchedPairNote(self: ProjectComponent, others: List<ProjectComponent>) {
if (self.freshness != "current") return
val behind = others.filter { it.name != self.name && it.freshness == "behind" }
if (behind.isEmpty()) return
val names = behind.joinToString(", ") { it.name }
val differing = others.filter { it.name != self.name && it.commitDiffers }
if (differing.isEmpty()) return
// Each one said in `freshnessNote`'s own words rather than in words of
// this note's -- a second word for the same measurement reads as a
// second, weaker signal rather than the same one said twice, and the
// two are drawn from the same field. Non-null for every freshness in
// `commitDiffers`, which is the whole of what got here.
val said = differing.joinToString(", ") { "${it.name} is ${it.freshnessNote}" }
Text(
// "Out of date" rather than "older": that is already the word
// `freshnessNote` uses for this exact freshness ("behind"), and a
// second word for the same measurement reads as a second, weaker
// signal rather than the same one said twice -- worth avoiding
// since this note and that one are drawn from the same field.
if (behind.size == 1) "$names is out of date, so the two would not match."
else "$names are out of date, so they would not match.",
if (differing.size == 1) "$said, so the two would not match."
else "$said, so they would not match.",
style = MaterialTheme.typography.bodySmall,
color = ActionTone.Caution.color,
)