Skip to main content

alba_engine/
affected.rs

1//! Which beams a git diff touches: the `--affected <ref>` selection.
2//!
3//! [`affected_beams`] is the pure half, over a loaded project and a list
4//! of absolute changed paths. [`select`] is the whole thing: it asks git
5//! for the paths, then narrows the result to what one run should target.
6
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use alba_core::git::changed_files;
11use alba_core::{Beam, BeamId, Project, SourceMap, execution_subgraph};
12
13use crate::EngineError;
14use crate::scheduler::Targets;
15
16/// What a run was asked for, before git (if involved) has answered.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Selection {
19    /// `alba run <beam>`: the beam and its subgraph.
20    Beam(BeamId),
21    /// `alba run --affected <ref> [beam]`: what changed since `reference`,
22    /// narrowed to `within`'s subgraph when a beam was named.
23    Affected {
24        reference: String,
25        within: Option<BeamId>,
26    },
27}
28
29impl Selection {
30    /// The beam whose subgraph a watch session puts under watch, or
31    /// `None` for the whole project.
32    pub fn watched_target(&self) -> Option<&BeamId> {
33        match self {
34            Selection::Beam(id) => Some(id),
35            Selection::Affected { within, .. } => within.as_ref(),
36        }
37    }
38}
39
40/// Every beam affected by what changed since `reference`, in project
41/// order: the beams whose `inputs` match a changed path or whose Beamfile
42/// changed, closed over their dependents. `root` is the project root git
43/// runs from; paths git reports are relative to it.
44pub fn affected(
45    project: &Project,
46    sources: &SourceMap,
47    root: &Path,
48    reference: &str,
49) -> Result<Vec<BeamId>, EngineError> {
50    let changed: Vec<PathBuf> = changed_files(root, reference)?
51        .into_iter()
52        .map(|path| root.join(path))
53        .collect();
54    Ok(affected_beams(project, sources, &changed))
55}
56
57/// [`affected`] narrowed to one run's targets. `Beam` is validated here
58/// (an unknown beam is the same `CoreError` a plain run reports), so a
59/// caller can rely on `select` for the exit-2-at-startup rule.
60pub fn select(
61    project: &Project,
62    sources: &SourceMap,
63    root: &Path,
64    selection: &Selection,
65) -> Result<Targets, EngineError> {
66    match selection {
67        Selection::Beam(id) => {
68            execution_subgraph(project, id)?;
69            Ok(Targets::beam(id.clone()))
70        }
71        Selection::Affected { reference, within } => {
72            let affected = affected(project, sources, root, reference)?;
73            let beams = match within {
74                // Closure over dependents means: if anything in the
75                // subgraph is affected, so is its root. The subgraph then
76                // runs as usual, the cache skipping what did not move.
77                Some(id) => {
78                    execution_subgraph(project, id)?;
79                    if affected.contains(id) {
80                        vec![id.clone()]
81                    } else {
82                        Vec::new()
83                    }
84                }
85                // Nothing can bind a parameterized beam's arguments in a
86                // run with several targets; `alba affected` still lists it.
87                None => affected
88                    .into_iter()
89                    .filter(|id| {
90                        project
91                            .beams
92                            .iter()
93                            .any(|beam| beam.id == *id && beam.params.is_empty())
94                    })
95                    .collect(),
96            };
97            Ok(Targets {
98                beams,
99                affected_by: Some(reference.clone()),
100            })
101        }
102    }
103}
104
105/// The pure computation: `changed` holds absolute paths (existing or not).
106pub(crate) fn affected_beams(
107    project: &Project,
108    sources: &SourceMap,
109    changed: &[PathBuf],
110) -> Vec<BeamId> {
111    // No filesystem touched, deliberately: `changed` and every `beam.dir`
112    // are already absolute (the caller's `root.join(..)` and the loader's
113    // own `std::path::absolute`, respectively), built from the same root,
114    // so a lexical prefix match is both correct and syscall-free — unlike
115    // `watch::set::normalize`, which exists to reconcile paths a live OS
116    // watcher resolved through a symlink, a problem git-diff paths do not
117    // have.
118    let changed_set: HashSet<&PathBuf> = changed.iter().collect();
119    let mut affected: HashSet<&str> = HashSet::new();
120
121    for beam in &project.beams {
122        let beamfile = sources
123            .get(beam.source)
124            .map(|(path, _)| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()));
125        if beamfile.is_some_and(|beamfile| changed_set.contains(&beamfile)) {
126            affected.insert(beam.id.0.as_str());
127        }
128    }
129
130    // Beams sharing a `dir` (the common case: one Beamfile, no imports)
131    // share the same relative view of `changed`, and their patterns are
132    // compiled into one `GlobSet` per `dir` rather than one per beam:
133    // compiling is what dominates, so matching each changed path once
134    // against the combined set — instead of once per beam's own set —
135    // is what keeps this linear rather than quadratic in beams.
136    let mut groups: HashMap<&Path, Vec<&Beam>> = HashMap::new();
137    for beam in &project.beams {
138        if !beam.inputs.is_empty() {
139            groups.entry(beam.dir.as_path()).or_default().push(beam);
140        }
141    }
142    for (base, beams) in groups {
143        let mut builder = globset::GlobSetBuilder::new();
144        let mut owners: Vec<&str> = Vec::new();
145        for beam in &beams {
146            for pattern in &beam.inputs {
147                if let Ok(glob) = globset::Glob::new(pattern) {
148                    builder.add(glob);
149                    owners.push(beam.id.0.as_str());
150                }
151            }
152        }
153        let Ok(set) = builder.build() else { continue };
154        for path in changed {
155            let Ok(relative) = path.strip_prefix(base) else {
156                continue;
157            };
158            let unified = relative.to_string_lossy().replace('\\', "/");
159            let candidate = globset::Candidate::new(&unified);
160            for index in set.matches_candidate(&candidate) {
161                affected.insert(owners[index]);
162            }
163        }
164    }
165
166    // Closure over dependents. ponytail: fixpoint over the whole beam
167    // list, quadratic in beams; a reverse adjacency map if a project ever
168    // has thousands of them.
169    loop {
170        let before = affected.len();
171        for beam in &project.beams {
172            if beam
173                .needs
174                .iter()
175                .any(|need| affected.contains(need.value.0.as_str()))
176            {
177                affected.insert(beam.id.0.as_str());
178            }
179        }
180        if affected.len() == before {
181            break;
182        }
183    }
184
185    project
186        .beams
187        .iter()
188        .filter(|beam| affected.contains(beam.id.0.as_str()))
189        .map(|beam| beam.id.clone())
190        .collect()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::time::{Duration, Instant};
197
198    /// The spec's guard: on the order of ten milliseconds, git's own time
199    /// excluded. 300 beams in a chain, each with an input pattern, against
200    /// 2000 changed paths; the median of 15 samples, as the other guards do.
201    #[test]
202    fn affected_selection_stays_imperceptible() {
203        let mut source = String::new();
204        for i in 0..300 {
205            let needs = if i == 0 {
206                String::new()
207            } else {
208                format!("needs [b{}] ", i - 1)
209            };
210            source.push_str(&format!(
211                "beam b{i} {{ {needs}inputs [\"src/m{i}/**\"] run \"x\" }}\n"
212            ));
213        }
214        let dir = tempfile::tempdir().unwrap();
215        std::fs::write(dir.path().join("Beamfile"), &source).unwrap();
216        let (project, sources) = alba_core::load_project(&dir.path().join("Beamfile")).unwrap();
217        let changed: Vec<PathBuf> = (0..2000)
218            .map(|i| dir.path().join(format!("src/m{}/f{i}.rs", i % 300)))
219            .collect();
220
221        let mut samples: Vec<Duration> = (0..15)
222            .map(|_| {
223                let start = Instant::now();
224                let affected = affected_beams(&project, &sources, &changed);
225                assert_eq!(affected.len(), 300);
226                start.elapsed()
227            })
228            .collect();
229        samples.sort();
230        let median = samples[samples.len() / 2];
231        assert!(median < Duration::from_millis(50), "median {median:?}");
232    }
233}