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

@@ -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)))
}
}