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