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

+52
View File
@@ -340,6 +340,58 @@ mutable at runtime from the phone.
remote deliberately, and a card that mentions it every time is nagging
about a choice somebody made.
- **A checkout can be moved from the phone, and it is the same act as a
pull.** The project settings sheet lists the checkout's branches and its
last fifty commits, and picking one moves the checkout and then builds
whatever that left behind. `BuildState::after_moving` is the half a pull
and a checkout share -- one copy, because the part that is easy to get
wrong is not the git command but handing the run over under a single
lock, so nothing can observe the moment between the move ending and the
builds it decided on starting. It reports through the project's own
state for the same reason: one progress path, not a second to keep in
step.
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
only**: what the remote has and this checkout has not fetched is not
offered, because Pull is what brings those in and the card already says
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
looked at, and neither shows up in a fixture built to pass:
`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 the obvious filter for a name ending in `/HEAD`
matches nothing and a phantom branch called `origin` reaches the phone.
It is dropped by *being* a symref (`%(symref)` non-empty) instead. The
test that was meant to cover the second asserted the same wrong thing
and passed.
- **The checkout guard is deliberately weaker than the pull guard, and
that is what stops it being a one-way door.** `git::checkout` refuses
only on **tracked** modifications (`--untracked-files=no`), where
`git::pull` refuses on any dirt at all. With the strict check, moving
back to a commit from before the `.gitignore` that covers this
project's build output leaves that output sitting there untracked, the
tree is dirty, and every move afterwards is refused -- you can go back
and you cannot come forward, from a phone, with the way out being the
build machine. Nothing is given up by relaxing it, because git makes
the better check itself: `git checkout` refuses when an untracked file
would be overwritten and carries across the ones that would not, and
its refusal arrives as the error the card shows. So this guard covers
work somebody typed and git's covers the files it would clobber.
Found by moving a real checkout back and forth rather than by reading
the code; the first version passed its tests and trapped the checkout
on the second move.
- **A detached HEAD is a state the card has to say out loud.** Picking a
commit leaves the checkout on no branch, `git::status` reports the
branch as the literal `HEAD`, and `can_pull` correctly goes false
because there is no upstream. The card draws that as **"no branch"**
rather than `HEAD`, which beside a branch icon reads as a branch
somebody named HEAD -- worth doing now that the sheet can produce the
state, where before it was only reachable on the build machine.
- **Unrelated histories are the one pull failure the phone may override.**
A checkout sharing no commit with its upstream has no fast-forward and
never will, so with nothing offered the card is one that can never be