features_cli/
git_helper.rs

1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::models::Change;
7
8fn format_timestamp(time: git2::Time) -> String {
9    let timestamp = time.seconds();
10    let datetime = chrono::DateTime::from_timestamp(timestamp, 0)
11        .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
12    datetime.format("%Y-%m-%d %H:%M:%S").to_string()
13}
14
15/// Get all commits for all paths in the repository at once.
16/// Returns a HashMap where keys are relative paths and values are lists of changes.
17/// This is much more efficient than calling get_commits_for_path for each path individually.
18pub fn get_all_commits_by_path(repo_path: &Path) -> Result<HashMap<String, Vec<Change>>> {
19    let repo = Repository::discover(repo_path).with_context(|| {
20        format!(
21            "failed to discover git repository at `{}`",
22            repo_path.display()
23        )
24    })?;
25
26    let mut revwalk = repo.revwalk()?;
27    revwalk.push_head()?;
28    revwalk.set_sorting(git2::Sort::TIME)?;
29
30    // Map from path to list of changes
31    let mut path_changes: HashMap<String, Vec<Change>> = HashMap::new();
32
33    for oid in revwalk {
34        let oid = oid?;
35        let commit = repo.find_commit(oid)?;
36        let author = commit.author();
37        let message = commit.message().unwrap_or("").to_string();
38
39        // Split message into title and description
40        let lines: Vec<&str> = message.lines().collect();
41        let title = lines.first().unwrap_or(&"").to_string();
42        let description = if lines.len() > 1 {
43            lines[1..].join("\n").trim().to_string()
44        } else {
45            String::new()
46        };
47
48        let change = Change {
49            title,
50            author_name: author.name().unwrap_or("Unknown").to_string(),
51            author_email: author.email().unwrap_or("").to_string(),
52            description,
53            date: format_timestamp(commit.time()),
54            hash: format!("{}", oid),
55        };
56
57        // Get all paths affected by this commit
58        let affected_paths = get_affected_paths(&repo, &commit)?;
59
60        // For each affected file path, add the change to all ancestor directories
61        // This matches the behavior of commit_affects_path which uses starts_with
62        for file_path in affected_paths {
63            let path_obj = Path::new(&file_path);
64
65            // Add to all ancestor directories (not the file itself, only dirs)
66            for ancestor in path_obj.ancestors().skip(1) {
67                if ancestor == Path::new("") {
68                    break;
69                }
70                let ancestor_str = ancestor.to_string_lossy().to_string();
71
72                // Only add if not already present (to avoid duplicates)
73                let changes_list = path_changes.entry(ancestor_str).or_default();
74                if !changes_list.iter().any(|c| c.hash == change.hash) {
75                    changes_list.push(change.clone());
76                }
77            }
78        }
79    }
80
81    Ok(path_changes)
82}
83
84/// Get all paths affected by a commit
85fn get_affected_paths(repo: &Repository, commit: &git2::Commit) -> Result<Vec<String>> {
86    let mut paths = Vec::new();
87
88    // For the first commit (no parents), get all files in the tree
89    if commit.parent_count() == 0 {
90        let tree = commit.tree()?;
91        collect_tree_paths(repo, &tree, "", &mut paths)?;
92        return Ok(paths);
93    }
94
95    // For commits with parents, check the diff
96    let tree = commit.tree()?;
97    let parent = commit.parent(0)?;
98    let parent_tree = parent.tree()?;
99
100    let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)?;
101
102    diff.foreach(
103        &mut |delta, _| {
104            if let Some(path) = delta.new_file().path()
105                && let Some(path_str) = path.to_str()
106            {
107                paths.push(path_str.to_string());
108            }
109            if let Some(path) = delta.old_file().path()
110                && let Some(path_str) = path.to_str()
111                && !paths.contains(&path_str.to_string())
112            {
113                paths.push(path_str.to_string());
114            }
115            true
116        },
117        None,
118        None,
119        None,
120    )?;
121
122    Ok(paths)
123}
124
125/// Recursively collect all paths in a tree
126fn collect_tree_paths(
127    repo: &Repository,
128    tree: &git2::Tree,
129    prefix: &str,
130    paths: &mut Vec<String>,
131) -> Result<()> {
132    for entry in tree.iter() {
133        if let Some(name) = entry.name() {
134            let path = if prefix.is_empty() {
135                name.to_string()
136            } else {
137                format!("{}/{}", prefix, name)
138            };
139
140            paths.push(path.clone());
141
142            if entry.kind() == Some(git2::ObjectType::Tree)
143                && let Ok(subtree) = entry.to_object(repo).and_then(|obj| obj.peel_to_tree())
144            {
145                collect_tree_paths(repo, &subtree, &path, paths)?;
146            }
147        }
148    }
149    Ok(())
150}