git_contribution_analyzer/
git.rs

1use glob::glob;
2use itertools::Itertools;
3use std::{
4    collections::HashMap,
5    error::Error,
6    path::{Path, PathBuf},
7    process::Command,
8};
9
10use crate::app::AuthorSummary;
11
12#[derive(Debug, Clone)]
13pub struct Contribution {
14    pub author: String,
15    pub email: String,
16    pub commits: u32,
17    pub lines_added: u32,
18    pub lines_deleted: u32,
19    pub contribution_percent: f64,
20    pub repository: String,
21}
22
23pub fn is_git_repository(path: &Path) -> bool {
24    let git_dir = path.join(".git");
25    git_dir.exists() && git_dir.is_dir()
26}
27
28pub fn find_repositories(
29    parent_path: &Path,
30    pattern: &str,
31) -> Result<Vec<PathBuf>, Box<dyn Error + Send>> {
32    let mut repositories = Vec::new();
33    let pattern_path = parent_path.join(pattern);
34    let pattern_str = pattern_path.to_string_lossy().to_string();
35
36    for entry in glob(&pattern_str).map_err(|e| Box::new(e) as Box<dyn Error + Send>)? {
37        match entry {
38            Ok(path) => {
39                if path.is_dir() && is_git_repository(&path) {
40                    repositories.push(path);
41                }
42            }
43            Err(e) => eprintln!("Error matching path: {}", e),
44        }
45    }
46
47    Ok(repositories)
48}
49
50pub fn analyze_repository(repo_path: &Path) -> Result<(String, Vec<Contribution>), Box<dyn Error>> {
51    let repo_name = repo_path
52        .file_name()
53        .ok_or("Invalid repository path")?
54        .to_string_lossy()
55        .to_string();
56
57    let mut contributions = Vec::new();
58
59    let total_output = Command::new("git")
60        .args(["log", "--no-merges", "--numstat"])
61        .current_dir(repo_path)
62        .output()?
63        .stdout;
64
65    let total_lines = String::from_utf8_lossy(&total_output);
66    let mut total_lines_changed = 0;
67
68    for line in total_lines.lines() {
69        if let Some((added, deleted, _)) = line.split_whitespace().collect_tuple() {
70            if added != "-" && deleted != "-" {
71                if let (Ok(a), Ok(d)) = (added.parse::<u32>(), deleted.parse::<u32>()) {
72                    total_lines_changed += a + d;
73                }
74            }
75        }
76    }
77
78    let authors_output = Command::new("git")
79        .args(["log", "--no-merges", "--format=%ae|%an"])
80        .current_dir(repo_path)
81        .output()?
82        .stdout;
83
84    let authors = String::from_utf8_lossy(&authors_output);
85
86    let mut author_map = HashMap::new();
87
88    for line in authors.lines() {
89        if let Some((email, name)) = line.split_once('|') {
90            author_map
91                .entry(email.to_string())
92                .or_insert_with(|| name.to_string());
93        }
94    }
95
96    for (email, name) in author_map {
97        let commits = Command::new("git")
98            .args(["log", "--no-merges", "--author", &email, "--format=%H"])
99            .current_dir(repo_path)
100            .output()?
101            .stdout;
102
103        let commit_count = String::from_utf8_lossy(&commits).lines().count() as u32;
104
105        let stats_output = Command::new("git")
106            .args([
107                "log",
108                "--no-merges",
109                "--author",
110                &email,
111                "--numstat",
112                "--pretty=format:",
113            ])
114            .current_dir(repo_path)
115            .output()?
116            .stdout;
117
118        let stats_str = String::from_utf8_lossy(&stats_output);
119
120        let mut lines_added = 0;
121        let mut lines_deleted = 0;
122
123        for line in stats_str.lines() {
124            if line.is_empty() {
125                continue;
126            }
127
128            if let Some((added, deleted, _)) = line.split_whitespace().collect_tuple() {
129                if added != "-" && deleted != "-" {
130                    if let (Ok(a), Ok(d)) = (added.parse::<u32>(), deleted.parse::<u32>()) {
131                        lines_added += a;
132                        lines_deleted += d;
133                    }
134                }
135            }
136        }
137
138        let lines_changed = lines_added + lines_deleted;
139        let contribution_percent = if total_lines_changed > 0 {
140            (lines_changed as f64 / total_lines_changed as f64) * 100.0
141        } else {
142            0.0
143        };
144
145        contributions.push(Contribution {
146            author: name,
147            email,
148            commits: commit_count,
149            lines_added,
150            lines_deleted,
151            contribution_percent,
152            repository: repo_name.clone(),
153        });
154    }
155
156    contributions.sort_by(|a, b| {
157        b.contribution_percent
158            .partial_cmp(&a.contribution_percent)
159            .unwrap()
160    });
161
162    Ok((repo_name, contributions))
163}
164
165pub fn calculate_author_summaries(
166    contributions_map: &HashMap<String, Vec<Contribution>>,
167) -> Vec<AuthorSummary> {
168    let mut author_data: HashMap<String, (String, String, u32, u32, u32, HashMap<String, f64>)> =
169        HashMap::new();
170    let mut total_lines_changed_all_repos = 0;
171
172    for (repo_name, contributions) in contributions_map {
173        for contrib in contributions {
174            let email = &contrib.email;
175            let author_name = &contrib.author;
176            let lines_changed = contrib.lines_added + contrib.lines_deleted;
177
178            total_lines_changed_all_repos += lines_changed;
179
180            let entry = author_data
181                .entry(email.clone())
182                .or_insert_with(|| (author_name.clone(), email.clone(), 0, 0, 0, HashMap::new()));
183
184            entry.2 += contrib.commits;
185            entry.3 += contrib.lines_added;
186            entry.4 += contrib.lines_deleted;
187            entry
188                .5
189                .insert(repo_name.clone(), contrib.contribution_percent);
190        }
191    }
192
193    let mut summaries = Vec::new();
194
195    for (email, (author, _, commits, lines_added, lines_deleted, repo_percentages)) in author_data {
196        let total_lines_changed = lines_added + lines_deleted;
197        let overall_percent = if total_lines_changed_all_repos > 0 {
198            (total_lines_changed as f64 / total_lines_changed_all_repos as f64) * 100.0
199        } else {
200            0.0
201        };
202
203        let mut preferred_repo = String::new();
204        let mut highest_percent = 0.0;
205
206        for (repo, percent) in &repo_percentages {
207            if *percent > highest_percent {
208                highest_percent = *percent;
209                preferred_repo = repo.clone();
210            }
211        }
212
213        summaries.push(AuthorSummary {
214            author,
215            email,
216            total_commits: commits,
217            total_lines_added: lines_added,
218            total_lines_deleted: lines_deleted,
219            overall_contribution_percent: overall_percent,
220            preferred_repo,
221            preferred_repo_percent: highest_percent,
222        });
223    }
224
225    summaries.sort_by(|a, b| {
226        b.overall_contribution_percent
227            .partial_cmp(&a.overall_contribution_percent)
228            .unwrap()
229    });
230
231    summaries
232}