A component's build progress reads under its buttons, and an unrelated history can be overridden

Two things, both about a card being able to act on what it says.

The progress bar, its counts and the last line a component printed now
sit below that component's buttons rather than above them. A bar reports
on the press that started it, so it reads in the order it happened -- and
above, it pushed the buttons down the moment a build began, moving the row
somebody had just pressed out from under their finger.

A pull that cannot fast-forward because the checkout shares no history
with its upstream now offers a way past it. There is no fast-forward
between two unrelated histories and there never will be, so the card was
one that could never be pulled again, with the only remedy on the build
machine -- exactly where the person holding the phone isn't. The card
reports the failure as before and a dialog offers a forced pull, naming
the branch and the upstream it is about to overwrite; confirming sends
?force=true, which resets onto the upstream instead of merging.

Whether it *is* that failure is decided structurally, by `git merge-base`
finding no common ancestor, rather than by matching what git printed:
those messages are translated, and a button that appeared only on an
English build machine would be worse than no button. It travels to the
phone as its own field for the same reason. The dirty-tree refusal stays
in front of it, so a forced pull can only ever discard something that was
committed, and a merely diverged history is not offered it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 22:03:44 -04:00
1 parent b0e83059a3
commit db47972a25
6 files changed
+306 -29

No files matched your search

