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

@@ -1,5 +1,6 @@
package com.example.devupdater
import java.net.HttpURLConnection
import org.json.JSONArray
import org.json.JSONObject
@@ -200,34 +201,62 @@ data class CheckoutRefs(
* the checkout has not fetched is Pull's business, and the card already says when there is any.
*/
fun checkoutRefs(key: String): CheckoutRefs =
requestFromServer("/apps/$key/refs") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
val branches = body.getJSONArray("branches")
val commits = body.getJSONArray("commits")
CheckoutRefs(
branches =
(0 until branches.length()).map { i ->
val branch = branches.getJSONObject(i)
GitBranch(
name = branch.getString("name"),
current = branch.optBoolean("current", false),
remoteOnly = branch.optBoolean("remoteOnly", false),
)
},
commits =
(0 until commits.length()).map { i ->
val commit = commits.getJSONObject(i)
GitCommit(
sha = commit.getString("sha"),
short = commit.getString("short"),
subject = commit.optString("subject"),
at = commit.optLong("at", 0),
current = commit.optBoolean("current", false),
)
},
head = body.optString("head").takeIf { it.isNotEmpty() },
)
}
requestFromServer("/apps/$key/refs", readBody = ::refs)
/**
* Brings the build machine's checkout up to date with its remote, and answers with the refs that
* leaves.
*
* The one call in this sheet that reaches the network, and the only way a branch pushed from
* another machine ever appears in the picker: nothing on the build machine writes a new
* remote-tracking ref except the fetch inside a pull, and a pull is only offered when the branch
* you are already on is behind. Answering with the refs rather than an acknowledgement is what
* makes one round trip enough -- reading them again afterwards would leave a moment where the
* button had finished and the list was still the old one.
*/
fun fetchRefs(key: String): CheckoutRefs =
requestFromServer(
"/apps/$key/fetch",
method = "POST",
// Longer than the build machine's own hard stop for a remote
// command, so a slow remote is reported in git's words rather
// than as this side giving up on a request that is still running.
readTimeoutMs = FETCH_READ_TIMEOUT_MS,
readBody = ::refs,
)
/** Beyond the 30s the build machine allows a remote command, so its answer arrives first. */
private const val FETCH_READ_TIMEOUT_MS = 35000
/** The one reader for [checkoutRefs] and [fetchRefs], so the two cannot read a checkout apart. */
private fun refs(connection: HttpURLConnection): CheckoutRefs {
val body = JSONObject(connection.inputStream.bufferedReader().readText())
val branches = body.getJSONArray("branches")
val commits = body.getJSONArray("commits")
return CheckoutRefs(
branches =
(0 until branches.length()).map { i ->
val branch = branches.getJSONObject(i)
GitBranch(
name = branch.getString("name"),
current = branch.optBoolean("current", false),
remoteOnly = branch.optBoolean("remoteOnly", false),
)
},
commits =
(0 until commits.length()).map { i ->
val commit = commits.getJSONObject(i)
GitCommit(
sha = commit.getString("sha"),
short = commit.getString("short"),
subject = commit.optString("subject"),
at = commit.optLong("at", 0),
current = commit.optBoolean("current", false),
)
},
head = body.optString("head").takeIf { it.isNotEmpty() },
)
}
/** One component's log, as the modal shows it. */
/**
@@ -84,6 +84,21 @@ data class GitStatus(
val root: String,
)
/**
* What a build button says for the components it covers.
*
* **Rebuild** only where every one of them is known to match the checkout, because that is the case
* where pressing it does the same work over again. Anything else -- behind the checkout, or never
* built, or parked on a commit nobody measured the output against -- has a build to make rather
* than one to repeat, and saying "Rebuild" there reads as though the work had already been done.
*
* One rule at both scales, so a project's button and its components' cannot come to disagree about
* what pressing them means. Iris asked for the word to follow the state on 2026-09-02, when moving
* a checkout stopped building what it left behind.
*/
fun buildWord(components: List<ProjectComponent>): String =
if (components.all { it.freshness == "current" }) "Rebuild" else "Build"
// One thing a project produces. [kind] is "apk" (installed on this phone)
// or "server" (installed and run on the build machine).
//
@@ -38,6 +38,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
@@ -895,11 +896,13 @@ private fun AppListScreen(
}
/**
* Moves this project's checkout onto a branch or a commit on the build machine, and builds what
* that leaves behind.
* Moves this project's checkout onto a branch or a commit on the build machine, and builds
* nothing.
*
* Followed exactly as a pull is, because it is the same act on the same single checkout: one
* progress path, reported in the project's own row, rather than a second one to keep in step.
* It is over as soon as git is, though, since there is no build behind it -- what the move
* leaves behind is a component the card reports as out of date, with its own Build beside it.
*/
fun startCheckout(entry: ManifestEntry, target: String) {
scope.launch {
@@ -2035,7 +2038,12 @@ private fun AppCard(
// behind is not obvious from here.
colors = ActionTone.Caution.colors(),
) {
Text("Rebuild")
// Asked of the components this actually builds:
// one with no command of its own has no output to
// be current with anything, and counting it would
// leave every such project's button saying Build
// for ever.
Text(buildWord(entry.components.filter { it.hasBuild }))
}
}
if (awaitingApproval) {
@@ -2226,6 +2234,12 @@ private fun ProjectSettingsDialog(
* that does nothing: not a checkout at all, something already running on it, uncommitted work in
* the way, still reading, or the read failed. Those are five different things to do next, and a
* disabled dropdown with no sentence beside it is the same picture for all of them.
*
* Fetch is the third control and the only one that touches the network. It is what puts a branch
* pushed from another machine into the picker above it -- without it the list is whatever the last
* pull happened to bring in, so a project already up to date on its own branch could never be moved
* onto a new one. It stays a press rather than something the sheet does on opening, because a fetch
* writes into the checkout and pulls down objects: opening a sheet should not do either.
*/
@Composable
private fun CheckoutSection(
@@ -2235,6 +2249,8 @@ private fun CheckoutSection(
) {
var refs by remember(entry.key) { mutableStateOf<CheckoutRefs?>(null) }
var failure by remember(entry.key) { mutableStateOf<String?>(null) }
var fetching by remember(entry.key) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val git = entry.git
// Only for a project actually in a checkout; asking git about a
@@ -2250,7 +2266,6 @@ private fun CheckoutSection(
}
}
SettingsHeading("Checkout")
when {
git == null -> {
SettingsNote("This project is not in a git repository, so there is nothing to move.")
@@ -2264,25 +2279,14 @@ private fun CheckoutSection(
"or stash on the build machine first."
)
busy -> SettingsNote("Something is already running on this checkout.")
else ->
SettingsNote(
"Moves the checkout on the build machine and builds what that leaves behind, the " +
"same as Pull does. Picking a commit parks it there, on no branch; HEAD is " +
"the way back to following the branch."
)
}
val enabled = !busy && !git.dirty && refs != null
// A fetch replaces the very lists these two are showing, so they are
// disabled while it runs rather than left offering the old answer.
val enabled = !busy && !git.dirty && refs != null && !fetching
val loaded = refs
if (loaded == null && failure == null) {
SettingsNote("Reading this checkout's branches...")
}
// The server's own words, so selectable like every other machine
// output here -- the fix is on the other machine.
failure?.let { OutputText(it, style = MaterialTheme.typography.bodySmall) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text("Branch", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
SettingRow("Branch") {
BranchPicker(
branches = loaded?.branches.orEmpty(),
// The branch line on the card is the same answer, and it is
@@ -2293,8 +2297,7 @@ private fun CheckoutSection(
onSelect = onCheckout,
)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text("Commit", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
SettingRow("Commit") {
CommitPicker(
commits = loaded?.commits.orEmpty(),
head = loaded?.head,
@@ -2306,6 +2309,81 @@ private fun CheckoutSection(
onSelect = onCheckout,
)
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = {
fetching = true
failure = null
scope.launch {
try {
refs = withContext(Dispatchers.IO) { fetchRefs(entry.key) }
} catch (e: DownloadServerException) {
failure = e.message ?: "the fetch failed"
} finally {
fetching = false
}
}
},
// Not while the checkout is being worked on: a pull is
// writing the same refs, and git would refuse one of the two
// for a reason nobody pressed anything to hear. Uncommitted
// work is deliberately *not* a reason -- a fetch touches no
// file in the working tree, so this is the one control in
// here that still does its job on a dirty checkout.
enabled = !busy && !fetching,
colors = ActionTone.Primary.colors(),
) {
if (fetching) {
// The button's own content colour, which while it is
// running is the disabled one -- a full-strength mark
// beside a dimmed label reads as two things happening
// rather than as one button that is busy.
Working(color = LocalContentColor.current)
Spacer(Modifier.width(8.dp))
}
Text("Fetch")
}
}
// Both of these belong *below* the controls rather than above them.
// Above, each one appears and disappears in the middle of the sheet
// and shoves the two pickers down the screen as it does -- so the row
// somebody is reaching for moves while they reach, and the failure
// ends up nowhere near the button that produced it.
if (loaded == null && failure == null) {
SettingsNote("Reading this checkout's branches...")
}
// The server's own words, so selectable like every other machine
// output here -- the fix is on the other machine.
failure?.let { OutputText(it, style = MaterialTheme.typography.bodySmall) }
}
/**
* One setting: what it is on the left, the control for it on the right.
*
* The two get [Modifier.weight] rather than the control being left to take whatever width it likes,
* because a picker's label is a branch name and those run long: unweighted, the pill grows to fit
* `second-branch-from-elsewhere` and squeezes "Branch" down to a column three characters wide,
* which then wraps one letter per line. Both sides truncate instead -- the control at its own end,
* since a pill that has to be cut is still recognisably a control, and the name it is showing is
* the thing the reader can open it to see in full.
*/
@Composable
private fun SettingRow(label: String, control: @Composable () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Box(Modifier.weight(2f), contentAlignment = Alignment.CenterEnd) { control() }
}
}
@Composable
@@ -2610,7 +2688,7 @@ private fun ComponentBuildButton(
enabled = !state.busy && !projectState.busy,
colors = ActionTone.Caution.colors(),
) {
Text("Rebuild")
Text(buildWord(listOf(component)))
}
}
@@ -2,8 +2,10 @@ package com.example.devupdater
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
@@ -15,11 +17,15 @@ import androidx.compose.ui.unit.dp
*
* One composable rather than a size and a stroke width repeated per site, so every one of them
* reads as the same mark.
*
* [color] is for the one inside a button, where the mark belongs to that button's label and taking
* the scheme's own accent instead would read as a second thing going on beside it.
*/
@Composable
fun Working(modifier: Modifier = Modifier) {
fun Working(modifier: Modifier = Modifier, color: Color = ProgressIndicatorDefaults.circularColor) {
CircularProgressIndicator(
modifier = modifier.size(12.dp),
color = color,
strokeWidth = 1.5.dp,
)
}