Skip to main content

cuenv_release/
commit_analyzer.rs

1//! Commit analysis for per-package versioning.
2//!
3//! This module analyzes git commits to determine which packages are affected
4//! by each commit. It uses file diffs to map changes to package boundaries,
5//! enabling independent per-package version bumps in monorepos.
6
7use crate::changeset::BumpType;
8use crate::conventional::ConventionalCommit;
9use crate::error::{Error, Result};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14/// A commit that affected a specific package.
15#[derive(Debug, Clone)]
16pub struct PackageAffect<'a> {
17    /// The commit that caused this affect.
18    pub commit: &'a ConventionalCommit,
19    /// Files changed in this package by the commit.
20    pub changed_files: Vec<PathBuf>,
21}
22
23impl PackageAffect<'_> {
24    /// Get the bump type for this affect.
25    #[must_use]
26    pub fn bump_type(&self) -> BumpType {
27        self.commit.bump_type()
28    }
29}
30
31/// Analyzes commits to determine which packages they affect.
32///
33/// This analyzer uses git diffs to map commits to packages based on
34/// which files were modified in each commit.
35pub struct CommitAnalyzer<'a> {
36    root: &'a Path,
37    /// Map of package names to their root paths (relative to workspace root).
38    package_paths: HashMap<String, PathBuf>,
39}
40
41impl<'a> CommitAnalyzer<'a> {
42    /// Create a new commit analyzer.
43    ///
44    /// # Arguments
45    ///
46    /// * `root` - The workspace root path.
47    /// * `package_paths` - Map of package names to their paths (relative to root).
48    #[must_use]
49    pub const fn new(root: &'a Path, package_paths: HashMap<String, PathBuf>) -> Self {
50        Self {
51            root,
52            package_paths,
53        }
54    }
55
56    /// Analyze commits and map them to affected packages.
57    ///
58    /// Returns a map of package names to the commits that affected them,
59    /// along with the specific files changed in that package.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if git operations fail when analyzing commits.
64    pub fn analyze<'c>(
65        &self,
66        commits: &'c [ConventionalCommit],
67    ) -> Result<HashMap<String, Vec<PackageAffect<'c>>>> {
68        let mut package_commits: HashMap<String, Vec<PackageAffect<'c>>> = HashMap::new();
69
70        for commit in commits {
71            let changed_files = self.get_changed_files(&commit.hash)?;
72            let affected = self.map_files_to_packages(&changed_files);
73
74            for (pkg_name, pkg_files) in affected {
75                let affect = PackageAffect {
76                    commit,
77                    changed_files: pkg_files,
78                };
79                package_commits.entry(pkg_name).or_default().push(affect);
80            }
81        }
82
83        Ok(package_commits)
84    }
85
86    /// Calculate the aggregate bump type per package from analyzed commits.
87    ///
88    /// Returns a map of package names to their maximum bump type.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if git operations fail when analyzing commits.
93    pub fn calculate_bumps(
94        &self,
95        commits: &[ConventionalCommit],
96    ) -> Result<HashMap<String, BumpType>> {
97        let package_affects = self.analyze(commits)?;
98
99        let mut bumps = HashMap::new();
100        for (pkg_name, affects) in package_affects {
101            let max_bump = affects
102                .iter()
103                .map(PackageAffect::bump_type)
104                .max()
105                .unwrap_or(BumpType::None);
106
107            if max_bump != BumpType::None {
108                bumps.insert(pkg_name, max_bump);
109            }
110        }
111
112        Ok(bumps)
113    }
114
115    /// Get the files changed in a specific commit.
116    ///
117    /// Uses `git diff-tree` to list files changed in the commit.
118    /// For root commits (no parent), uses `--root` flag to show all added files.
119    fn get_changed_files(&self, commit_hash: &str) -> Result<Vec<PathBuf>> {
120        // Use git diff-tree to get changed files
121        // --no-commit-id: Don't print commit hash
122        // --name-only: Only print file names
123        // -r: Recurse into subtrees
124        // --root: For root commits (no parent), show files as additions
125        let output = Command::new("git")
126            .args([
127                "diff-tree",
128                "--no-commit-id",
129                "--name-only",
130                "-r",
131                "--root",
132                commit_hash,
133            ])
134            .current_dir(self.root)
135            .output()
136            .map_err(|e| Error::git(format!("Failed to run git diff-tree: {e}")))?;
137
138        if !output.status.success() {
139            let stderr = String::from_utf8_lossy(&output.stderr);
140            return Err(Error::git(format!(
141                "git diff-tree failed for {commit_hash}: {stderr}"
142            )));
143        }
144
145        let stdout = String::from_utf8_lossy(&output.stdout);
146        let files: Vec<PathBuf> = stdout
147            .lines()
148            .filter(|line| !line.is_empty())
149            .map(PathBuf::from)
150            .collect();
151
152        Ok(files)
153    }
154
155    /// Map changed files to packages.
156    ///
157    /// Returns a map of package names to the files that changed in that package.
158    fn map_files_to_packages(&self, files: &[PathBuf]) -> HashMap<String, Vec<PathBuf>> {
159        let mut package_files: HashMap<String, Vec<PathBuf>> = HashMap::new();
160
161        for file in files {
162            if let Some(pkg_name) = self.file_to_package(file) {
163                package_files
164                    .entry(pkg_name)
165                    .or_default()
166                    .push(file.clone());
167            }
168        }
169
170        package_files
171    }
172
173    /// Determine which package owns a file path.
174    ///
175    /// Returns `None` if the file doesn't belong to any package
176    /// (e.g., root-level config files).
177    fn file_to_package(&self, file_path: &Path) -> Option<String> {
178        // Find the package whose path is a prefix of this file path
179        // Use longest match to handle nested packages correctly
180        let mut best_match: Option<(&String, usize)> = None;
181
182        for (pkg_name, pkg_path) in &self.package_paths {
183            // Normalize the package path (make it relative if absolute)
184            let relative_pkg_path = if pkg_path.is_absolute() {
185                pkg_path.strip_prefix(self.root).unwrap_or(pkg_path)
186            } else {
187                pkg_path.as_path()
188            };
189
190            if file_path.starts_with(relative_pkg_path) {
191                let path_len = relative_pkg_path.components().count();
192                if best_match
193                    .as_ref()
194                    .is_none_or(|(_, prev_len)| path_len > *prev_len)
195                {
196                    best_match = Some((pkg_name, path_len));
197                }
198            }
199        }
200
201        best_match.map(|(name, _)| name.clone())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::fs;
209    use std::process::Command;
210    use tempfile::TempDir;
211
212    fn create_test_workspace(temp: &TempDir) -> PathBuf {
213        let root = temp.path().to_path_buf();
214
215        // Create workspace structure
216        fs::create_dir_all(root.join("crates/foo/src")).unwrap();
217        fs::create_dir_all(root.join("crates/bar/src")).unwrap();
218
219        // Create root Cargo.toml
220        let root_manifest = r#"[workspace]
221resolver = "2"
222members = ["crates/foo", "crates/bar"]
223
224[workspace.package]
225version = "1.0.0"
226"#;
227        fs::write(root.join("Cargo.toml"), root_manifest).unwrap();
228
229        // Create package manifests
230        fs::write(
231            root.join("crates/foo/Cargo.toml"),
232            "[package]\nname = \"foo\"\nversion.workspace = true\n",
233        )
234        .unwrap();
235        fs::write(
236            root.join("crates/bar/Cargo.toml"),
237            "[package]\nname = \"bar\"\nversion.workspace = true\n",
238        )
239        .unwrap();
240
241        // Create source files
242        fs::write(root.join("crates/foo/src/lib.rs"), "// foo lib").unwrap();
243        fs::write(root.join("crates/bar/src/lib.rs"), "// bar lib").unwrap();
244
245        root
246    }
247
248    fn init_git_repo(path: &Path) {
249        Command::new("git")
250            .args(["init", "--ref-format=files"])
251            .current_dir(path)
252            .output()
253            .unwrap();
254
255        Command::new("git")
256            .args(["config", "user.name", "Test User"])
257            .current_dir(path)
258            .output()
259            .unwrap();
260
261        Command::new("git")
262            .args(["config", "user.email", "test@example.com"])
263            .current_dir(path)
264            .output()
265            .unwrap();
266    }
267
268    fn create_commit(path: &Path, message: &str) -> String {
269        Command::new("git")
270            .args(["add", "."])
271            .current_dir(path)
272            .output()
273            .unwrap();
274
275        Command::new("git")
276            .args(["commit", "--no-gpg-sign", "-m", message])
277            .current_dir(path)
278            .output()
279            .unwrap();
280
281        // Get the commit hash
282        let output = Command::new("git")
283            .args(["rev-parse", "HEAD"])
284            .current_dir(path)
285            .output()
286            .unwrap();
287
288        String::from_utf8_lossy(&output.stdout).trim().to_string()
289    }
290
291    #[test]
292    fn test_file_to_package() {
293        let temp = TempDir::new().unwrap();
294        let root = create_test_workspace(&temp);
295
296        let package_paths = HashMap::from([
297            ("foo".to_string(), PathBuf::from("crates/foo")),
298            ("bar".to_string(), PathBuf::from("crates/bar")),
299        ]);
300
301        let analyzer = CommitAnalyzer::new(&root, package_paths);
302
303        // Test files in packages
304        assert_eq!(
305            analyzer.file_to_package(Path::new("crates/foo/src/lib.rs")),
306            Some("foo".to_string())
307        );
308        assert_eq!(
309            analyzer.file_to_package(Path::new("crates/bar/Cargo.toml")),
310            Some("bar".to_string())
311        );
312
313        // Test root-level files (not in any package)
314        assert_eq!(analyzer.file_to_package(Path::new("Cargo.toml")), None);
315        assert_eq!(analyzer.file_to_package(Path::new("README.md")), None);
316    }
317
318    #[test]
319    fn test_analyze_commits_per_package() {
320        let temp = TempDir::new().unwrap();
321        let root = create_test_workspace(&temp);
322        init_git_repo(&root);
323
324        // Initial commit
325        let _hash1 = create_commit(&root, "feat: initial commit");
326
327        // Modify only foo
328        fs::write(root.join("crates/foo/src/lib.rs"), "// foo updated").unwrap();
329        let hash2 = create_commit(&root, "fix: update foo");
330
331        // Modify only bar
332        fs::write(root.join("crates/bar/src/lib.rs"), "// bar updated").unwrap();
333        let hash3 = create_commit(&root, "feat: update bar");
334
335        let package_paths = HashMap::from([
336            ("foo".to_string(), PathBuf::from("crates/foo")),
337            ("bar".to_string(), PathBuf::from("crates/bar")),
338        ]);
339
340        let commits = vec![
341            ConventionalCommit {
342                commit_type: "fix".to_string(),
343                scope: None,
344                breaking: false,
345                description: "update foo".to_string(),
346                body: None,
347                hash: hash2,
348            },
349            ConventionalCommit {
350                commit_type: "feat".to_string(),
351                scope: None,
352                breaking: false,
353                description: "update bar".to_string(),
354                body: None,
355                hash: hash3,
356            },
357        ];
358
359        let analyzer = CommitAnalyzer::new(&root, package_paths);
360        let bumps = analyzer.calculate_bumps(&commits).unwrap();
361
362        // foo should get patch (from fix)
363        assert_eq!(bumps.get("foo"), Some(&BumpType::Patch));
364        // bar should get minor (from feat)
365        assert_eq!(bumps.get("bar"), Some(&BumpType::Minor));
366    }
367
368    #[test]
369    fn test_analyze_commit_affecting_multiple_packages() {
370        let temp = TempDir::new().unwrap();
371        let root = create_test_workspace(&temp);
372        init_git_repo(&root);
373
374        // Initial commit
375        create_commit(&root, "chore: initial");
376
377        // Modify both packages in one commit
378        fs::write(root.join("crates/foo/src/lib.rs"), "// foo v2").unwrap();
379        fs::write(root.join("crates/bar/src/lib.rs"), "// bar v2").unwrap();
380        let hash = create_commit(&root, "feat: update both");
381
382        let package_paths = HashMap::from([
383            ("foo".to_string(), PathBuf::from("crates/foo")),
384            ("bar".to_string(), PathBuf::from("crates/bar")),
385        ]);
386
387        let commits = vec![ConventionalCommit {
388            commit_type: "feat".to_string(),
389            scope: None,
390            breaking: false,
391            description: "update both".to_string(),
392            body: None,
393            hash,
394        }];
395
396        let analyzer = CommitAnalyzer::new(&root, package_paths);
397        let result = analyzer.analyze(&commits).unwrap();
398
399        // Both packages should be affected
400        assert!(result.contains_key("foo"));
401        assert!(result.contains_key("bar"));
402    }
403
404    #[test]
405    fn test_root_files_not_mapped() {
406        let temp = TempDir::new().unwrap();
407        let root = create_test_workspace(&temp);
408        init_git_repo(&root);
409
410        // Initial commit
411        create_commit(&root, "chore: initial");
412
413        // Modify only root-level file
414        fs::write(root.join("README.md"), "# Updated").unwrap();
415        let hash = create_commit(&root, "docs: update readme");
416
417        let package_paths = HashMap::from([
418            ("foo".to_string(), PathBuf::from("crates/foo")),
419            ("bar".to_string(), PathBuf::from("crates/bar")),
420        ]);
421
422        let commits = vec![ConventionalCommit {
423            commit_type: "docs".to_string(),
424            scope: None,
425            breaking: false,
426            description: "update readme".to_string(),
427            body: None,
428            hash,
429        }];
430
431        let analyzer = CommitAnalyzer::new(&root, package_paths);
432        let result = analyzer.analyze(&commits).unwrap();
433
434        // No packages should be affected
435        assert!(result.is_empty());
436    }
437}