@@ -17,6 +17,13 @@ data class BuildStatus(
val stale: Boolean,
val building: Boolean,
val error: String?,
// The failure above was a pull with no fast-forward to make, because
// the checkout on the build machine shares no commit with its
// upstream. The one failure this app offers a way past, and it is
// said as a fact of its own rather than read out of [error]: git's
// wording is translated, so a button that matched on it would appear
// only on an English build machine.
val unrelatedHistories: Boolean,
// What the whole *project* is doing ("fetching", "pulling"), and how
// long this run has taken. Work belonging to one component is in
// [components] instead, because that is where it is drawn.
@@ -66,6 +73,7 @@ private fun requestBuildStatus(path: String, method: String): BuildStatus =
stale = json.getBoolean("stale"),
building = json.getBoolean("building"),
error = if (json.isNull("error")) null else json.getString("error"),
unrelatedHistories = json.optBoolean("unrelatedHistories", false),
phase = if (json.isNull("phase")) null else json.optString("phase").ifEmpty { null },
elapsedMs = json.optLong("elapsedMs", 0),
components =
@@ -93,7 +101,16 @@ private fun requestBuildStatus(path: String, method: String): BuildStatus =
)
}
fun pullAndBuild(key: String): BuildStatus = requestBuildStatus("/apps/$key/pull", "POST")
/**
* Fetches and fast-forwards the checkout on the build machine, then builds it.
*
* [force] resets the checkout onto its upstream instead, throwing away every commit it has that the
* remote doesn't. Only ever sent after a plain pull has come back saying the two histories are
* unrelated, and only after the dialog that says so has been confirmed -- there is no fast-forward
* for that case ever, so the alternative is a project that can never be pulled again.
*/
fun pullAndBuild(key: String, force: Boolean = false): BuildStatus =
requestBuildStatus("/apps/$key/pull" + if (force) "?force=true" else "", "POST")
fun prepareBuild(key: String): BuildStatus = requestBuildStatus("/apps/$key/prepare", "POST")
@@ -428,6 +428,11 @@ private fun AppListScreen(
// local storage rather than the manifest. Held as state so picking one
// redraws the card without a round trip.
var chosenVariants by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// The project whose pull came back saying its checkout and its remote
// share no history, waiting on an answer about throwing that history
// away. One slot rather than one per card: it is a modal, so only one
// can be open, and the entry in it says which card asked.
var forcePull by remember { mutableStateOf<ManifestEntry?>(null) }
// Which component of which project has a service action in flight.
// Per project, because that is the granularity of a card, and the row
// that is busy is the one that shows it.
@@ -625,6 +630,10 @@ private fun AppListScreen(
val failure = status.error
if (failure != null) {
cardStates = cardStates + (entry.key to CardState.Error(failure, retryPull))
// The card keeps the message either way -- the dialog is
// dismissible, and a failure that vanished with it would
// leave the card looking as though nothing had happened.
if (status.unrelatedHistories) forcePull = entry
return
}
// The APK's mtime is what decides "update available", so the
@@ -669,13 +678,18 @@ private fun AppListScreen(
}
}
/** Pull acts on the build machine: fetch, fast-forward, rebuild. */
fun startPull(entry: ManifestEntry) {
/**
* Pull acts on the build machine: fetch, fast-forward, rebuild.
*
* [force] is the answer to the dialog below, and nothing else ever passes it: it abandons
* whatever history that checkout has of its own.
*/
fun startPull(entry: ManifestEntry, force: Boolean = false) {
scope.launch {
followBuild(
entry,
retryPull = true,
start = { pullAndBuild(entry.key) },
start = { pullAndBuild(entry.key, force) },
progress = { CardState.Pulling(it) },
)
}
@@ -1073,9 +1087,67 @@ private fun AppListScreen(
) {
Text(PLUS_GLYPH, fontFamily = NerdIcons, fontSize = 22.sp)
}
forcePull?.let { entry ->
ForcePullDialog(
entry = entry,
onDismiss = { forcePull = null },
onForce = {
forcePull = null
startPull(entry, force = true)
},
)
}
}
}
/**
* Offered when a pull comes back saying the checkout and its remote share no history at all.
*
* There is no fast-forward between two unrelated histories and there never will be, so without this
* the card is one that can never be pulled again — and the way out is on the build machine, which
* is exactly where the person holding the phone isn't. So the capability is shown rather than
* withheld, with what it costs said in front of it: the alternative to a destructive button here is
* not safety, it is a dead card.
*
* The branch and its upstream are named because they are what is about to be overwritten, and this
* is the only place they are seen before it happens.
*/
@Composable
private fun ForcePullDialog(entry: ManifestEntry, onDismiss: () -> Unit, onForce: () -> Unit) {
// Named where they are known. Pull is only offered for a checkout
// with an upstream, so the fallbacks are for a list that has moved on
// since the failure rather than for the ordinary case.
val branch = entry.git?.branch ?: "the branch"
val upstream = entry.git?.upstream ?: "the remote"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("${entry.label} shares no history with $upstream") },
text = {
Text(
"Its checkout on the build machine has no commit in common with $upstream, so " +
"there is no fast-forward to make and Pull can go no further.\n\n" +
"Forcing resets $branch onto $upstream. Every commit the build machine has " +
"that the remote doesn't is abandoned -- they stay in that checkout's " +
"reflog, but nothing on this phone will bring them back.\n\n" +
"Uncommitted changes are not touched: a pull refuses over those before it " +
"ever gets this far."
)
},
confirmButton = {
TextButton(
// The red every control that takes something away wears,
// so this doesn't read as the ordinary way past a message.
colors = ActionTone.Destructive.colors(),
onClick = onForce,
) {
Text("Force pull")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun AppCard(
entry: ManifestEntry,
@@ -2006,13 +2078,6 @@ private fun ComponentCard(
// together -- controls belong to an APK, service buttons to a
// server -- so each gets the card's full width instead of
// sharing a row. A progress bar in particular wants all of it.
// Before the controls, and inside this component's own card:
// what is happening to *this* component belongs with it, not
// under the project where it could be any of them.
build?.let {
Spacer(Modifier.height(6.dp))
ComponentBuildProgress(it)
}
controls()
// Nothing to offer until its script has been asked: the buttons
// depend on the answer, and guessing which to show would mean
@@ -2073,6 +2138,16 @@ private fun ComponentCard(
}
}
}
// Under the buttons, and inside this component's own card. It
// reports on the press that started it, so it reads in the
// order it happened -- and putting it above meant the buttons
// moved down the moment a build began, so the row somebody had
// just pressed slid out from under their finger.
build?.let {
Spacer(Modifier.height(6.dp))
ComponentBuildProgress(it)
}
}
}