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

+94 -23
View File
@@ -60,6 +60,15 @@ pub enum Freshness {
/// starts a build has the list and supplies this.
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 project configuration it is built from.
///
@@ -713,37 +722,40 @@ impl BuildState {
may_build: impl Fn() -> bool + Send + 'static,
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
/// then builds whatever that left behind.
/// builds nothing.
///
/// Exclusive with everything, and deferred to the same machinery as a
/// pull, because it is the same kind of act: there is one checkout,
/// and this rewrites the files every component builds from. It
/// reports through the project's own state for the same reason, so a
/// card shows it exactly as it shows a pull.
pub fn checkout_and_build(
self: &Arc<Self>,
target: String,
may_build: impl Fn() -> bool + Send + 'static,
record: RecordBuilt,
) {
///
/// Where it deliberately differs from a pull is that nothing is built
/// afterwards. 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 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(
move |this| {
this.begin_project("checking out");
crate::git::checkout(&this.project_path, &target)?;
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)
},
may_build,
record,
None,
);
}
@@ -759,8 +771,7 @@ impl BuildState {
fn after_moving(
self: &Arc<Self>,
move_it: impl FnOnce(&Arc<Self>) -> Result<bool, crate::git::PullError> + Send + 'static,
may_build: impl Fn() -> bool + Send + 'static,
record: RecordBuilt,
then_build: BuildAfterMoving,
) {
// Claimed before anything is spawned, and while nothing else is
// running: one checkout, so a move is exclusive with every build
@@ -783,14 +794,18 @@ impl BuildState {
this.fail_pull(error);
}
Ok(pulled) => {
// Nothing configured to build, or nothing allowed to:
// the pull was the whole job.
// Nothing configured to build, nothing allowed to, or
// a move that builds nothing by definition: the move
// was the whole job.
// `may_build()` is called here, with the pulled
// declaration on disk, for the reason in the doc
// comment above. Both it and `is_stale` take the lock
// themselves, so they are asked before it is held.
let build =
may_build() && this.has_command() && (pulled || this.is_stale(None));
let build = then_build
.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
// observe the moment between the pull ending and the
// builds it decided on starting -- see `claim`.
@@ -804,7 +819,9 @@ impl BuildState {
inner.phase = None;
claimed
};
this.run_claimed(claimed, &record);
if let Some((_, record)) = then_build {
this.run_claimed(claimed, &record);
}
}
}
});
@@ -813,7 +830,10 @@ impl BuildState {
/// Fetches and fast-forwards, reporting whether anything arrived.
fn pull(&self, force: bool) -> Result<bool, crate::git::PullError> {
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
// place that has the objects to count against.
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`
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
/// the staleness check also wants. Taking it twice on one thread is a
+92 -9
View File
@@ -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
/// at [`REMOTE_HARD_TIMEOUT`] regardless, so nothing accumulates when a
/// 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
// itself, and that is what carries the preference to an https remote.
// An ssh remote is covered by `ssh_command` either way.
let mut args = vec!["fetch", "--quiet"];
if prune {
args.push("--prune");
}
if ipv4 {
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,
// which is the one way a name from a phone could mean something other
// 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(())
}
/// 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.
///
/// `--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
/// thing about them that is easy to get wrong: `origin/main` must not
/// 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");
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");
assert!(!has_new_commits(&clone, true).expect("check"));
}
@@ -1193,7 +1276,7 @@ mod tests {
);
// Pull is what actually takes them.
fetch(&clone, false).expect("fetch");
fetch(&clone, false, false).expect("fetch");
assert_eq!(behind(&clone), 1);
pull(&clone, false, false).expect("pull");
assert!(!has_new_commits(&clone, false).expect("check"));
@@ -1221,7 +1304,7 @@ mod tests {
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
fetch(&clone, false, false).expect("fetch");
assert!(
has_new_commits(&clone, false).expect("check"),
"fetched but not merged is still something to pull",
@@ -1330,7 +1413,7 @@ mod tests {
let (origin, clone) = origin_and_clone(dir.path());
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
fetch(&clone, false, false).expect("fetch");
pull(&clone, false, false).expect("pull");
assert_eq!(behind(&clone), 0);
@@ -1348,7 +1431,7 @@ mod tests {
let (origin, clone) = origin_and_clone(dir.path());
std::fs::write(origin.join("file"), "two").expect("write");
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");
assert!(status(&clone).expect("status").dirty);
@@ -1398,7 +1481,7 @@ mod tests {
std::fs::write(origin.join("file"), "two").expect("write");
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");
assert!(err.unrelated_histories, "{}", err.message);
@@ -1417,7 +1500,7 @@ mod tests {
run(&clone, &["git", "commit", "-qam", "mine"]);
std::fs::write(origin.join("file"), "theirs").expect("write");
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");
assert!(!err.unrelated_histories, "{}", err.message);
+64 -19
View File
@@ -103,6 +103,10 @@ pub fn tls_router(state: Arc<AppState>) -> Router {
// both of these spawn git, and the manifest is fetched on every
// open, resume and Refresh.
.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}/prepare", post(build_prepare))
.route("/apps/{key}/build", post(build_now))
@@ -1607,13 +1611,60 @@ async fn checkout_refs(
) -> Result<Json<RefsResponse>, ApiError> {
let entry = lookup(&state, Some(key))?;
let project = entry.project_path.clone();
let refs = tokio::task::spawn_blocking(move || RefsResponse {
branches: crate::git::branches(&project),
commits: crate::git::recent_commits(&project, COMMIT_HISTORY),
head: crate::git::head_branch(&project),
let refs = tokio::task::spawn_blocking(move || read_refs(&project))
.await
.context("reading the checkout's refs panicked")?;
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("reading the checkout's refs panicked")?;
.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))
}
@@ -1626,15 +1677,14 @@ struct CheckoutBody {
target: String,
}
/// Moves the checkout onto a branch or a commit, then builds what that
/// left behind -- reported through the same status a pull is, because it
/// is the same kind of act on the same single checkout.
/// Moves the checkout onto a branch or a commit -- reported through the
/// same status a pull is, because it is the same kind of act on the same
/// single checkout.
///
/// Available while a declaration is waiting to be accepted, for the
/// reason a pull is: moving the checkout runs git rather than the
/// project's command, and it is how a different version of that request
/// arrives to be read. What it must not do is *build* in that state,
/// which is why the gate is asked afterwards, on the tree the move left.
/// It builds nothing; see [`BuildState::move_checkout`]. That is also
/// what makes the acceptance gate a pull needs irrelevant here: nothing
/// this runs is the project's own command, so there is nothing to ask
/// permission for.
async fn build_checkout(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
@@ -1651,12 +1701,7 @@ async fn build_checkout(
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
let gate = Arc::clone(&entry);
build.checkout_and_build(
target,
move || gate.pending_declaration().is_none(),
records_builds(&state, &entry.key),
);
build.move_checkout(target);
Ok(Json(build.status()))
}