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:
1 parent
17d873ab7c
commit
3aa6f2f290
7 files changed
+841
-7
No files matched your search
@@ -164,6 +164,58 @@ fun setAppSettings(key: String, gitIpv4: Boolean) {
|
||||
) {}
|
||||
}
|
||||
|
||||
/** One branch this project's checkout could be moved to. */
|
||||
data class GitBranch(val name: String, val current: Boolean, val remoteOnly: Boolean)
|
||||
|
||||
/** One commit it could be moved to. [sha] is what gets sent; [short] is what gets read. */
|
||||
data class GitCommit(
|
||||
val sha: String,
|
||||
val short: String,
|
||||
val subject: String,
|
||||
val at: Long,
|
||||
val current: Boolean,
|
||||
)
|
||||
|
||||
/** What the project settings sheet's two pickers are built from. */
|
||||
data class CheckoutRefs(val branches: List<GitBranch>, val commits: List<GitCommit>)
|
||||
|
||||
/**
|
||||
* The branches and recent commits of one project's checkout.
|
||||
*
|
||||
* Fetched when the settings sheet is opened rather than carried on the manifest: reading them
|
||||
* spawns git on the build machine, and the manifest is fetched on every open, resume and Refresh.
|
||||
* Local reads only there, so this cannot stall on a network round trip -- what the remote has and
|
||||
* 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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** One component's log, as the modal shows it. */
|
||||
/**
|
||||
* Which of a component's two logs to read.
|
||||
|
||||
@@ -74,8 +74,12 @@ data class BuildProgressCount(val done: Long, val total: Long)
|
||||
// answers both routes immediately either way -- a rebuild it kicks off runs
|
||||
// in a background thread there, not inline with the request -- so neither
|
||||
// needs the multi-minute read timeout an actual build would.
|
||||
private fun requestBuildStatus(path: String, method: String): BuildStatus =
|
||||
requestFromServer(path, method) { connection ->
|
||||
private fun requestBuildStatus(
|
||||
path: String,
|
||||
method: String,
|
||||
jsonBody: String? = null,
|
||||
): BuildStatus =
|
||||
requestFromServer(path, method, jsonBody = jsonBody) { connection ->
|
||||
val json = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val components = json.optJSONArray("components")
|
||||
BuildStatus(
|
||||
@@ -119,6 +123,22 @@ private fun requestBuildStatus(path: String, method: String): BuildStatus =
|
||||
fun pullAndBuild(key: String, force: Boolean = false): BuildStatus =
|
||||
requestBuildStatus("/apps/$key/pull" + if (force) "?force=true" else "", "POST")
|
||||
|
||||
/**
|
||||
* Moves the checkout onto a branch or a commit on the build machine, then builds what that left
|
||||
* behind.
|
||||
*
|
||||
* Answers a build status like a pull does, and is here beside it rather than with the other
|
||||
* management calls for exactly that reason: it is the same act on the same single checkout, so it
|
||||
* reports through the one progress path the card already polls instead of a second one to keep in
|
||||
* step.
|
||||
*/
|
||||
fun checkoutTarget(key: String, target: String): BuildStatus =
|
||||
requestBuildStatus(
|
||||
"/apps/$key/checkout",
|
||||
"POST",
|
||||
jsonBody = JSONObject().put("target", target).toString(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds one component if its staleness rule says so, before it is downloaded.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user