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

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

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

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

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

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

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

No files matched your search

+54 -3
View File
@@ -694,6 +694,58 @@ impl BuildState {
may_build: impl Fn() -> bool + Send + 'static,
record: RecordBuilt,
) {
self.after_moving(move |this| this.pull(force), may_build, record);
}
/// Moves this checkout onto `target` -- a branch or a commit -- and
/// then builds whatever that left behind.
///
/// 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,
) {
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,
);
}
/// The half a pull and a checkout share: run the thing that moves the
/// working tree, then build what it left behind.
///
/// One copy, because the part that is easy to get wrong is not the
/// git command -- it is handing the run over under a single lock so
/// that nothing can observe the moment between the move ending and
/// the builds it decided on starting. A phone polling in that gap
/// sees a project that is neither pulling nor building and calls the
/// run finished.
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,
) {
// Claimed before anything is spawned, and while nothing else is
// running: one checkout, so a move is exclusive with every build
// as well as with another move.
{
let mut inner = self.inner.lock().unwrap();
if inner.anything_running() {
@@ -703,13 +755,12 @@ impl BuildState {
inner.error = None;
inner.unrelated_histories = false;
}
let this = Arc::clone(self);
tokio::task::spawn_blocking(move || {
let outcome = this.pull(force);
let outcome = move_it(&this);
match outcome {
Err(error) => {
tracing::error!("pull failed: {}", error.message);
tracing::error!("moving the checkout failed: {}", error.message);
this.fail_pull(error);
}
Ok(pulled) => {