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:
1 parent
b0e83059a3
commit
db47972a25
6 files changed
+306
-29
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user