Offer the rollback, and call it Downgrade
Picking an old commit from the phone detaches HEAD, so every rollback is parked by construction -- and a parked checkout withheld the commit comparison entirely. The card answered the move with "up to date" and nothing to press, which left a rollback unfinishable from the phone. The withholding was right for one direction only. A checkout parked past what was built is somebody looking, and nagging about it is nagging about a decision. A checkout parked *behind* it is somebody rolling back, and there the point of the move is to get that commit built and installed. So `git::is_ancestor` asks which of the two commits is the later one -- one extra process, and only where they already differ -- and `Freshness::BuiltAhead` says the build is. It is stale like `Behind`, so the card leaves "Up to date" and both Update buttons come alive; `updateWord` then says Downgrade, at both scales and by one rule, the way `buildWord` does. Nothing else about the press differs, which is what was asked for. The row says "newer than the checkout" rather than "out of date", which is the wrong direction. Two things fell out of it. `UpdateButton` asks about the direction before comparing file times, since what is on disk stays older than the phone's copy right up until the press rebuilds it -- otherwise a card with a rollback waiting says "Reinstall". And a project-wide Update now does the build-and-install half alone when there is nothing to pull *from* -- a parked checkout, a branch tracking nothing, or `gitPull` off -- rather than failing on a pull it was never going to make, which is what a downgrade press used to hit. Verified end to end against a throwaway checkout driven through a scratch server: `current` on the built commit, `builtAhead` once parked back on the one before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuRzBWJuEGaWmMhDuX3X3p
This commit is contained in:
1 parent
c41c36f25d
commit
283b971186
5 files changed
+287
-52
No files matched your search
+128
-13
@@ -45,7 +45,8 @@ const PROGRESS_MARKER: &str = "@@progress ";
|
||||
///
|
||||
/// The states divide into three groups, and the phone is where that
|
||||
/// division is written down (`ProjectComponent.isStale`, one place):
|
||||
/// something to do about it (`Behind`, `NeverBuilt`, `OtherMode`),
|
||||
/// something to do about it (`Behind`, `BuiltAhead`, `NeverBuilt`,
|
||||
/// `OtherMode`),
|
||||
/// nothing to do because it is current, and *cannot tell* (the rest).
|
||||
/// Only the first group makes a card read as out of date -- saying that
|
||||
/// about something nobody measured, or about a commit somebody
|
||||
@@ -57,6 +58,21 @@ pub enum Freshness {
|
||||
Current,
|
||||
/// Its directory has moved past the commit it was built from.
|
||||
Behind,
|
||||
/// Built from a commit that comes *after* the one its directory is on,
|
||||
/// which is a checkout somebody moved back: the build is the newer of
|
||||
/// the two, so matching it to the checkout is a rollback rather than
|
||||
/// an update, and the phone says "Downgrade" where it would say
|
||||
/// "Update".
|
||||
///
|
||||
/// Reported whether or not the checkout is parked, unlike `Parked`
|
||||
/// below. Withholding it there was the whole of what made rolling
|
||||
/// back unreachable from the phone -- picking an old commit detaches
|
||||
/// HEAD, so every rollback is parked by construction, and the card
|
||||
/// answered the move with "up to date" and nothing to press. Bryan
|
||||
/// asked for this on 2026-09-06. There is also nothing here to
|
||||
/// withhold: which way round the two commits are is a measurement,
|
||||
/// not a guess about what somebody meant by parking.
|
||||
BuiltAhead,
|
||||
/// Nothing where this component's builds land, so there is a first
|
||||
/// build to make rather than a comparison to draw.
|
||||
NeverBuilt,
|
||||
@@ -78,8 +94,10 @@ pub enum Freshness {
|
||||
NotBuiltHere,
|
||||
/// Cannot tell: no checkout under this project, or git would not say.
|
||||
NoCheckout,
|
||||
/// Withheld: the checkout is parked on a commit somebody chose, so a
|
||||
/// differing commit is a decision rather than something behind.
|
||||
/// Withheld: the checkout is parked on a commit somebody chose and
|
||||
/// the build is the *older* of the two, so a differing commit is a
|
||||
/// decision rather than something behind. The other direction is
|
||||
/// `BuiltAhead` above, which is reported.
|
||||
Parked,
|
||||
}
|
||||
|
||||
@@ -625,14 +643,17 @@ impl BuildState {
|
||||
/// rather than following a branch, which is the one way a phone can
|
||||
/// leave it: picking a commit in the settings sheet detaches HEAD.
|
||||
/// Saying "out of date" about a checkout somebody deliberately moved
|
||||
/// backwards is nagging about a decision already made -- and it is
|
||||
/// nothing to act on either, because every way this reads as behind
|
||||
/// while parked is a build that failed, a declaration waiting to be
|
||||
/// accepted, or a component with no build step, each of which is
|
||||
/// already said on the same card beside the button for it. So while
|
||||
/// parked a differing commit is reported as unknown: withheld, rather
|
||||
/// than claimed current, since nothing here measured the output to be
|
||||
/// what somebody wanted.
|
||||
/// forward is nagging about a decision already made, so while parked
|
||||
/// that direction is reported as unknown: withheld, rather than
|
||||
/// claimed current, since nothing here measured the output to be what
|
||||
/// somebody wanted.
|
||||
///
|
||||
/// The other direction is not withheld. A checkout parked *behind*
|
||||
/// what was built is somebody rolling back, and there the decision
|
||||
/// argues the opposite way: the whole point of the move is to get
|
||||
/// that older commit built and installed, and withholding it left the
|
||||
/// card saying "up to date" with nothing to press. See
|
||||
/// `Freshness::BuiltAhead`.
|
||||
///
|
||||
/// Passed in rather than read here. The caller has already asked git
|
||||
/// for this project's status, and asking again would be one more
|
||||
@@ -680,12 +701,34 @@ impl BuildState {
|
||||
}
|
||||
match crate::git::subtree_head(&self.project_path, &component.watched_paths()) {
|
||||
Some(current) if ¤t == built => Freshness::Current,
|
||||
Some(_) if parked => Freshness::Parked,
|
||||
Some(_) => Freshness::Behind,
|
||||
Some(current) => self.which_way_round(¤t, built, parked),
|
||||
None => Freshness::NoCheckout,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of two differing commits is the later one, and so whether
|
||||
/// there is an update or a rollback waiting.
|
||||
///
|
||||
/// One more process on the manifest path, which is fetched on every
|
||||
/// open, resume and Refresh -- paid only where the two commits already
|
||||
/// differ, so a project whose builds match its checkout, which is the
|
||||
/// ordinary state, spawns nothing extra. That is also the only place
|
||||
/// the answer is worth anything.
|
||||
///
|
||||
/// Anything git could not answer, and every other way the two commits
|
||||
/// can relate -- a checkout moved forward, or onto a branch that
|
||||
/// diverged -- keeps what this said before: behind, or withheld while
|
||||
/// parked.
|
||||
fn which_way_round(&self, current: &str, built: &str, parked: bool) -> Freshness {
|
||||
if crate::git::is_ancestor(&self.project_path, current, built) == Some(true) {
|
||||
return Freshness::BuiltAhead;
|
||||
}
|
||||
if parked {
|
||||
return Freshness::Parked;
|
||||
}
|
||||
Freshness::Behind
|
||||
}
|
||||
|
||||
/// Whether this component has no build output at all.
|
||||
///
|
||||
/// The state a commit cannot express, which is why both readers of
|
||||
@@ -2284,6 +2327,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The other direction, which is what rolling back from the phone
|
||||
/// produces: picking an older commit detaches HEAD, so the checkout is
|
||||
/// parked *behind* the build rather than in front of it.
|
||||
///
|
||||
/// Withheld like the case above, this was the whole of why a rollback
|
||||
/// could not be finished from the phone -- the card read as up to date
|
||||
/// and offered nothing to press, with the older commit sitting
|
||||
/// unbuilt on the build machine. Reported here whichever way the
|
||||
/// checkout got there, since which commit is the later one is
|
||||
/// something git was asked rather than something inferred from the
|
||||
/// move.
|
||||
#[test]
|
||||
fn a_checkout_moved_back_behind_its_build_says_the_build_is_the_newer_one() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
let components = two_component_checkout(root);
|
||||
let first = crate::git::subtree_head(root, &[PathBuf::from("app")]).expect("a commit");
|
||||
|
||||
std::fs::write(root.join("app/main.kt"), "two").expect("write");
|
||||
run(root, &["git", "commit", "-qam", "two"]);
|
||||
let second = crate::git::subtree_head(root, &[PathBuf::from("app")]).expect("a commit");
|
||||
|
||||
// Built at the second commit, and the checkout then moved back to
|
||||
// the first: exactly what the commit picker leaves behind.
|
||||
run(root, &["git", "checkout", "-q", &first]);
|
||||
|
||||
let state = state_for(root, components);
|
||||
state
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.built_from
|
||||
.insert("app".to_string(), second);
|
||||
let app = state
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| component.name() == "app")
|
||||
.expect("the app component");
|
||||
|
||||
assert_eq!(
|
||||
state.freshness(app, true),
|
||||
Freshness::BuiltAhead,
|
||||
"a checkout parked behind its build has a rollback waiting, not nothing to do",
|
||||
);
|
||||
assert_eq!(
|
||||
state.freshness(app, false),
|
||||
Freshness::BuiltAhead,
|
||||
"and the same is true of one reset back on its own branch",
|
||||
);
|
||||
}
|
||||
|
||||
/// The words themselves, because they are half of an agreement with a
|
||||
/// file in the other language: the phone matches these strings to
|
||||
/// decide whether a card has work waiting, what its row says, and
|
||||
/// whether its button offers an update or a downgrade. A rename here
|
||||
/// is a card that silently says nothing there, which is why the two
|
||||
/// states that mean "there is a commit difference" are pinned rather
|
||||
/// than left to `rename_all` and a reader's memory.
|
||||
#[test]
|
||||
fn the_freshness_words_are_what_the_phone_matches_on() {
|
||||
for (state, word) in [
|
||||
(Freshness::Behind, "\"behind\""),
|
||||
(Freshness::BuiltAhead, "\"builtAhead\""),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&state).expect("serialize"),
|
||||
word,
|
||||
"UpdateManifest.kt matches this string",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three ways of not knowing are three different answers, and the
|
||||
/// card has to be able to say which.
|
||||
///
|
||||
|
||||
@@ -138,6 +138,38 @@ pub fn subtree_head(project: &Path, within: &[PathBuf]) -> Option<String> {
|
||||
(!sha.is_empty()).then_some(sha)
|
||||
}
|
||||
|
||||
/// Whether `ancestor` is reachable from `descendant` -- the question
|
||||
/// "which way round is this difference?" once two commits are known to
|
||||
/// differ.
|
||||
///
|
||||
/// Its one caller compares the commit a component was built from against
|
||||
/// the one its directory is on now, and a checkout sitting at an ancestor
|
||||
/// of the build is one somebody moved *back*: the build is the newer of
|
||||
/// the two, and bringing them together means building something older
|
||||
/// than what is installed.
|
||||
///
|
||||
/// The exit status is the whole answer, which is why this does not go
|
||||
/// through [`git`]: `--is-ancestor` reports no with a status of 1 and
|
||||
/// nothing on stderr, so a helper that turns every non-zero exit into an
|
||||
/// error cannot tell "no" from "could not tell". Anything but 0 or 1 is
|
||||
/// the latter -- most often a commit this checkout no longer has -- and
|
||||
/// `None` keeps that out of the answer rather than passing it off as a
|
||||
/// direction.
|
||||
pub fn is_ancestor(project: &Path, ancestor: &str, descendant: &str) -> Option<bool> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(project)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.ok()?;
|
||||
match output.status.code() {
|
||||
Some(0) => Some(true),
|
||||
Some(1) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the upstream branch has that this one doesn't, counted from
|
||||
/// refs already on disk. Only meaningful right after a fetch, which is why
|
||||
/// the only caller is the pull itself.
|
||||
|
||||
Reference in new issue
Block a user