git_contribution_analyzer/
git.rs1use 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}