polykit_core/
change.rs

1//! Change detection for determining affected packages.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use git2::{DiffOptions, Repository};
7
8use crate::error::{Error, Result};
9use crate::graph::DependencyGraph;
10use crate::path_utils;
11
12/// Detects packages affected by file changes.
13pub struct ChangeDetector;
14
15impl ChangeDetector {
16    /// Determines which packages are affected by the given changed files.
17    pub fn detect_affected_packages(
18        graph: &DependencyGraph,
19        changed_files: &[impl AsRef<Path>],
20        packages_dir: impl AsRef<Path>,
21    ) -> Result<HashSet<String>> {
22        let packages_dir = packages_dir.as_ref();
23        let mut changed_packages = HashSet::new();
24
25        for file_path in changed_files {
26            let path = file_path.as_ref();
27            if let Some(package_name) = Self::file_to_package(path, packages_dir) {
28                changed_packages.insert(package_name);
29            }
30        }
31
32        graph.affected_packages(&changed_packages.into_iter().collect::<Vec<_>>())
33    }
34
35    pub fn detect_from_git(
36        graph: &DependencyGraph,
37        packages_dir: impl AsRef<Path>,
38        base: Option<&str>,
39    ) -> Result<HashSet<String>> {
40        let base = base.unwrap_or("HEAD");
41        Self::validate_git_ref(base)?;
42        let changed_files = Self::git_diff_with_libgit2(base)?;
43        Self::detect_affected_packages(graph, &changed_files, packages_dir)
44    }
45
46    /// Reads changed files from stdin (one path per line).
47    pub fn detect_from_stdin(
48        graph: &DependencyGraph,
49        packages_dir: impl AsRef<Path>,
50    ) -> Result<HashSet<String>> {
51        use std::io::{self, BufRead};
52
53        let stdin = io::stdin();
54        let mut changed_files = Vec::new();
55
56        for line in stdin.lock().lines() {
57            let line = line.map_err(Error::Io)?;
58            let path = PathBuf::from(line.trim());
59            if !path.as_os_str().is_empty() {
60                changed_files.push(path);
61            }
62        }
63
64        Self::detect_affected_packages(graph, &changed_files, packages_dir)
65    }
66
67    fn file_to_package(file_path: &Path, packages_dir: &Path) -> Option<String> {
68        path_utils::file_to_package(file_path, packages_dir)
69    }
70
71    fn validate_git_ref(git_ref: &str) -> Result<()> {
72        if git_ref.is_empty() {
73            return Err(Error::Adapter {
74                package: "change-detection".to_string(),
75                message: "Git reference cannot be empty".to_string(),
76            });
77        }
78
79        if git_ref.len() > 256 {
80            return Err(Error::Adapter {
81                package: "change-detection".to_string(),
82                message: "Git reference exceeds maximum length".to_string(),
83            });
84        }
85
86        if git_ref.contains('\0') || git_ref.contains('\n') || git_ref.contains('\r') {
87            return Err(Error::Adapter {
88                package: "change-detection".to_string(),
89                message: "Git reference contains invalid characters".to_string(),
90            });
91        }
92
93        if git_ref.starts_with('-') {
94            return Err(Error::Adapter {
95                package: "change-detection".to_string(),
96                message: "Git reference cannot start with '-'".to_string(),
97            });
98        }
99
100        Ok(())
101    }
102
103    fn git_diff_with_libgit2(base: &str) -> Result<Vec<PathBuf>> {
104        let repo = Repository::open_from_env().map_err(|e| Error::Adapter {
105            package: "change-detection".to_string(),
106            message: format!("Failed to open git repository: {}", e),
107        })?;
108
109        let base_obj = repo.revparse_single(base).map_err(|e| Error::Adapter {
110            package: "change-detection".to_string(),
111            message: format!("Failed to parse git reference '{}': {}", base, e),
112        })?;
113
114        let base_tree = base_obj.peel_to_tree().map_err(|e| Error::Adapter {
115            package: "change-detection".to_string(),
116            message: format!("Failed to get tree from git reference: {}", e),
117        })?;
118
119        let mut diff_opts = DiffOptions::new();
120        diff_opts.include_untracked(false);
121        diff_opts.recurse_untracked_dirs(false);
122
123        let head = repo.head().map_err(|e| Error::Adapter {
124            package: "change-detection".to_string(),
125            message: format!("Failed to get HEAD: {}", e),
126        })?;
127
128        let head_tree = head.peel_to_tree().map_err(|e| Error::Adapter {
129            package: "change-detection".to_string(),
130            message: format!("Failed to get tree from HEAD: {}", e),
131        })?;
132
133        let diff = repo
134            .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))
135            .map_err(|e| Error::Adapter {
136                package: "change-detection".to_string(),
137                message: format!("Failed to compute git diff: {}", e),
138            })?;
139
140        let mut changed_files = Vec::new();
141        diff.foreach(
142            &mut |delta, _| {
143                if let Some(path) = delta.new_file().path() {
144                    changed_files.push(path.to_path_buf());
145                }
146                true
147            },
148            None,
149            None,
150            None,
151        )
152        .map_err(|e| Error::Adapter {
153            package: "change-detection".to_string(),
154            message: format!("Failed to iterate over git diff: {}", e),
155        })?;
156
157        Ok(changed_files)
158    }
159}