A component can name the files its build reads elsewhere

Freshness compared the commits under a component's own cwd, which is
right about where its files are and wrong about what its build reads.
tdep-survey's two clients each source one shared scripts/android-sdk-env.sh:
a fix committed there changed what every build does, moved no component's
subtree head, rebuilt nothing, and left every card correctly reporting
"current" while answering a narrower question than the reader was asking.

alsoWatch names the rest. The paths join the component's own in the same
subtree-head and dirty comparisons -- git takes several pathspecs, so it
stays one call each -- and it is part of the acceptance gate like any
other declared field. Declared rather than inferred: which files a build
reads is not knowable from here, and both wrong guesses are expensive.

Raised by the tdep-survey session, which traced why its dioxus client
could never become stale on the serving host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-01 11:11:07 -04:00
1 parent f00c3b970f
commit b67c70fa2f
7 files changed
+209 -18

No files matched your search

+76 -5
View File
@@ -556,10 +556,11 @@ impl BuildState {
let Some(built) = built else {
return Freshness::Unknown;
};
if crate::git::subtree_dirty(&self.project_path, component.cwd()) != Some(false) {
if crate::git::subtree_dirty(&self.project_path, &component.watched_paths()) != Some(false)
{
return Freshness::Unknown;
}
match crate::git::subtree_head(&self.project_path, component.cwd()) {
match crate::git::subtree_head(&self.project_path, &component.watched_paths()) {
Some(current) if current == built => Freshness::Current,
Some(_) => Freshness::Behind,
None => Freshness::Unknown,
@@ -611,7 +612,7 @@ impl BuildState {
// tell; neither is evidence of staleness, and announcing one would
// make every project built before this existed demand a rebuild.
if let Some(built) = built_from.get(component.name())
&& crate::git::subtree_head(&self.project_path, component.cwd())
&& crate::git::subtree_head(&self.project_path, &component.watched_paths())
.is_some_and(|current| &current != built)
{
return true;
@@ -850,7 +851,8 @@ impl BuildState {
//
// A checkout that cannot be read records nothing, which reads
// as unknown rather than as current -- see `component_is_stale`.
if let Some(sha) = crate::git::subtree_head(&self.project_path, component.cwd()) {
if let Some(sha) = crate::git::subtree_head(&self.project_path, &component.watched_paths())
{
self.inner
.lock()
.unwrap()
@@ -1324,6 +1326,7 @@ mod tests {
build: crate::config::Command::from_line("true"),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
built_from: None,
},
@@ -1332,6 +1335,7 @@ mod tests {
build: crate::config::Command::from_line("true"),
cwd: Some(PathBuf::from("app")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1388,6 +1392,7 @@ mod tests {
build: crate::config::Command::from_line("touch built-marker"),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1441,6 +1446,7 @@ mod tests {
build: crate::config::Command::from_line("touch backend-built"),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
built_from: None,
},
@@ -1449,6 +1455,7 @@ mod tests {
build: crate::config::Command::from_line("touch app-built"),
cwd: Some(PathBuf::from("app")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1522,6 +1529,7 @@ mod tests {
}),
cwd: Some(PathBuf::from(name)),
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1592,6 +1600,7 @@ mod tests {
}),
cwd: Some(PathBuf::from(name)),
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1661,6 +1670,7 @@ mod tests {
build: crate::config::Command::from_line("touch a-built"),
cwd: Some(PathBuf::from("a")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1670,6 +1680,7 @@ mod tests {
build: crate::config::Command::from_line("touch b-built"),
cwd: Some(PathBuf::from("b")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: None,
@@ -1767,7 +1778,7 @@ mod tests {
// Built now, from what is checked out now.
for name in ["backend", "app"] {
let cwd = PathBuf::from(if name == "backend" { "server" } else { "app" });
let sha = crate::git::subtree_head(root, Some(&cwd)).expect("a commit");
let sha = crate::git::subtree_head(root, std::slice::from_ref(&cwd)).expect("a commit");
state
.inner
.lock()
@@ -1789,6 +1800,65 @@ mod tests {
);
}
/// The other half of that scoping: a component's build reads more
/// than its own directory, and what it names with `alsoWatch` counts
/// as its own.
///
/// The case this is for had every piece defensible and the whole
/// silent: two clients sourcing one shared script that locates their
/// SDK and mints their signing key, a fix committed to that script,
/// and no component's subtree head moved -- so nothing rebuilt, and
/// every card correctly reported "current" while answering a
/// narrower question than the reader was asking. The component that
/// says nothing must still be left alone, which is the second
/// assertion here.
#[test]
fn a_commit_to_a_watched_path_makes_only_the_components_that_watch_it_stale() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let mut components = two_component_checkout(root);
let Component::Apk { also_watch, .. } = &mut components[1] else {
panic!("the app is the second component");
};
*also_watch = vec![PathBuf::from("scripts")];
let state = state_for(root, components);
std::fs::create_dir_all(root.join("scripts")).expect("mkdir");
std::fs::write(root.join("scripts/env.sh"), "one").expect("write");
run(root, &["git", "add", "-A"]);
run(root, &["git", "commit", "-qm", "scripts"]);
for name in ["backend", "app"] {
let component = state
.components
.iter()
.find(|component| component.name() == name)
.expect("component");
let sha = crate::git::subtree_head(root, &component.watched_paths()).expect("a commit");
state
.inner
.lock()
.unwrap()
.built_from
.insert(name.to_string(), sha);
}
assert!(!stale(&state, "backend"), "just built");
assert!(!stale(&state, "app"), "just built");
// A change to neither component's own directory.
std::fs::write(root.join("scripts/env.sh"), "two").expect("write");
run(root, &["git", "commit", "-qam", "shared script"]);
assert!(
stale(&state, "app"),
"the app watches scripts/, so a commit there is a commit to it",
);
assert!(
!stale(&state, "backend"),
"the server said nothing about scripts/, so nothing about it changed",
);
}
/// Nothing recorded is "we have never built this", not "this is out of
/// date" -- otherwise every project that existed before this feature
/// would demand a rebuild the moment it arrived.
@@ -1822,6 +1892,7 @@ mod tests {
build: crate::config::Command::from_line("true"),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
package: None,
built_from: Some("0000000000000000000000000000000000000000".to_string()),