Claiming the whole project means what's stale, not everything built here

after_moving (Pull and Update's shared machinery) gated a post-pull build
on `pulled || is_stale(None)` and then, once open, `claim(&mut inner,
None)` -- which marks every component with a build command regardless of
whether it was the one that moved. `trigger_if_needed(None, ...)` had the
same shape one level up: check is_stale, then hand off to build_now, which
is unconditional by design. So any pull that fetched anything rebuilt
every sibling beside the one that actually changed -- "it tries to update
the compose apk even if nothing was done."

`claim` now takes `only_stale`: false for build_now (a person pressed a
specific button, and staleness has no business overruling that), true for
trigger_if_needed and for the build after_moving runs after a pull.
component_is_stale was already scoped correctly per component; the bug was
building past that answer once any one component tripped it.

MismatchedPairNote also had its own word for the same "behind" freshness
freshnessNote already calls "out of date" -- "older than this build" was a
second name for one measurement, which is what "pulling says something
weird about the compose apk" turned out to be. Reused the existing word.

Regression test: a_pull_touching_one_component_does_not_rebuild_its_sibling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet 5 committed 2026-09-06 13:45:45 -04:00
1 parent 663626345a
commit c41c36f25d
3 files changed
+194 -19

No files matched your search

+26
View File
@@ -1320,6 +1320,32 @@ mutable at runtime from the phone.
version. It follows that a build and an install landing in the same version. It follows that a build and an install landing in the same
second can briefly read as "update available"; that's inherent, not a second can briefly read as "update available"; that's inherent, not a
bug to chase. bug to chase.
- **Claiming "the whole project" must still mean "what's actually
stale," not "everything with a command."** `after_moving` (what Pull
and Update share) used to gate a post-pull build on
`pulled || is_stale(None)` and then, once open, `claim(&mut inner,
None)` -- which marks *every* component with a build command, stale or
not. `trigger_if_needed(None, ...)` had the same shape: check
`is_stale`, then hand off to `build_now`, which is unconditional by
design (it is also the button). So any pull that fetched anything --
a one-line fix under `server/`, say -- rebuilt the APK beside it too,
reported as "it tries to update the compose apk even if nothing was
done." `claim` now takes `only_stale`: `false` for `build_now` (a
person pressed a specific button, and staleness has no business
overruling that), `true` for `trigger_if_needed` and for the build
`after_moving` runs after a pull. `component_is_stale` was already
scoped correctly per component (`watched_paths`) -- the bug was never
in *what* counts as stale, only in building past that answer once any
one component tripped it. Regression test:
`a_pull_touching_one_component_does_not_rebuild_its_sibling`.
- **Two words for one measurement is a bug even when both are true.**
`MismatchedPairNote` said a sibling still on its old build was "older
than this build," while `freshnessNote` already has a word for that
exact freshness (`behind` → "out of date"). Same field, two names --
which is what "pulling says something weird about the compose apk"
turned out to be: not a wrong condition, a second vocabulary for a
state the reader had already been told about elsewhere on the same
card. Reuse the existing word rather than inventing an adjacent one.
## Running the server for real ## Running the server for real
@@ -3146,8 +3146,13 @@ private fun MismatchedPairNote(self: ProjectComponent, others: List<ProjectCompo
if (behind.isEmpty()) return if (behind.isEmpty()) return
val names = behind.joinToString(", ") { it.name } val names = behind.joinToString(", ") { it.name }
Text( Text(
if (behind.size == 1) "$names is older than this build, so the two would not match." // "Out of date" rather than "older": that is already the word
else "$names are older than this build, so they would not match.", // `freshnessNote` uses for this exact freshness ("behind"), and a
// second word for the same measurement reads as a second, weaker
// signal rather than the same one said twice -- worth avoiding
// since this note and that one are drawn from the same field.
if (behind.size == 1) "$names is out of date, so the two would not match."
else "$names are out of date, so they would not match.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = ActionTone.Caution.color, color = ActionTone.Caution.color,
) )
+161 -17
View File
@@ -480,10 +480,23 @@ impl BuildState {
/// doesn't also pay for the other's build. `None` is every other /// doesn't also pay for the other's build. `None` is every other
/// caller, which still means "the whole project". /// caller, which still means "the whole project".
pub fn trigger_if_needed(self: &Arc<Self>, component: Option<&str>, record: RecordBuilt) { pub fn trigger_if_needed(self: &Arc<Self>, component: Option<&str>, record: RecordBuilt) {
// Before the lock, not inside it -- see `is_stale`. let claimed = {
if self.is_stale(component) { let mut inner = self.inner.lock().unwrap();
self.build_now(component, record); // The one thing a component still waits behind. See
} // `Inner::pulling`.
if inner.pulling {
return;
}
// `true` is the whole of this method: claim only what is
// individually stale, so naming no component still means "the
// whole project" while leaving every component already
// current alone. Passing `component` through unfiltered here
// used to build every component with a command the moment any
// one of them was stale -- pressing Update to bring in a
// one-line server fix rebuilt the APK beside it every time.
self.claim(&mut inner, component, true)
};
self.start(claimed, record);
} }
/// Builds whether or not anything looks stale, for somebody who asked. /// Builds whether or not anything looks stale, for somebody who asked.
@@ -514,7 +527,7 @@ impl BuildState {
if inner.pulling { if inner.pulling {
return; return;
} }
self.claim(&mut inner, component) self.claim(&mut inner, component, false)
}; };
self.start(claimed, record); self.start(claimed, record);
} }
@@ -522,6 +535,15 @@ impl BuildState {
/// Marks each component `name` selects that has something to build /// Marks each component `name` selects that has something to build
/// and is not already building, and answers where they are. /// and is not already building, and answers where they are.
/// ///
/// `only_stale` is the whole difference between a person's button and
/// an automatic trigger: `false` for `build_now`, where somebody
/// pressed a specific button and every component `name` selects
/// starts regardless of what it looks like; `true` for
/// `trigger_if_needed`, where naming no component means "the whole
/// project" and that must not mean "every component with a command,
/// stale or not" -- a pull that only touched one component's
/// directory has no business rebuilding the others beside it.
///
/// Takes the guard rather than the lock so that a caller can do this /// Takes the guard rather than the lock so that a caller can do this
/// and something else in one step -- which the pull needs: releasing /// and something else in one step -- which the pull needs: releasing
/// `pulling` and claiming what it decided to build have to happen /// `pulling` and claiming what it decided to build have to happen
@@ -533,7 +555,7 @@ impl BuildState {
/// returns, so a status read in that window would otherwise find the /// returns, so a status read in that window would otherwise find the
/// component idle -- and idle is exactly what the phone is waiting /// component idle -- and idle is exactly what the phone is waiting
/// for. /// for.
fn claim(&self, inner: &mut Inner, name: Option<&str>) -> Vec<usize> { fn claim(&self, inner: &mut Inner, name: Option<&str>, only_stale: bool) -> Vec<usize> {
let mut claimed = Vec::new(); let mut claimed = Vec::new();
for (index, component) in self.components.iter().enumerate() { for (index, component) in self.components.iter().enumerate() {
if name.is_some_and(|name| component.name() != name) { if name.is_some_and(|name| component.name() != name) {
@@ -545,6 +567,9 @@ impl BuildState {
if inner.component_running(component.name()) { if inner.component_running(component.name()) {
continue; continue;
} }
if only_stale && !self.component_is_stale(component, &inner.built_from) {
continue;
}
// Whatever the last run left against this component goes now, // Whatever the last run left against this component goes now,
// so nothing from it can be read as this run's outcome -- an // so nothing from it can be read as this run's outcome -- an
// old error in particular, which the phone treats as a reason // old error in particular, which the phone treats as a reason
@@ -899,26 +924,32 @@ impl BuildState {
tracing::error!("moving the checkout failed: {}", error.message); tracing::error!("moving the checkout failed: {}", error.message);
this.fail_pull(error); this.fail_pull(error);
} }
Ok(pulled) => { Ok(_) => {
// Nothing configured to build, nothing allowed to, or // Nothing configured to build, or nothing allowed to:
// a move that builds nothing by definition: the move // the move was the whole job. Which components (if
// was the whole job. // any) actually build is `claim`'s question, not this
// one -- a pull that fetched something is not itself a
// reason to rebuild a component whose own directory
// the fetch never touched. That used to be folded in
// here as `pulled || this.is_stale(None)`, which
// meant *any* pull rebuilt *every* component with a
// command: pressing Update to bring in a one-line
// server fix rebuilt the APK beside it every time,
// whether or not `app/` had moved.
// `may_build()` is called here, with the pulled // `may_build()` is called here, with the pulled
// declaration on disk, for the reason in the doc // declaration on disk, for the reason in the doc
// comment above. Both it and `is_stale` take the lock // comment above.
// themselves, so they are asked before it is held. let may_build = then_build
let build = then_build
.as_ref() .as_ref()
.is_some_and(|(may_build, _)| may_build()) .is_some_and(|(may_build, _)| may_build())
&& this.has_command() && this.has_command();
&& (pulled || this.is_stale(None));
// Handing the run over in one step, so nothing can // Handing the run over in one step, so nothing can
// observe the moment between the pull ending and the // observe the moment between the pull ending and the
// builds it decided on starting -- see `claim`. // builds it decided on starting -- see `claim`.
let claimed = { let claimed = {
let mut inner = this.inner.lock().unwrap(); let mut inner = this.inner.lock().unwrap();
let claimed = match build { let claimed = match may_build {
true => this.claim(&mut inner, None), true => this.claim(&mut inner, None, true),
false => Vec::new(), false => Vec::new(),
}; };
inner.pulling = false; inner.pulling = false;
@@ -1679,6 +1710,119 @@ mod tests {
); );
} }
/// Update pulls the whole project, but a commit under only one
/// component's directory must not rebuild the other.
///
/// The bug this guards: `after_moving` used to gate the whole build on
/// `pulled || is_stale(None)` and then claim *every* component with a
/// command once that gate opened, rather than only the ones
/// `component_is_stale` actually flagged. So any pull that fetched
/// anything -- a one-line fix to `server/`, say -- rebuilt every
/// sibling beside it too, which on a real project meant pressing
/// Update to bring in a backend fix also rebuilt the APK for no
/// reason. Reported as "it tries to update the compose apk even if
/// nothing was done."
#[tokio::test]
async fn a_pull_touching_one_component_does_not_rebuild_its_sibling() {
let dir = tempfile::tempdir().expect("tempdir");
let origin = dir.path().join("origin");
std::fs::create_dir_all(origin.join("server")).expect("mkdir");
// Committed rather than left untracked: `pull` refuses to run over
// uncommitted changes, and `nothing_built` asks the filesystem for
// an Apk's own output regardless of git, so without something
// here the "app" component reads as never-built on every round --
// stale for a reason that has nothing to do with what this test
// is checking.
std::fs::create_dir_all(origin.join("app/build/outputs/apk/debug")).expect("mkdir");
std::fs::write(origin.join("app/build/outputs/apk/debug/a.apk"), b"").expect("write");
std::fs::write(origin.join("server/main.rs"), "one").expect("write");
std::fs::write(origin.join("app/main.kt"), "one").expect("write");
for args in [
&["git", "init", "-q", "-b", "main"][..],
&["git", "config", "user.email", "t@example.com"],
&["git", "config", "user.name", "Test"],
&["git", "add", "-A"],
&["git", "commit", "-qm", "one"],
] {
run(&origin, args);
}
let clone = dir.path().join("clone");
run(
dir.path(),
&[
"git",
"clone",
"-q",
origin.to_str().unwrap(),
clone.to_str().unwrap(),
],
);
let components = vec![
Component::Server {
name: "backend".to_string(),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch backend-built",
)),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
mode: None,
built_from: None,
built_mode: None,
},
Component::Apk {
name: "app".to_string(),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch app-built",
)),
cwd: Some(PathBuf::from("app")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
},
];
let state = state_for(&clone, components);
// Settle both components once, so each has a recorded commit to be
// "current" against -- otherwise both would build regardless, as
// "never built" rather than as a pull bringing something in.
state.build_now(None, Arc::new(|_: &str, _: String| {}));
while state.status().building {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(clone.join("server/backend-built").exists());
assert!(clone.join("app/app-built").exists());
std::fs::remove_file(clone.join("server/backend-built")).expect("rm");
std::fs::remove_file(clone.join("app/app-built")).expect("rm");
// A commit under the remote's `server/` alone, then Update.
std::fs::write(origin.join("server/main.rs"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "server fix"]);
state.pull_and_build(false, || true, Arc::new(|_: &str, _: String| {}));
while state.status().building {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(
clone.join("server/backend-built").exists(),
"backend moved, so it is the one to rebuild",
);
assert!(
!clone.join("app/app-built").exists(),
"nothing under app/ changed -- it must be left alone",
);
}
/// Naming one component builds only that one. /// Naming one component builds only that one.
/// ///
/// The case a project with more than one component exists to avoid /// The case a project with more than one component exists to avoid