Move a project's checkout to a branch or commit from the phone

The project settings sheet now lists the checkout's branches and its last
fifty commits, and picking one moves the checkout on the build machine and
builds what that leaves behind. It is the same act as a pull on the same
single checkout, so it goes through the same machinery and reports in the
same place: `after_moving` is the half the two share, which is not the git
command but handing the run over under one lock, so nothing can observe
the moment between the move ending and the builds it decided on starting.

The two lists are read when the sheet opens rather than riding on the
manifest, since both spawn git and the manifest is fetched on every open,
resume and Refresh. Local reads only, so opening the sheet cannot stall on
a network round trip: what the remote has and this checkout has not
fetched is Pull's business, and the card already says when there is any.

Listing branches was wrong twice in ways only a real checkout showed.
`git branch --format` adds a `(HEAD detached at abc123)` pseudo-entry that
is not a branch and cannot be checked out, so the list comes from
`for-each-ref refs/heads`. And `refs/remotes/origin/HEAD` abbreviates to a
bare `origin`, so filtering names that end in `/HEAD` matches nothing and
a phantom branch called `origin` reached the phone; it is dropped by being
a symref instead. The test written to cover the second asserted the same
wrong thing and passed.

Moving is refused over tracked modifications only, where a pull refuses
over any dirt at all. The strict check makes this a one-way door: going
back to a commit from before the .gitignore that covers this project's
build output leaves that output untracked, so the tree is dirty and every
move afterwards is refused -- back but never forward, from a phone, with
the way out being the build machine. Nothing is lost by relaxing it,
because git refuses to overwrite an untracked file itself and carries
across the ones it would not, and its refusal arrives as the error the
card already shows. Found by moving a checkout back and forth rather than
by reading it: the first version passed its tests and trapped the checkout
on the second move.

Picking a commit leaves the checkout on no branch, which the card now says
in those words rather than showing git's literal `HEAD` -- beside a branch
icon that reads as a branch somebody named HEAD. Worth saying now that the
sheet can produce the state, where before it was reachable only on the
build machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 06:41:49 -04:00
1 parent 17d873ab7c
commit 3aa6f2f290
7 files changed
+841 -7

No files matched your search

