A component's build progress reads under its buttons, and an unrelated history can be overridden

Two things, both about a card being able to act on what it says.

The progress bar, its counts and the last line a component printed now
sit below that component's buttons rather than above them. A bar reports
on the press that started it, so it reads in the order it happened -- and
above, it pushed the buttons down the moment a build began, moving the row
somebody had just pressed out from under their finger.

A pull that cannot fast-forward because the checkout shares no history
with its upstream now offers a way past it. There is no fast-forward
between two unrelated histories and there never will be, so the card was
one that could never be pulled again, with the only remedy on the build
machine -- exactly where the person holding the phone isn't. The card
reports the failure as before and a dialog offers a forced pull, naming
the branch and the upstream it is about to overwrite; confirming sends
?force=true, which resets onto the upstream instead of merging.

Whether it *is* that failure is decided structurally, by `git merge-base`
finding no common ancestor, rather than by matching what git printed:
those messages are translated, and a button that appeared only on an
English build machine would be worse than no button. It travels to the
phone as its own field for the same reason. The dirty-tree refusal stays
in front of it, so a forced pull can only ever discard something that was
committed, and a merely diverged history is not offered it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 22:03:44 -04:00
1 parent b0e83059a3
commit db47972a25
6 files changed
+306 -29

No files matched your search

