Skip to main content

affected_core/
lib.rs

1pub mod config;
2pub mod detect;
3pub mod git;
4pub mod graph;
5pub mod resolvers;
6pub mod runner;
7pub mod types;
8
9use anyhow::{Context, Result};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12use tracing::debug;
13
14use types::{AffectedResult, ExplainEntry, ExplainReason};
15
16/// Main orchestration: given a project root and base ref,
17/// determine which packages are affected by git changes.
18pub fn find_affected(root: &Path, base_ref: &str) -> Result<AffectedResult> {
19    find_affected_with_options(root, base_ref, false, None, None)
20}
21
22/// Enhanced version of find_affected with support for explain, filter, and skip.
23///
24/// - `explain`: When true, populates the `explanations` field in the result.
25/// - `filter`: Optional glob pattern to include only matching package names.
26/// - `skip`: Optional glob pattern to exclude matching package names.
27pub fn find_affected_with_options(
28    root: &Path,
29    base_ref: &str,
30    explain: bool,
31    filter: Option<&str>,
32    skip: Option<&str>,
33) -> Result<AffectedResult> {
34    debug!(
35        "Finding affected packages at {} with base ref '{}'",
36        root.display(),
37        base_ref
38    );
39
40    let config = config::Config::load(root)?;
41
42    // 1. Detect ecosystem and get resolver
43    let resolver = resolvers::detect_resolver(root)?;
44    debug!("Using resolver for ecosystem: {}", resolver.ecosystem());
45
46    // 2. Build project graph
47    let project_graph = resolver
48        .resolve(root)
49        .context("Failed to resolve project graph")?;
50    debug!("Resolved {} packages", project_graph.packages.len());
51
52    // 3. Get changed files from git
53    let git_diff = git::changed_files(root, base_ref).context("Failed to compute git diff")?;
54    debug!(
55        "{} files changed since {}",
56        git_diff.changed_files.len(),
57        base_ref
58    );
59
60    // 4. Map changed files to packages (filtering ignored files)
61    let mut changed_packages = HashSet::new();
62    let mut changed_files_per_package: HashMap<types::PackageId, Vec<String>> = HashMap::new();
63    for file in &git_diff.changed_files {
64        let file_str = file.to_str().unwrap_or("");
65        if config.is_ignored(file_str) {
66            debug!("Ignoring file: {}", file_str);
67            continue;
68        }
69        if let Some(pkg_id) = resolver.package_for_file(&project_graph, file) {
70            changed_packages.insert(pkg_id.clone());
71            if explain {
72                changed_files_per_package
73                    .entry(pkg_id)
74                    .or_default()
75                    .push(file_str.to_string());
76            }
77        }
78    }
79    debug!("{} packages directly changed", changed_packages.len());
80
81    // 5. Build dependency graph and compute transitive affected set
82    let dep_graph = graph::DepGraph::from_project_graph(&project_graph);
83    let affected = dep_graph.affected_by(&changed_packages);
84    debug!(
85        "{} packages affected (including transitive)",
86        affected.len()
87    );
88
89    // 6. Build explanations if requested
90    let explanations = if explain {
91        let chains = dep_graph.explain_affected(&changed_packages, &affected);
92        let mut entries: Vec<ExplainEntry> = chains
93            .into_iter()
94            .map(|(pkg_id, chain)| {
95                let reason = if changed_packages.contains(&pkg_id) {
96                    ExplainReason::DirectlyChanged {
97                        files: changed_files_per_package
98                            .get(&pkg_id)
99                            .cloned()
100                            .unwrap_or_default(),
101                    }
102                } else {
103                    ExplainReason::TransitivelyAffected {
104                        chain: chain.into_iter().map(|p| p.0).collect(),
105                    }
106                };
107                ExplainEntry {
108                    package: pkg_id.0,
109                    reason,
110                }
111            })
112            .collect();
113        entries.sort_by(|a, b| a.package.cmp(&b.package));
114        Some(entries)
115    } else {
116        None
117    };
118
119    // 7. Sort and apply filter/skip for deterministic output
120    let mut affected_names: Vec<String> = affected.into_iter().map(|p| p.0).collect();
121    affected_names.sort();
122
123    // Apply filter (include only matching)
124    if let Some(filter_pattern) = filter {
125        let pat = glob::Pattern::new(filter_pattern)
126            .with_context(|| format!("Invalid filter pattern '{filter_pattern}'"))?;
127        debug!("Applying filter pattern: {}", filter_pattern);
128        affected_names.retain(|name| pat.matches(name));
129    }
130
131    // Apply skip (exclude matching)
132    if let Some(skip_pattern) = skip {
133        let pat = glob::Pattern::new(skip_pattern)
134            .with_context(|| format!("Invalid skip pattern '{skip_pattern}'"))?;
135        debug!("Applying skip pattern: {}", skip_pattern);
136        affected_names.retain(|name| !pat.matches(name));
137    }
138
139    Ok(AffectedResult {
140        affected: affected_names,
141        base: base_ref.to_string(),
142        changed_files: git_diff.changed_files.len(),
143        total_packages: project_graph.packages.len(),
144        explanations,
145    })
146}
147
148/// Compute the merge-base between HEAD and the given branch.
149/// Wraps `git::merge_base` for use from the CLI.
150pub fn find_merge_base(root: &Path, branch: &str) -> Result<String> {
151    debug!("Finding merge-base with branch '{}'", branch);
152    git::merge_base(root, branch)
153}
154
155/// Build the project graph and return it alongside the resolver.
156/// Used by commands that need the graph without computing affected packages.
157pub fn resolve_project(root: &Path) -> Result<(Box<dyn resolvers::Resolver>, types::ProjectGraph)> {
158    let resolver = resolvers::detect_resolver(root)?;
159    let graph = resolver
160        .resolve(root)
161        .context("Failed to resolve project graph")?;
162    Ok((resolver, graph))
163}