@@ -881,6 +881,23 @@ private fun AppListScreen(
}
}
/**
* Moves this project's checkout onto a branch or a commit on the build machine, and builds what
* that leaves behind.
*
* 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.
*/
fun startCheckout(entry: ManifestEntry, target: String) {
scope.launch {
followBuild(
entry,
start = { checkoutTarget(entry.key, target) },
progress = { ProjectState.Pulling(it) },
)
}
}
/**
* Build because somebody asked, with nothing to pull and nothing looking stale -- which is the
* only way a project already current with its checkout ever records what it was built from.
@@ -1393,6 +1410,7 @@ private fun AppListScreen(
onSettings = { gitIpv4 ->
manage(entry) { setAppSettings(entry.key, gitIpv4) }
},
onCheckout = { target -> startCheckout(entry, target) },
onApprove = {
manage(entry) { approveDeclaration(entry.key) }
},
@@ -1553,6 +1571,8 @@ private fun AppCard(
onRebuild: () -> Unit,
onRefresh: () -> Unit,
onSettings: (gitIpv4: Boolean) -> Unit,
/** Move this project's checkout onto a branch or commit, and build what that leaves behind. */
onCheckout: (target: String) -> Unit,
onApprove: () -> Unit,
onRemove: () -> Unit,
onSelectVariant: (component: String, ApkVariant?) -> Unit,
@@ -1665,7 +1685,18 @@ private fun AppCard(
Spacer(Modifier.width(4.dp))
Text(
buildString {
append(git.branch)
// git answers `HEAD` when no branch
// is checked out, which beside a
// branch icon reads as a branch
// somebody named HEAD. Worth saying
// in words now that the settings
// sheet can put a checkout in that
// state -- before this it was only
// reachable on the build machine.
append(
if (git.branch == DETACHED_HEAD) "no branch"
else git.branch
)
// Not "new commits", which the colour
// says. And not the lack of an
// upstream either: plenty of
@@ -2040,6 +2071,14 @@ private fun AppCard(
if (settingsOpen) {
ProjectSettingsDialog(
// Nothing can be moved while the checkout is being worked on:
// the server refuses it, and offering it anyway would be a
// control whose only answer is a refusal.
busy = projectState.busy,
onCheckout = { target ->
settingsOpen = false
onCheckout(target)
},
entry = entry,
onDismiss = { settingsOpen = false },
onApply = { gitIpv4 ->
@@ -2065,6 +2104,9 @@ private fun AppCard(
@Composable
private fun ProjectSettingsDialog(
entry: ManifestEntry,
/** Something is already running on this checkout, so it must not be moved. */
busy: Boolean,
onCheckout: (target: String) -> Unit,
onDismiss: () -> Unit,
onApply: (gitIpv4: Boolean) -> Unit,
) {
@@ -2076,7 +2118,7 @@ private fun ProjectSettingsDialog(
onDismissRequest = onDismiss,
title = { Text(entry.label) },
text = {
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -2089,6 +2131,8 @@ private fun ProjectSettingsDialog(
Spacer(Modifier.width(12.dp))
Switch(checked = gitIpv4, onCheckedChange = { gitIpv4 = it })
}
Spacer(Modifier.height(16.dp))
CheckoutSection(entry = entry, busy = busy, onCheckout = onCheckout)
}
},
confirmButton = { TextButton(onClick = { onApply(gitIpv4) }) { Text("Save") } },
@@ -2096,6 +2140,188 @@ private fun ProjectSettingsDialog(
)
}
/**
* Where this project's checkout is, and the two ways to move it.
*
* Both pickers act on the press rather than waiting for Save, and the sheet closes behind them:
* moving the checkout is not a setting, it is a job that starts on the build machine and reports in
* the project's own row like a pull. Save is left meaning what it meant, which is the switch above.
*
* Reading the refs is a round trip, so it happens when the sheet opens rather than riding on the
* manifest -- listing branches and commits spawns git, and the manifest is fetched on every open,
* resume and Refresh. It is deliberately a *local* read on that side, so this cannot sit waiting on
* a network round trip before the sheet is usable.
*
* Every reason the pickers cannot be used is said rather than left to be inferred from a control
* 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.
*/
@Composable
private fun CheckoutSection(
entry: ManifestEntry,
busy: Boolean,
onCheckout: (target: String) -> Unit,
) {
var refs by remember(entry.key) { mutableStateOf<CheckoutRefs?>(null) }
var failure by remember(entry.key) { mutableStateOf<String?>(null) }
val git = entry.git
// Only for a project actually in a checkout; asking git about a
// directory that is not one would fail for a reason that is not a
// fault.
if (git != null) {
LaunchedEffect(entry.key) {
try {
refs = withContext(Dispatchers.IO) { checkoutRefs(entry.key) }
} catch (e: DownloadServerException) {
failure = e.message ?: "couldn't read this checkout's branches"
}
}
}
SettingsHeading("Checkout")
when {
git == null -> {
SettingsNote("This project is not in a git repository, so there is nothing to move.")
return
}
// Said before the pickers are drawn, because it is the reason
// they will refuse rather than something to discover by pressing.
git.dirty ->
SettingsNote(
"The checkout has uncommitted changes. Moving it would go over them, so commit " +
"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 leaves it on no branch until a branch " +
"is picked again."
)
}
val enabled = !busy && !git.dirty && refs != null
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))
BranchPicker(
branches = loaded?.branches.orEmpty(),
// The branch line on the card is the same answer, and it is
// the one already on screen -- taking it from there rather
// than working one out keeps the two from disagreeing.
current = git.branch,
enabled = enabled,
onSelect = onCheckout,
)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text("Commit", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
CommitPicker(
commits = loaded?.commits.orEmpty(),
enabled = enabled,
onSelect = onCheckout,
)
}
}
@Composable
private fun BranchPicker(
branches: List<GitBranch>,
current: String,
enabled: Boolean,
onSelect: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { expanded = true }, enabled = enabled && branches.isNotEmpty()) {
// "HEAD" is what git says when no branch is checked out. Said
// in words here, because a reader has no way to know that the
// literal string is a state rather than a branch somebody
// made.
Text(
if (current == DETACHED_HEAD) "none" else current,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
branches.forEach { branch ->
DropdownMenuItem(
text = {
Text(
buildString {
append(branch.name)
if (branch.current) append("")
// Worth saying: moving to one of these
// starts a local branch rather than
// returning to one.
if (branch.remoteOnly) append(" (on the remote)")
}
)
},
onClick = {
expanded = false
if (!branch.current) onSelect(branch.name)
},
)
}
}
}
}
@Composable
private fun CommitPicker(commits: List<GitCommit>, enabled: Boolean, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val here = commits.firstOrNull { it.current }
Box {
TextButton(onClick = { expanded = true }, enabled = enabled && commits.isNotEmpty()) {
Text(here?.short ?: "\u2014", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
commits.forEach { commit ->
DropdownMenuItem(
text = {
// The hash identifies it and the subject is what
// a person recognises, so both -- and the subject
// is the half that truncates, since a cut hash
// names nothing.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
commit.short,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Spacer(Modifier.width(8.dp))
Text(
if (commit.current) "${commit.subject}" else commit.subject,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
onClick = {
expanded = false
if (!commit.current) onSelect(commit.sha)
},
)
}
}
}
}
/** What git reports as the branch when no branch is checked out. */
private const val DETACHED_HEAD = "HEAD"
/**
* How far along whatever is on its way to this phone is.
*