+39 -7
View File
@@ -102,6 +102,12 @@ struct Inner {
/// where this is being driven from and usually has no access to the
/// server's log.
error: Option<String>,
/// Whether that failure was a pull with no fast-forward to make,
/// which is the one the phone can offer a way past. Beside the
/// message rather than inside it because the phone acts on it: a
/// button that appears only when git happened to phrase itself a
/// certain way is a button nobody can rely on.
unrelated_histories: bool,
/// The component whose step produced that failure.
///
/// Kept beside the message so the card can open the right log without
@@ -180,6 +186,10 @@ pub struct BuildStatus {
pub stale: bool,
pub building: bool,
pub error: Option<String>,
/// That failure was a pull the checkout has no fast-forward for,
/// because it shares no history with its upstream. The phone offers
/// the forced pull for this and nothing else.
pub unrelated_histories: bool,
/// What the whole project is doing -- fetching, pulling -- absent
/// between runs and while the work belongs to a component instead.
pub phase: Option<String>,
@@ -387,6 +397,7 @@ impl BuildState {
inner.building = true;
inner.error = None;
inner.failed = None;
inner.unrelated_histories = false;
inner.started = Some(Instant::now());
inner.runs.clear();
}
@@ -509,8 +520,14 @@ impl BuildState {
/// whose new commits changed the declaration built anyway -- the card
/// went off and updated the app instead of stopping to ask, which is
/// the single case this gate exists for.
/// `force` abandons this checkout's own history in favour of the
/// upstream's, and is only ever pressed after a pull has already
/// reported that the two are unrelated (`git::PullError`). It is a
/// parameter rather than something decided here because it is a
/// person's answer to that report, not a state of the repository.
pub fn pull_and_build(
self: &Arc<Self>,
force: bool,
may_build: impl Fn() -> bool + Send + 'static,
record: RecordBuilt,
) {
@@ -522,18 +539,18 @@ impl BuildState {
inner.building = true;
inner.error = None;
inner.failed = None;
inner.unrelated_histories = false;
inner.started = Some(Instant::now());
inner.runs.clear();
}
let this = Arc::clone(self);
tokio::task::spawn_blocking(move || {
let outcome = this.pull();
let outcome = this.pull(force);
match outcome {
Err(message) => {
tracing::error!("pull failed: {message}");
// No component: a pull fails before the walk starts.
this.finish(Some(message), None);
Err(error) => {
tracing::error!("pull failed: {}", error.message);
this.fail_pull(error);
}
Ok(pulled) => {
// Nothing configured to build, or nothing allowed to:
@@ -553,7 +570,7 @@ impl BuildState {
}
/// Fetches and fast-forwards, reporting whether anything arrived.
fn pull(&self) -> Result<bool, String> {
fn pull(&self, force: bool) -> Result<bool, crate::git::PullError> {
self.begin_project("fetching");
crate::git::fetch(&self.project_path, self.git_ipv4)?;
// Counted after the fetch, from refs now on disk: this is the one
@@ -563,7 +580,7 @@ impl BuildState {
return Ok(false);
}
self.begin_project("pulling");
crate::git::pull(&self.project_path, self.git_ipv4)?;
crate::git::pull(&self.project_path, self.git_ipv4, force)?;
tracing::info!(
"pulled {behind} commit(s) into {}",
self.project_path.display()
@@ -990,6 +1007,19 @@ impl BuildState {
inner.failed = failed;
}
/// Finishes a run that never got past its pull.
///
/// Separate from [`Self::finish`] only because a pull failure carries
/// the one thing a build failure cannot: whether abandoning this
/// checkout's own history would clear it. Cleared where its siblings
/// are, at the start of every run, so this is the only thing that can
/// ever make it true.
fn fail_pull(&self, error: crate::git::PullError) {
self.inner.lock().unwrap().unrelated_histories = error.unrelated_histories;
// No component: a pull fails before the walk starts.
self.finish(Some(error.message), None);
}
/// Records how far the running command says it has got. Replaces the
/// previous count rather than accumulating: the command reports its
/// own total, and `begin` clears this so one component's count is
@@ -1041,6 +1071,7 @@ impl BuildState {
stale,
building: inner.building,
error: inner.error.clone(),
unrelated_histories: inner.unrelated_histories,
phase: inner.phase.clone(),
elapsed_ms: inner
.started
@@ -1230,6 +1261,7 @@ mod tests {
let project = clone.clone();
state.pull_and_build(
false,
move || crate::config::project_config(&project).matches_accepted(&accepted, None),
Arc::new(|_: &str, _: String| {}),
);
+131 -11
View File
@@ -246,28 +246,70 @@ fn run_bounded(project: &Path, ipv4: bool, args: &[&str]) -> Result<String, Stri
}
}
/// What stopped a pull, and whether there is anything the phone can do
/// about it.
///
/// Every failure here is a message and nothing else, with one exception: a
/// branch and its upstream that share no commit have no fast-forward
/// between them and never will, so the only thing that would ever move
/// that checkout is discarding one side's history. That is destructive
/// enough that nobody should do it by inference, so it travels as a fact
/// of its own rather than as a message the phone would have to match on --
/// git's is translated, and a button that appears only in English is
/// worse than no button.
#[derive(Debug)]
pub struct PullError {
pub message: String,
pub unrelated_histories: bool,
}
impl From<String> for PullError {
fn from(message: String) -> Self {
Self {
message,
unrelated_histories: false,
}
}
}
/// Fast-forwards the current branch onto its upstream.
///
/// `--ff-only` deliberately: a merge or a rebase can conflict, and
/// resolving a conflict is not something to start from a phone with no way
/// to finish it. Refusing to touch a dirty tree is the same reasoning --
/// the failure is reported and the working tree is left exactly as it was.
pub fn pull(project: &Path, ipv4: bool) -> Result<(), String> {
///
/// `force` replaces the fast-forward with a hard reset onto the upstream,
/// which is what somebody presses after being told the histories are
/// unrelated: it throws away every commit this checkout has that the
/// remote does not. The dirty check stays in front of it, so what it
/// discards is always something that was committed -- still in the
/// reflog on the build machine -- and never somebody's uncommitted work.
pub fn pull(project: &Path, ipv4: bool, force: bool) -> Result<(), PullError> {
let status = status(project).ok_or_else(|| "not a git repository".to_string())?;
if status.upstream.is_none() {
return Err(format!(
"branch {} tracks no upstream, so there is nothing to pull",
status.branch
));
)
.into());
}
if status.dirty {
return Err(format!(
"{} has uncommitted changes -- refusing to pull over them. Commit or stash on the \
build machine first.",
project.display()
));
)
.into());
}
if force {
git(project, &["reset", "--hard", "@{u}"])?;
} else if let Err(message) = git(project, &["merge", "--ff-only", "@{u}"]) {
return Err(PullError {
unrelated_histories: unrelated_to_upstream(project),
message,
});
}
git(project, &["merge", "--ff-only", "@{u}"])?;
// A merge moves the *pointer* a submodule is recorded at without
// touching its working tree, so a pull that updated one leaves the
@@ -298,6 +340,22 @@ pub fn pull(project: &Path, ipv4: bool) -> Result<(), String> {
Ok(())
}
/// Whether HEAD and its upstream share any commit at all.
///
/// Asked of the object store rather than read off what git printed:
/// "refusing to merge unrelated histories" is a translated string, so
/// matching it would work on this machine and quietly stop working on one
/// running in another language. `merge-base` fails, printing nothing,
/// exactly when there is no common ancestor.
///
/// Only asked once a fast-forward has already failed, which is what keeps
/// its other ways of failing -- an upstream that doesn't resolve, say --
/// from being reported as unrelated history: by then the merge has just
/// resolved both revisions itself.
fn unrelated_to_upstream(project: &Path) -> bool {
git(project, &["merge-base", "HEAD", "@{u}"]).is_err()
}
/// The phone-sized version of a failed remote command: the first line git
/// printed, plus what this process can see of the ssh agent when what
/// failed was authentication.
@@ -571,7 +629,7 @@ mod tests {
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, true).expect("fetch with ipv4 forced");
pull(&clone, true).expect("pull with ipv4 forced");
pull(&clone, true, false).expect("pull with ipv4 forced");
assert!(!has_new_commits(&clone, true).expect("check"));
}
@@ -622,7 +680,7 @@ mod tests {
// Pull is what actually takes them.
fetch(&clone, false).expect("fetch");
assert_eq!(behind(&clone), 1);
pull(&clone, false).expect("pull");
pull(&clone, false, false).expect("pull");
assert!(!has_new_commits(&clone, false).expect("check"));
}
@@ -654,7 +712,7 @@ mod tests {
"fetched but not merged is still something to pull",
);
pull(&clone, false).expect("pull");
pull(&clone, false, false).expect("pull");
run(
&clone,
&["git", "commit", "-q", "--allow-empty", "-m", "local"],
@@ -759,7 +817,7 @@ mod tests {
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
pull(&clone, false).expect("pull");
pull(&clone, false, false).expect("pull");
assert_eq!(behind(&clone), 0);
assert_eq!(
std::fs::read_to_string(clone.join("file")).expect("read"),
@@ -780,8 +838,12 @@ mod tests {
std::fs::write(clone.join("file"), "local edit").expect("write");
assert!(status(&clone).expect("status").dirty);
let err = pull(&clone, false).expect_err("should refuse");
assert!(err.contains("uncommitted changes"), "{err}");
let err = pull(&clone, false, false).expect_err("should refuse");
assert!(
err.message.contains("uncommitted changes"),
"{}",
err.message
);
// Left exactly as it was.
assert_eq!(
std::fs::read_to_string(clone.join("file")).expect("read"),
@@ -789,6 +851,63 @@ mod tests {
);
}
/// The one failure the phone may override, so it has to be
/// distinguishable from every other reason a pull stops -- and the
/// override has to actually take the remote's history rather than
/// somehow merging the two.
///
/// The second half is the case the button must *not* appear on: a
/// history that has merely diverged still shares a commit, so there is
/// something better than throwing it away and this must not offer to.
#[test]
fn only_an_unrelated_history_offers_the_override() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
// A local history sharing no commit with the remote's, which is
// what a checkout re-created from somewhere else looks like.
run(&clone, &["git", "checkout", "-q", "--orphan", "rebuilt"]);
std::fs::write(clone.join("file"), "local").expect("write");
run(&clone, &["git", "add", "."]);
run(&clone, &["git", "commit", "-qm", "unrelated"]);
run(&clone, &["git", "branch", "-qM", "main"]);
run(
&clone,
&[
"git",
"branch",
"-q",
"--set-upstream-to=origin/main",
"main",
],
);
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
let err = pull(&clone, false, false).expect_err("no fast-forward exists");
assert!(err.unrelated_histories, "{}", err.message);
pull(&clone, false, true).expect("forced pull");
assert_eq!(behind(&clone), 0);
assert_eq!(
std::fs::read_to_string(clone.join("file")).expect("read"),
"two"
);
// Now the two share everything up to here and each add a commit,
// which is a pull that cannot fast-forward for an entirely
// ordinary reason.
std::fs::write(clone.join("file"), "mine").expect("write");
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");
let err = pull(&clone, false, false).expect_err("diverged");
assert!(!err.unrelated_histories, "{}", err.message);
}
#[test]
fn a_branch_tracking_nothing_has_nothing_to_pull() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -799,8 +918,9 @@ mod tests {
assert_eq!(status.branch, "detached-work");
assert_eq!(status.upstream, None);
assert!(
pull(&clone, false)
pull(&clone, false, false)
.expect_err("no upstream")
.message
.contains("no upstream")
);
// Nothing to compare against, so the list is told plainly rather
+16
View File
@@ -12,6 +12,7 @@
//! PUT /roots {roots} set the directories /suggestions scans
//! GET /apps/{key}/apk[?variant=] the payload itself (ranged)
//! POST /apps/{key}/pull fetch, fast-forward, and build
//! ?force=true resets onto the upstream
//! POST /apps/{key}/prepare run the on-demand build step, if any
//! POST /apps/{key}/build run it whether or not it looks stale
//! POST /apps/{key}/approve accept the build step it asks for
@@ -1024,6 +1025,19 @@ struct PurgeQuery {
config: bool,
}
/// Whether a pull may throw this checkout's own history away.
///
/// Off unless the phone says otherwise, and it only says so after being
/// told the checkout and its upstream are unrelated -- there is no
/// fast-forward for that ever, so the alternative to this is a card that
/// can never be pulled again. Destructive, and confirmed on the phone
/// against the branch it names before it is ever sent.
#[derive(Deserialize)]
struct PullQuery {
#[serde(default)]
force: bool,
}
/// Which build a download wants. Absent means the newest, which is what
/// every phone gets until it says otherwise.
#[derive(Deserialize)]
@@ -1180,6 +1194,7 @@ async fn build_now(
async fn build_pull(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
Query(query): Query<PullQuery>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
let build = entry
@@ -1191,6 +1206,7 @@ async fn build_pull(
// here is the answer for the commit being replaced.
let gate = Arc::clone(&entry);
build.pull_and_build(
query.force,
move || gate.pending_declaration().is_none(),
records_builds(&state, &entry.key),
);