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:
1 parent
f983db154d
commit
25c069909b
8 files changed
+480
-90
No files matched your search
@@ -340,22 +340,37 @@ mutable at runtime from the phone.
|
|||||||
remote deliberately, and a card that mentions it every time is nagging
|
remote deliberately, and a card that mentions it every time is nagging
|
||||||
about a choice somebody made.
|
about a choice somebody made.
|
||||||
|
|
||||||
- **A checkout can be moved from the phone, and it is the same act as a
|
- **A checkout can be moved from the phone, and it is nearly the same act
|
||||||
pull.** The project settings sheet lists the checkout's branches and its
|
as a pull.** The project settings sheet lists the checkout's branches and
|
||||||
last fifty commits, and picking one moves the checkout and then builds
|
its last fifty commits, and picking one moves the checkout.
|
||||||
whatever that left behind. `BuildState::after_moving` is the half a pull
|
`BuildState::after_moving` is the half a pull and a checkout share -- one
|
||||||
and a checkout share -- one copy, because the part that is easy to get
|
copy, because the part that is easy to get wrong is not the git command
|
||||||
wrong is not the git command but handing the run over under a single
|
but handing the run over under a single lock, so nothing can observe the
|
||||||
lock, so nothing can observe the moment between the move ending and the
|
moment between the move ending and the builds it decided on starting. It
|
||||||
builds it decided on starting. It reports through the project's own
|
reports through the project's own state for the same reason: one progress
|
||||||
state for the same reason: one progress path, not a second to keep in
|
path, not a second to keep in step.
|
||||||
step.
|
**Where they differ is that a move builds nothing** (`then_build` is
|
||||||
|
`None`, which is what `BuildAfterMoving` exists to say). A pull is
|
||||||
|
somebody taking new work, so building it is the point; moving the
|
||||||
|
checkout is somebody *looking* -- at another branch, at last week's
|
||||||
|
commit -- and charging a full build of every component for a look means
|
||||||
|
an accidental tap starts a minute of work with no way to cancel it, and
|
||||||
|
replaces outputs that were wanted. What it leaves behind is a component
|
||||||
|
whose build no longer matches the checkout, which the card already says
|
||||||
|
and already carries the button for. Iris asked for this on 2026-09-02.
|
||||||
|
Which is why **the build button's word follows the state**: `buildWord`
|
||||||
|
in `UpdateManifest.kt` says `Rebuild` only where every component it
|
||||||
|
covers is *known* current, and `Build` otherwise -- behind, never built,
|
||||||
|
or parked on a commit nobody measured the output against. One rule at
|
||||||
|
both scales, so the project row and a component row cannot come to
|
||||||
|
disagree about what pressing them means; the project's asks only about
|
||||||
|
the components it actually builds, since one with no command has no
|
||||||
|
output to be current with anything and counting it would pin that button
|
||||||
|
to `Build` for ever.
|
||||||
The two lists are read by `GET /apps/{key}/refs` when the sheet is
|
The two lists are read by `GET /apps/{key}/refs` when the sheet is
|
||||||
opened, never on the manifest -- both spawn git. They are **local reads
|
opened, never on the manifest -- both spawn git, and both are **local
|
||||||
only**: what the remote has and this checkout has not fetched is not
|
reads**, so opening the sheet cannot stall on a round trip or fail the
|
||||||
offered, because Pull is what brings those in and the card already says
|
way a remote check can.
|
||||||
when there are some, so opening the sheet cannot stall on a round trip
|
|
||||||
or fail the way a remote check can.
|
|
||||||
Two things about listing branches were wrong until a real checkout was
|
Two things about listing branches were wrong until a real checkout was
|
||||||
looked at, and neither shows up in a fixture built to pass:
|
looked at, and neither shows up in a fixture built to pass:
|
||||||
`git branch --format` adds a **`(HEAD detached at abc123)` pseudo-entry**
|
`git branch --format` adds a **`(HEAD detached at abc123)` pseudo-entry**
|
||||||
@@ -367,6 +382,54 @@ mutable at runtime from the phone.
|
|||||||
test that was meant to cover the second asserted the same wrong thing
|
test that was meant to cover the second asserted the same wrong thing
|
||||||
and passed.
|
and passed.
|
||||||
|
|
||||||
|
- **Fetch is a button, because nothing else here writes a
|
||||||
|
remote-tracking ref.** The only `git fetch` this server ran was the one
|
||||||
|
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 only 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. The way
|
||||||
|
out was a terminal on the build machine, which is where the person
|
||||||
|
holding the phone is not. `POST /apps/{key}/fetch` is the fix, sitting
|
||||||
|
under the two pickers in the project settings sheet. Deliberately still
|
||||||
|
not on a timer and not on opening the sheet: a fetch mutates the checkout
|
||||||
|
and pulls down objects, so it stays something somebody pressed, and
|
||||||
|
`/refs` stays the local read described above.
|
||||||
|
Both pickers sit in a `SettingRow`, which weights the label *and* the
|
||||||
|
control rather than letting the control take what it likes: unweighted,
|
||||||
|
a pill showing `second-branch-from-elsewhere` squeezes "Branch" into a
|
||||||
|
three-character column that wraps one letter per line. The same rule the
|
||||||
|
component rows already follow, in the other place a row mixes text with
|
||||||
|
a control. The fetch failure and the "reading branches" note go
|
||||||
|
*below* both pickers for the same family of reason -- above them, each
|
||||||
|
appears and disappears mid-sheet and shoves the pickers down the screen
|
||||||
|
as somebody is reaching for one, and the failure ends up nowhere near
|
||||||
|
the button that produced it.
|
||||||
|
It answers with the **whole ref list** rather than an acknowledgement, so
|
||||||
|
the pickers repopulate from after the fetch in the same round trip --
|
||||||
|
`routes::read_refs` is the one place both routes build that list.
|
||||||
|
`--prune` is asked for here and nowhere else: this is the call whose job
|
||||||
|
is to make the picker say what the remote says, where a pull should
|
||||||
|
change as little as it can. The phone's read timeout for it is longer
|
||||||
|
than the server's own 30s hard stop for a remote command, so a slow
|
||||||
|
remote is reported in git's words rather than as the phone giving up on a
|
||||||
|
request that is still running. While it runs, both pickers are disabled
|
||||||
|
-- it is replacing the lists they are showing -- but uncommitted work is
|
||||||
|
*not* a reason to disable it, since a fetch touches no file in the
|
||||||
|
working tree. It is the one control in that sheet that still works on a
|
||||||
|
dirty checkout.
|
||||||
|
|
||||||
|
- **`git checkout origin/topic` detaches HEAD**, which is why
|
||||||
|
`git::local_branch_for` exists. Git's DWIM that starts a tracking branch
|
||||||
|
fires on the bare name `topic` and on nothing else, and the picker's
|
||||||
|
remote-only entries are named `origin/topic` because that is what they
|
||||||
|
are -- so picking a branch left the checkout on no branch, with no
|
||||||
|
upstream and so no Pull: the state picking a *commit* is meant to
|
||||||
|
produce, reached from the control that says it is picking a branch.
|
||||||
|
Nothing about it looks like a failure either, since `git checkout`
|
||||||
|
prints its detached-HEAD advice and succeeds. Found by running it
|
||||||
|
against a real clone once the Fetch button made remote-only branches
|
||||||
|
something you could actually reach.
|
||||||
|
|
||||||
- **The checkout guard is deliberately weaker than the pull guard, and
|
- **The checkout guard is deliberately weaker than the pull guard, and
|
||||||
that is what stops it being a one-way door.** `git::checkout` refuses
|
that is what stops it being a one-way door.** `git::checkout` refuses
|
||||||
only on **tracked** modifications (`--untracked-files=no`), where
|
only on **tracked** modifications (`--untracked-files=no`), where
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.devupdater
|
package com.example.devupdater
|
||||||
|
|
||||||
|
import java.net.HttpURLConnection
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
@@ -200,11 +201,39 @@ data class CheckoutRefs(
|
|||||||
* the checkout has not fetched is Pull's business, and the card already says when there is any.
|
* the checkout has not fetched is Pull's business, and the card already says when there is any.
|
||||||
*/
|
*/
|
||||||
fun checkoutRefs(key: String): CheckoutRefs =
|
fun checkoutRefs(key: String): CheckoutRefs =
|
||||||
requestFromServer("/apps/$key/refs") { connection ->
|
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 body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||||
val branches = body.getJSONArray("branches")
|
val branches = body.getJSONArray("branches")
|
||||||
val commits = body.getJSONArray("commits")
|
val commits = body.getJSONArray("commits")
|
||||||
CheckoutRefs(
|
return CheckoutRefs(
|
||||||
branches =
|
branches =
|
||||||
(0 until branches.length()).map { i ->
|
(0 until branches.length()).map { i ->
|
||||||
val branch = branches.getJSONObject(i)
|
val branch = branches.getJSONObject(i)
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ data class GitStatus(
|
|||||||
val root: String,
|
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)
|
// One thing a project produces. [kind] is "apk" (installed on this phone)
|
||||||
// or "server" (installed and run on the build machine).
|
// 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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedCard
|
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
|
* Moves this project's checkout onto a branch or a commit on the build machine, and builds
|
||||||
* that leaves behind.
|
* nothing.
|
||||||
*
|
*
|
||||||
* Followed exactly as a pull is, because it is the same act on the same single checkout: one
|
* 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.
|
* 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) {
|
fun startCheckout(entry: ManifestEntry, target: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -2035,7 +2038,12 @@ private fun AppCard(
|
|||||||
// behind is not obvious from here.
|
// behind is not obvious from here.
|
||||||
colors = ActionTone.Caution.colors(),
|
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) {
|
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
|
* 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
|
* 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.
|
* 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
|
@Composable
|
||||||
private fun CheckoutSection(
|
private fun CheckoutSection(
|
||||||
@@ -2235,6 +2249,8 @@ private fun CheckoutSection(
|
|||||||
) {
|
) {
|
||||||
var refs by remember(entry.key) { mutableStateOf<CheckoutRefs?>(null) }
|
var refs by remember(entry.key) { mutableStateOf<CheckoutRefs?>(null) }
|
||||||
var failure by remember(entry.key) { mutableStateOf<String?>(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
|
val git = entry.git
|
||||||
|
|
||||||
// Only for a project actually in a checkout; asking git about a
|
// Only for a project actually in a checkout; asking git about a
|
||||||
@@ -2250,7 +2266,6 @@ private fun CheckoutSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsHeading("Checkout")
|
|
||||||
when {
|
when {
|
||||||
git == null -> {
|
git == null -> {
|
||||||
SettingsNote("This project is not in a git repository, so there is nothing to move.")
|
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."
|
"or stash on the build machine first."
|
||||||
)
|
)
|
||||||
busy -> SettingsNote("Something is already running on this checkout.")
|
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
|
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()) {
|
SettingRow("Branch") {
|
||||||
Text("Branch", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
|
||||||
BranchPicker(
|
BranchPicker(
|
||||||
branches = loaded?.branches.orEmpty(),
|
branches = loaded?.branches.orEmpty(),
|
||||||
// The branch line on the card is the same answer, and it is
|
// The branch line on the card is the same answer, and it is
|
||||||
@@ -2293,8 +2297,7 @@ private fun CheckoutSection(
|
|||||||
onSelect = onCheckout,
|
onSelect = onCheckout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
SettingRow("Commit") {
|
||||||
Text("Commit", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
|
||||||
CommitPicker(
|
CommitPicker(
|
||||||
commits = loaded?.commits.orEmpty(),
|
commits = loaded?.commits.orEmpty(),
|
||||||
head = loaded?.head,
|
head = loaded?.head,
|
||||||
@@ -2306,6 +2309,81 @@ private fun CheckoutSection(
|
|||||||
onSelect = onCheckout,
|
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
|
@Composable
|
||||||
@@ -2610,7 +2688,7 @@ private fun ComponentBuildButton(
|
|||||||
enabled = !state.busy && !projectState.busy,
|
enabled = !state.busy && !projectState.busy,
|
||||||
colors = ActionTone.Caution.colors(),
|
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.foundation.layout.size
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
* One composable rather than a size and a stroke width repeated per site, so every one of them
|
||||||
* reads as the same mark.
|
* 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
|
@Composable
|
||||||
fun Working(modifier: Modifier = Modifier) {
|
fun Working(modifier: Modifier = Modifier, color: Color = ProgressIndicatorDefaults.circularColor) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = modifier.size(12.dp),
|
modifier = modifier.size(12.dp),
|
||||||
|
color = color,
|
||||||
strokeWidth = 1.5.dp,
|
strokeWidth = 1.5.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+93
-22
@@ -60,6 +60,15 @@ pub enum Freshness {
|
|||||||
/// starts a build has the list and supplies this.
|
/// starts a build has the list and supplies this.
|
||||||
pub type RecordBuilt = Arc<dyn Fn(&str, String) + Send + Sync>;
|
pub type RecordBuilt = Arc<dyn Fn(&str, String) + Send + Sync>;
|
||||||
|
|
||||||
|
/// What a move does once the working tree has moved: the acceptance gate
|
||||||
|
/// to ask on the tree it left, and where to record what got built.
|
||||||
|
///
|
||||||
|
/// `None` is a move that builds nothing at all, which is every checkout --
|
||||||
|
/// the two halves travel together because neither is any use without the
|
||||||
|
/// other, and an `Option` of the pair is what makes "build, but with
|
||||||
|
/// nowhere to record it" unsayable.
|
||||||
|
type BuildAfterMoving = Option<(Box<dyn Fn() -> bool + Send>, RecordBuilt)>;
|
||||||
|
|
||||||
/// The handles a build shares with the rest of the server, as opposed to
|
/// The handles a build shares with the rest of the server, as opposed to
|
||||||
/// the project configuration it is built from.
|
/// the project configuration it is built from.
|
||||||
///
|
///
|
||||||
@@ -713,37 +722,40 @@ impl BuildState {
|
|||||||
may_build: impl Fn() -> bool + Send + 'static,
|
may_build: impl Fn() -> bool + Send + 'static,
|
||||||
record: RecordBuilt,
|
record: RecordBuilt,
|
||||||
) {
|
) {
|
||||||
self.after_moving(move |this| this.pull(force), may_build, record);
|
self.after_moving(
|
||||||
|
move |this| this.pull(force),
|
||||||
|
Some((Box::new(may_build), record)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves this checkout onto `target` -- a branch or a commit -- and
|
/// Moves this checkout onto `target` -- a branch or a commit -- and
|
||||||
/// then builds whatever that left behind.
|
/// builds nothing.
|
||||||
///
|
///
|
||||||
/// Exclusive with everything, and deferred to the same machinery as a
|
/// Exclusive with everything, and deferred to the same machinery as a
|
||||||
/// pull, because it is the same kind of act: there is one checkout,
|
/// pull, because it is the same kind of act: there is one checkout,
|
||||||
/// and this rewrites the files every component builds from. It
|
/// and this rewrites the files every component builds from. It
|
||||||
/// reports through the project's own state for the same reason, so a
|
/// reports through the project's own state for the same reason, so a
|
||||||
/// card shows it exactly as it shows a pull.
|
/// card shows it exactly as it shows a pull.
|
||||||
pub fn checkout_and_build(
|
///
|
||||||
self: &Arc<Self>,
|
/// Where it deliberately differs from a pull is that nothing is built
|
||||||
target: String,
|
/// afterwards. A pull is somebody taking new work, so building it is
|
||||||
may_build: impl Fn() -> bool + Send + 'static,
|
/// the point; moving the checkout is somebody *looking* -- at another
|
||||||
record: RecordBuilt,
|
/// branch, at last week's commit -- and making the cost of looking a
|
||||||
) {
|
/// full build of every component means an accidental tap starts a
|
||||||
|
/// minute of work with nothing to cancel it, and replaces outputs
|
||||||
|
/// that were wanted. What that leaves behind is a component whose
|
||||||
|
/// build no longer matches the checkout, which the card already says
|
||||||
|
/// and already carries the button for. Iris asked for this on
|
||||||
|
/// 2026-09-02.
|
||||||
|
pub fn move_checkout(self: &Arc<Self>, target: String) {
|
||||||
self.after_moving(
|
self.after_moving(
|
||||||
move |this| {
|
move |this| {
|
||||||
this.begin_project("checking out");
|
this.begin_project("checking out");
|
||||||
crate::git::checkout(&this.project_path, &target)?;
|
crate::git::checkout(&this.project_path, &target)?;
|
||||||
tracing::info!("checked out {target} in {}", this.project_path.display());
|
tracing::info!("checked out {target} in {}", this.project_path.display());
|
||||||
// Always "something moved": a checkout onto the commit
|
|
||||||
// that was already there is the one case this over-reports,
|
|
||||||
// and the cost of that is one build that had nothing to do.
|
|
||||||
// Under-reporting costs a checkout whose outputs stay from
|
|
||||||
// the commit before it, which is the silent kind.
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
},
|
},
|
||||||
may_build,
|
None,
|
||||||
record,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,8 +771,7 @@ impl BuildState {
|
|||||||
fn after_moving(
|
fn after_moving(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
move_it: impl FnOnce(&Arc<Self>) -> Result<bool, crate::git::PullError> + Send + 'static,
|
move_it: impl FnOnce(&Arc<Self>) -> Result<bool, crate::git::PullError> + Send + 'static,
|
||||||
may_build: impl Fn() -> bool + Send + 'static,
|
then_build: BuildAfterMoving,
|
||||||
record: RecordBuilt,
|
|
||||||
) {
|
) {
|
||||||
// Claimed before anything is spawned, and while nothing else is
|
// Claimed before anything is spawned, and while nothing else is
|
||||||
// running: one checkout, so a move is exclusive with every build
|
// running: one checkout, so a move is exclusive with every build
|
||||||
@@ -783,14 +794,18 @@ impl BuildState {
|
|||||||
this.fail_pull(error);
|
this.fail_pull(error);
|
||||||
}
|
}
|
||||||
Ok(pulled) => {
|
Ok(pulled) => {
|
||||||
// Nothing configured to build, or nothing allowed to:
|
// Nothing configured to build, nothing allowed to, or
|
||||||
// the pull was the whole job.
|
// a move that builds nothing by definition: the move
|
||||||
|
// was the whole job.
|
||||||
// `may_build()` is called here, with the pulled
|
// `may_build()` is called here, with the pulled
|
||||||
// declaration on disk, for the reason in the doc
|
// declaration on disk, for the reason in the doc
|
||||||
// comment above. Both it and `is_stale` take the lock
|
// comment above. Both it and `is_stale` take the lock
|
||||||
// themselves, so they are asked before it is held.
|
// themselves, so they are asked before it is held.
|
||||||
let build =
|
let build = then_build
|
||||||
may_build() && this.has_command() && (pulled || this.is_stale(None));
|
.as_ref()
|
||||||
|
.is_some_and(|(may_build, _)| may_build())
|
||||||
|
&& this.has_command()
|
||||||
|
&& (pulled || this.is_stale(None));
|
||||||
// Handing the run over in one step, so nothing can
|
// Handing the run over in one step, so nothing can
|
||||||
// observe the moment between the pull ending and the
|
// observe the moment between the pull ending and the
|
||||||
// builds it decided on starting -- see `claim`.
|
// builds it decided on starting -- see `claim`.
|
||||||
@@ -804,16 +819,21 @@ impl BuildState {
|
|||||||
inner.phase = None;
|
inner.phase = None;
|
||||||
claimed
|
claimed
|
||||||
};
|
};
|
||||||
|
if let Some((_, record)) = then_build {
|
||||||
this.run_claimed(claimed, &record);
|
this.run_claimed(claimed, &record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches and fast-forwards, reporting whether anything arrived.
|
/// Fetches and fast-forwards, reporting whether anything arrived.
|
||||||
fn pull(&self, force: bool) -> Result<bool, crate::git::PullError> {
|
fn pull(&self, force: bool) -> Result<bool, crate::git::PullError> {
|
||||||
self.begin_project("fetching");
|
self.begin_project("fetching");
|
||||||
crate::git::fetch(&self.project_path, self.git_ipv4)?;
|
// No prune: taking commits is not the moment to decide which
|
||||||
|
// remote branches still exist, and the Fetch button is what asks
|
||||||
|
// that question.
|
||||||
|
crate::git::fetch(&self.project_path, self.git_ipv4, false)?;
|
||||||
// Counted after the fetch, from refs now on disk: this is the one
|
// Counted after the fetch, from refs now on disk: this is the one
|
||||||
// place that has the objects to count against.
|
// place that has the objects to count against.
|
||||||
let behind = crate::git::behind(&self.project_path);
|
let behind = crate::git::behind(&self.project_path);
|
||||||
@@ -2010,6 +2030,57 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moving the checkout runs git and nothing else.
|
||||||
|
///
|
||||||
|
/// It used to build whatever the move left behind, which made looking
|
||||||
|
/// at another branch cost a full build of every component -- and there
|
||||||
|
/// is no way to call one back from a phone. What replaces it is the
|
||||||
|
/// card saying the component no longer matches the checkout, with its
|
||||||
|
/// own Build beside it. Asserted by giving the components a command
|
||||||
|
/// that leaves a file, so "it did not build" is a fact about the disk
|
||||||
|
/// rather than about a status word that could be read too early.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn moving_the_checkout_builds_nothing() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root = dir.path();
|
||||||
|
let mut components = two_component_checkout(root);
|
||||||
|
for component in &mut components {
|
||||||
|
let marker = format!("{}-built", component.name());
|
||||||
|
match component {
|
||||||
|
Component::Apk { build, .. } | Component::Server { build, .. } => {
|
||||||
|
*build = crate::config::ByMode::One(crate::config::Command::from_line(
|
||||||
|
&format!("touch ../{marker}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run(root, &["git", "checkout", "-qb", "elsewhere"]);
|
||||||
|
std::fs::write(root.join("app/main.kt"), "two").expect("write");
|
||||||
|
run(root, &["git", "commit", "-qam", "two"]);
|
||||||
|
run(root, &["git", "checkout", "-q", "main"]);
|
||||||
|
|
||||||
|
let state = state_for(root, components);
|
||||||
|
state.move_checkout("elsewhere".to_string());
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
|
while state.status().building {
|
||||||
|
assert!(std::time::Instant::now() < deadline, "the move never ended");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
crate::git::status(root).expect("a checkout").branch,
|
||||||
|
"elsewhere",
|
||||||
|
"the checkout really moved, so the rest of this is about a move that happened",
|
||||||
|
);
|
||||||
|
for marker in ["backend-built", "app-built"] {
|
||||||
|
assert!(
|
||||||
|
!root.join(marker).exists(),
|
||||||
|
"{marker} exists, so moving the checkout built something",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The shape of bug a unit test that only calls `component_is_stale`
|
/// The shape of bug a unit test that only calls `component_is_stale`
|
||||||
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
|
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
|
||||||
/// the staleness check also wants. Taking it twice on one thread is a
|
/// the staleness check also wants. Taking it twice on one thread is a
|
||||||
|
|||||||
+92
-9
@@ -200,11 +200,22 @@ pub fn has_new_commits(project: &Path, ipv4: bool) -> Result<bool, String> {
|
|||||||
/// than wait for a person who isn't there. Then the whole thing is killed
|
/// than wait for a person who isn't there. Then the whole thing is killed
|
||||||
/// at [`REMOTE_HARD_TIMEOUT`] regardless, so nothing accumulates when a
|
/// at [`REMOTE_HARD_TIMEOUT`] regardless, so nothing accumulates when a
|
||||||
/// remote is merely very slow.
|
/// remote is merely very slow.
|
||||||
pub fn fetch(project: &Path, ipv4: bool) -> Result<(), String> {
|
///
|
||||||
|
/// `prune` drops remote-tracking refs for branches the remote no longer
|
||||||
|
/// has. Only the Fetch button asks for it, because that is the one call
|
||||||
|
/// whose job is to make the branch picker say what the remote says: a
|
||||||
|
/// deleted branch left behind is one the picker offers forever, and
|
||||||
|
/// checking it out makes a local branch tracking something gone. A pull
|
||||||
|
/// does not, because pruning is not part of taking commits and a pull
|
||||||
|
/// should change as little as it can.
|
||||||
|
pub fn fetch(project: &Path, ipv4: bool, prune: bool) -> Result<(), String> {
|
||||||
// `fetch` is one of the few git subcommands that takes the flag
|
// `fetch` is one of the few git subcommands that takes the flag
|
||||||
// itself, and that is what carries the preference to an https remote.
|
// itself, and that is what carries the preference to an https remote.
|
||||||
// An ssh remote is covered by `ssh_command` either way.
|
// An ssh remote is covered by `ssh_command` either way.
|
||||||
let mut args = vec!["fetch", "--quiet"];
|
let mut args = vec!["fetch", "--quiet"];
|
||||||
|
if prune {
|
||||||
|
args.push("--prune");
|
||||||
|
}
|
||||||
if ipv4 {
|
if ipv4 {
|
||||||
args.insert(1, "-4");
|
args.insert(1, "-4");
|
||||||
}
|
}
|
||||||
@@ -577,10 +588,47 @@ pub fn checkout(project: &Path, target: &str) -> Result<(), PullError> {
|
|||||||
// `--` so a branch and a path that share a name cannot be confused,
|
// `--` so a branch and a path that share a name cannot be confused,
|
||||||
// which is the one way a name from a phone could mean something other
|
// which is the one way a name from a phone could mean something other
|
||||||
// than what it says.
|
// than what it says.
|
||||||
git(project, &["checkout", target, "--"]).map_err(PullError::from)?;
|
let mut args: Vec<&str> = vec!["checkout"];
|
||||||
|
let starting;
|
||||||
|
if let Some(local) = local_branch_for(project, target) {
|
||||||
|
starting = local;
|
||||||
|
args.extend(["-b", &starting, "--track"]);
|
||||||
|
}
|
||||||
|
args.extend([target, "--"]);
|
||||||
|
git(project, &args).map_err(PullError::from)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The local branch to start when `target` names a remote-tracking ref
|
||||||
|
/// that has none, or `None` when it is anything else.
|
||||||
|
///
|
||||||
|
/// `git checkout origin/topic` **detaches HEAD** at that commit: the DWIM
|
||||||
|
/// that creates a tracking branch fires on the bare name `topic` and
|
||||||
|
/// nothing else. So the branch picker's remote-only entries -- which are
|
||||||
|
/// named `origin/topic`, because that is what they are -- checked out to
|
||||||
|
/// no branch at all, with no upstream and so no Pull: exactly the parked
|
||||||
|
/// state picking a *commit* produces, from a control that says it is
|
||||||
|
/// picking a branch. Measured against a real clone rather than read;
|
||||||
|
/// `git checkout` prints its detached-HEAD advice and succeeds, so
|
||||||
|
/// nothing about the result looks like a failure.
|
||||||
|
///
|
||||||
|
/// A local branch of the same name already existing is not this case --
|
||||||
|
/// [`branches`] does not offer the remote one then -- but if it somehow
|
||||||
|
/// arrives, `-b` would fail on a name that is already taken, so it is
|
||||||
|
/// left to the plain checkout, where it means the same branch.
|
||||||
|
fn local_branch_for(project: &Path, target: &str) -> Option<String> {
|
||||||
|
if !ref_exists(project, &format!("refs/remotes/{target}")) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (_remote, branch) = target.split_once('/')?;
|
||||||
|
(!ref_exists(project, &format!("refs/heads/{branch}"))).then(|| branch.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a fully-qualified ref is in this checkout.
|
||||||
|
fn ref_exists(project: &Path, refname: &str) -> bool {
|
||||||
|
git(project, &["rev-parse", "--verify", "--quiet", refname]).is_ok_and(|sha| !sha.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fast-forwards the current branch onto its upstream.
|
/// Fast-forwards the current branch onto its upstream.
|
||||||
///
|
///
|
||||||
/// `--ff-only` deliberately: a merge or a rebase can conflict, and
|
/// `--ff-only` deliberately: a merge or a rebase can conflict, and
|
||||||
@@ -860,6 +908,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picking a branch that only exists on the remote has to leave the
|
||||||
|
/// checkout *on a branch*, with an upstream.
|
||||||
|
///
|
||||||
|
/// `git checkout origin/topic` detaches HEAD instead, which is the
|
||||||
|
/// same state picking a commit produces -- no branch on the card, no
|
||||||
|
/// Pull -- reached from the control that says it is picking a branch.
|
||||||
|
/// The picker names these entries `origin/topic` because that is what
|
||||||
|
/// they are, so this is the ordinary path rather than an edge.
|
||||||
|
#[test]
|
||||||
|
fn a_branch_only_on_the_remote_is_checked_out_as_a_branch() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let (origin, clone) = origin_and_clone(dir.path());
|
||||||
|
run(&origin, &["git", "branch", "topic"]);
|
||||||
|
run(&clone, &["git", "fetch", "-q"]);
|
||||||
|
|
||||||
|
checkout(&clone, "origin/topic").expect("checking out a remote-only branch");
|
||||||
|
|
||||||
|
let moved = status(&clone).expect("a checkout");
|
||||||
|
assert_eq!(moved.branch, "topic", "left on no branch: {moved:?}");
|
||||||
|
assert_eq!(
|
||||||
|
moved.upstream.as_deref(),
|
||||||
|
Some("origin/topic"),
|
||||||
|
"a branch with no upstream cannot be pulled",
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the ordinary cases still go through unchanged: a local
|
||||||
|
// branch by name, and a commit, which is the one that is *meant*
|
||||||
|
// to detach.
|
||||||
|
checkout(&clone, "main").expect("checking out a local branch");
|
||||||
|
assert_eq!(status(&clone).expect("a checkout").branch, "main");
|
||||||
|
let head = recent_commits(&clone, 1)[0].short.clone();
|
||||||
|
checkout(&clone, &head).expect("checking out a commit");
|
||||||
|
assert_eq!(status(&clone).expect("a checkout").branch, DETACHED);
|
||||||
|
}
|
||||||
|
|
||||||
/// What the settings sheet's two pickers are built from, and the one
|
/// What the settings sheet's two pickers are built from, and the one
|
||||||
/// thing about them that is easy to get wrong: `origin/main` must not
|
/// thing about them that is easy to get wrong: `origin/main` must not
|
||||||
/// be offered beside `main`, because picking it would detach HEAD
|
/// be offered beside `main`, because picking it would detach HEAD
|
||||||
@@ -1143,7 +1226,7 @@ mod tests {
|
|||||||
std::fs::write(origin.join("file"), "two").expect("write");
|
std::fs::write(origin.join("file"), "two").expect("write");
|
||||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||||
|
|
||||||
fetch(&clone, true).expect("fetch with ipv4 forced");
|
fetch(&clone, true, false).expect("fetch with ipv4 forced");
|
||||||
pull(&clone, true, false).expect("pull with ipv4 forced");
|
pull(&clone, true, false).expect("pull with ipv4 forced");
|
||||||
assert!(!has_new_commits(&clone, true).expect("check"));
|
assert!(!has_new_commits(&clone, true).expect("check"));
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1276,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Pull is what actually takes them.
|
// Pull is what actually takes them.
|
||||||
fetch(&clone, false).expect("fetch");
|
fetch(&clone, false, false).expect("fetch");
|
||||||
assert_eq!(behind(&clone), 1);
|
assert_eq!(behind(&clone), 1);
|
||||||
pull(&clone, false, false).expect("pull");
|
pull(&clone, false, false).expect("pull");
|
||||||
assert!(!has_new_commits(&clone, false).expect("check"));
|
assert!(!has_new_commits(&clone, false).expect("check"));
|
||||||
@@ -1221,7 +1304,7 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::write(origin.join("file"), "two").expect("write");
|
std::fs::write(origin.join("file"), "two").expect("write");
|
||||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||||
fetch(&clone, false).expect("fetch");
|
fetch(&clone, false, false).expect("fetch");
|
||||||
assert!(
|
assert!(
|
||||||
has_new_commits(&clone, false).expect("check"),
|
has_new_commits(&clone, false).expect("check"),
|
||||||
"fetched but not merged is still something to pull",
|
"fetched but not merged is still something to pull",
|
||||||
@@ -1330,7 +1413,7 @@ mod tests {
|
|||||||
let (origin, clone) = origin_and_clone(dir.path());
|
let (origin, clone) = origin_and_clone(dir.path());
|
||||||
std::fs::write(origin.join("file"), "two").expect("write");
|
std::fs::write(origin.join("file"), "two").expect("write");
|
||||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||||
fetch(&clone, false).expect("fetch");
|
fetch(&clone, false, false).expect("fetch");
|
||||||
|
|
||||||
pull(&clone, false, false).expect("pull");
|
pull(&clone, false, false).expect("pull");
|
||||||
assert_eq!(behind(&clone), 0);
|
assert_eq!(behind(&clone), 0);
|
||||||
@@ -1348,7 +1431,7 @@ mod tests {
|
|||||||
let (origin, clone) = origin_and_clone(dir.path());
|
let (origin, clone) = origin_and_clone(dir.path());
|
||||||
std::fs::write(origin.join("file"), "two").expect("write");
|
std::fs::write(origin.join("file"), "two").expect("write");
|
||||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||||
fetch(&clone, false).expect("fetch");
|
fetch(&clone, false, false).expect("fetch");
|
||||||
|
|
||||||
std::fs::write(clone.join("file"), "local edit").expect("write");
|
std::fs::write(clone.join("file"), "local edit").expect("write");
|
||||||
assert!(status(&clone).expect("status").dirty);
|
assert!(status(&clone).expect("status").dirty);
|
||||||
@@ -1398,7 +1481,7 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::write(origin.join("file"), "two").expect("write");
|
std::fs::write(origin.join("file"), "two").expect("write");
|
||||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||||
fetch(&clone, false).expect("fetch");
|
fetch(&clone, false, false).expect("fetch");
|
||||||
|
|
||||||
let err = pull(&clone, false, false).expect_err("no fast-forward exists");
|
let err = pull(&clone, false, false).expect_err("no fast-forward exists");
|
||||||
assert!(err.unrelated_histories, "{}", err.message);
|
assert!(err.unrelated_histories, "{}", err.message);
|
||||||
@@ -1417,7 +1500,7 @@ mod tests {
|
|||||||
run(&clone, &["git", "commit", "-qam", "mine"]);
|
run(&clone, &["git", "commit", "-qam", "mine"]);
|
||||||
std::fs::write(origin.join("file"), "theirs").expect("write");
|
std::fs::write(origin.join("file"), "theirs").expect("write");
|
||||||
run(&origin, &["git", "commit", "-qam", "theirs"]);
|
run(&origin, &["git", "commit", "-qam", "theirs"]);
|
||||||
fetch(&clone, false).expect("fetch");
|
fetch(&clone, false, false).expect("fetch");
|
||||||
|
|
||||||
let err = pull(&clone, false, false).expect_err("diverged");
|
let err = pull(&clone, false, false).expect_err("diverged");
|
||||||
assert!(!err.unrelated_histories, "{}", err.message);
|
assert!(!err.unrelated_histories, "{}", err.message);
|
||||||
|
|||||||
+64
-19
@@ -103,6 +103,10 @@ pub fn tls_router(state: Arc<AppState>) -> Router {
|
|||||||
// both of these spawn git, and the manifest is fetched on every
|
// both of these spawn git, and the manifest is fetched on every
|
||||||
// open, resume and Refresh.
|
// open, resume and Refresh.
|
||||||
.route("/apps/{key}/refs", get(checkout_refs))
|
.route("/apps/{key}/refs", get(checkout_refs))
|
||||||
|
// Beside them, and the only one of the three that uses the
|
||||||
|
// network: it is what brings a branch pushed from somewhere else
|
||||||
|
// into the list the other two read.
|
||||||
|
.route("/apps/{key}/fetch", post(fetch_refs))
|
||||||
.route("/apps/{key}/checkout", post(build_checkout))
|
.route("/apps/{key}/checkout", post(build_checkout))
|
||||||
.route("/apps/{key}/prepare", post(build_prepare))
|
.route("/apps/{key}/prepare", post(build_prepare))
|
||||||
.route("/apps/{key}/build", post(build_now))
|
.route("/apps/{key}/build", post(build_now))
|
||||||
@@ -1607,16 +1611,63 @@ async fn checkout_refs(
|
|||||||
) -> Result<Json<RefsResponse>, ApiError> {
|
) -> Result<Json<RefsResponse>, ApiError> {
|
||||||
let entry = lookup(&state, Some(key))?;
|
let entry = lookup(&state, Some(key))?;
|
||||||
let project = entry.project_path.clone();
|
let project = entry.project_path.clone();
|
||||||
let refs = tokio::task::spawn_blocking(move || RefsResponse {
|
let refs = tokio::task::spawn_blocking(move || read_refs(&project))
|
||||||
branches: crate::git::branches(&project),
|
|
||||||
commits: crate::git::recent_commits(&project, COMMIT_HISTORY),
|
|
||||||
head: crate::git::head_branch(&project),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.context("reading the checkout's refs panicked")?;
|
.context("reading the checkout's refs panicked")?;
|
||||||
Ok(Json(refs))
|
Ok(Json(refs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The three local reads behind [`checkout_refs`], in one place because
|
||||||
|
/// [`fetch_refs`] answers with the same thing and the two must not come
|
||||||
|
/// to describe a checkout differently.
|
||||||
|
fn read_refs(project: &std::path::Path) -> RefsResponse {
|
||||||
|
RefsResponse {
|
||||||
|
branches: crate::git::branches(project),
|
||||||
|
commits: crate::git::recent_commits(project, COMMIT_HISTORY),
|
||||||
|
head: crate::git::head_branch(project),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates this checkout's remote-tracking refs, and answers with the
|
||||||
|
/// refs that leaves.
|
||||||
|
///
|
||||||
|
/// The one thing here that reaches the network on somebody's press rather
|
||||||
|
/// than on a timer, and the only way a branch pushed from another machine
|
||||||
|
/// ever reaches the picker: nothing else this server runs writes into
|
||||||
|
/// `refs/remotes` except the fetch inside a pull, and a pull is only
|
||||||
|
/// reachable when the branch you are already on is behind. So a project
|
||||||
|
/// whose current branch is up to date could never be moved onto a new
|
||||||
|
/// branch at all -- the way out was a terminal on the build machine,
|
||||||
|
/// which is where the person holding the phone is not.
|
||||||
|
///
|
||||||
|
/// Deliberately still not on the manifest, and still not on a timer. A
|
||||||
|
/// fetch mutates the checkout and pulls down objects, so it stays
|
||||||
|
/// something somebody asked for -- which also means the sheet's own read
|
||||||
|
/// (`checkout_refs`) keeps being a local one that cannot stall.
|
||||||
|
///
|
||||||
|
/// It answers with the whole ref list rather than a bare acknowledgement,
|
||||||
|
/// so the pickers repopulate from *after* the fetch in the same round
|
||||||
|
/// trip. Two calls would leave a window where the button had finished and
|
||||||
|
/// the list was still the old one.
|
||||||
|
async fn fetch_refs(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
key: UrlPath<String>,
|
||||||
|
) -> Result<Json<RefsResponse>, ApiError> {
|
||||||
|
let entry = lookup(&state, Some(key))?;
|
||||||
|
let project = entry.project_path.clone();
|
||||||
|
let ipv4 = entry.git_ipv4;
|
||||||
|
let refs = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::git::fetch(&project, ipv4, true).map(|()| read_refs(&project))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("fetching panicked")?
|
||||||
|
// Already shortened and explained by `git::remote_failure`, and a 500
|
||||||
|
// here answers with its message -- so what reaches the card is git's
|
||||||
|
// first line plus whatever this process can see of the ssh agent.
|
||||||
|
.map_err(|err| ApiError::Internal(anyhow::anyhow!(err)))?;
|
||||||
|
Ok(Json(refs))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CheckoutBody {
|
struct CheckoutBody {
|
||||||
/// A branch name or a commit hash, as the phone read it back from
|
/// A branch name or a commit hash, as the phone read it back from
|
||||||
@@ -1626,15 +1677,14 @@ struct CheckoutBody {
|
|||||||
target: String,
|
target: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves the checkout onto a branch or a commit, then builds what that
|
/// Moves the checkout onto a branch or a commit -- reported through the
|
||||||
/// left behind -- reported through the same status a pull is, because it
|
/// same status a pull is, because it is the same kind of act on the same
|
||||||
/// is the same kind of act on the same single checkout.
|
/// single checkout.
|
||||||
///
|
///
|
||||||
/// Available while a declaration is waiting to be accepted, for the
|
/// It builds nothing; see [`BuildState::move_checkout`]. That is also
|
||||||
/// reason a pull is: moving the checkout runs git rather than the
|
/// what makes the acceptance gate a pull needs irrelevant here: nothing
|
||||||
/// project's command, and it is how a different version of that request
|
/// this runs is the project's own command, so there is nothing to ask
|
||||||
/// arrives to be read. What it must not do is *build* in that state,
|
/// permission for.
|
||||||
/// which is why the gate is asked afterwards, on the tree the move left.
|
|
||||||
async fn build_checkout(
|
async fn build_checkout(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
key: UrlPath<String>,
|
key: UrlPath<String>,
|
||||||
@@ -1651,12 +1701,7 @@ async fn build_checkout(
|
|||||||
.build
|
.build
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
|
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
|
||||||
let gate = Arc::clone(&entry);
|
build.move_checkout(target);
|
||||||
build.checkout_and_build(
|
|
||||||
target,
|
|
||||||
move || gate.pending_declaration().is_none(),
|
|
||||||
records_builds(&state, &entry.key),
|
|
||||||
);
|
|
||||||
Ok(Json(build.status()))
|
Ok(Json(build.status()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user