Skip to content

Commit 90f1fe0

Browse files
committed
feat(analysis): add git module with clone/fetch and commit analysis
- clone_or_update(): clones repo to cache or fetches if already present using token-based credentials via git2::RemoteCallbacks - analyze_repo(): walks commits filtered by author email and time period (week=7d, month=30d, all=unlimited), computes diff stats per commit - Accumulates Commit and Contributor records into Report
1 parent 6323b0d commit 90f1fe0

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

src/analysis/git.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use chrono::{DateTime, Duration, Utc};
2+
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks, Repository};
3+
use std::collections::HashMap;
4+
use std::path::Path;
5+
6+
use super::{Commit, Contributor, Report};
7+
use crate::config::{Profile, RepoEntry};
8+
9+
/// Clona el repo en la caché local o hace fetch si ya existe.
10+
pub fn clone_or_update(entry: &RepoEntry, profile: &Profile) -> Result<Repository, String> {
11+
let cache_path = entry.cache_path(&profile.name);
12+
let url = entry.clone_url(&profile.token);
13+
14+
if cache_path.exists() {
15+
eprintln!(
16+
" → Actualizando caché: {}",
17+
cache_path.display()
18+
);
19+
let repo = Repository::open(&cache_path)
20+
.map_err(|e| format!("No se pudo abrir repo en caché: {}", e))?;
21+
22+
let mut remote = repo
23+
.find_remote("origin")
24+
.map_err(|e| format!("Remote 'origin' no encontrado: {}", e))?;
25+
26+
let mut callbacks = RemoteCallbacks::new();
27+
let token = profile.token.clone();
28+
callbacks.credentials(move |_, _, _| {
29+
Cred::userpass_plaintext(&token, "")
30+
});
31+
32+
let mut fetch_opts = FetchOptions::new();
33+
fetch_opts.remote_callbacks(callbacks);
34+
fetch_opts.download_tags(git2::AutotagOption::All);
35+
36+
remote
37+
.fetch(&["refs/heads/*:refs/remotes/origin/*"], Some(&mut fetch_opts), None)
38+
.map_err(|e| format!("Error en fetch: {}", e))?;
39+
40+
Ok(repo)
41+
} else {
42+
eprintln!(
43+
" → Clonando en: {}",
44+
cache_path.display()
45+
);
46+
std::fs::create_dir_all(&cache_path)
47+
.map_err(|e| format!("No se pudo crear directorio de caché: {}", e))?;
48+
49+
let mut callbacks = RemoteCallbacks::new();
50+
let token = profile.token.clone();
51+
callbacks.credentials(move |_, _, _| {
52+
Cred::userpass_plaintext(&token, "")
53+
});
54+
55+
let mut fetch_opts = FetchOptions::new();
56+
fetch_opts.remote_callbacks(callbacks);
57+
58+
let mut builder = RepoBuilder::new();
59+
builder.fetch_options(fetch_opts);
60+
61+
builder
62+
.clone(&url, Path::new(&cache_path))
63+
.map_err(|e| format!("Error al clonar '{}': {}", url, e))
64+
}
65+
}
66+
67+
/// Analiza los commits del repo filtrando por periodo y email del autor.
68+
pub fn analyze_repo(
69+
repo: &Repository,
70+
period: &str,
71+
email: &str,
72+
repo_name: &str,
73+
) -> Result<Report, String> {
74+
let mut report = Report::new(repo_name.to_string(), period.to_string());
75+
76+
let cutoff: Option<DateTime<Utc>> = match period {
77+
"week" => Some(Utc::now() - Duration::days(7)),
78+
"month" => Some(Utc::now() - Duration::days(30)),
79+
_ => None,
80+
};
81+
82+
// Determinar HEAD; si el repo está vacío se devuelve un reporte vacío
83+
let head = match repo.head() {
84+
Ok(h) => h,
85+
Err(_) => return Ok(report),
86+
};
87+
let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
88+
89+
let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?;
90+
revwalk.push(head_commit.id()).map_err(|e| e.to_string())?;
91+
revwalk.set_sorting(git2::Sort::TIME).map_err(|e| e.to_string())?;
92+
93+
let mut contributors: HashMap<String, Contributor> = HashMap::new();
94+
95+
for oid in revwalk {
96+
let oid = oid.map_err(|e| e.to_string())?;
97+
let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
98+
99+
let author = commit.author();
100+
let commit_email = author.email().unwrap_or("").to_string();
101+
let commit_name = author.name().unwrap_or("Unknown").to_string();
102+
103+
// Filtrar por email del perfil
104+
if commit_email != email {
105+
continue;
106+
}
107+
108+
// Filtrar por periodo
109+
let commit_time = commit.time();
110+
let commit_dt: DateTime<Utc> =
111+
DateTime::from_timestamp(commit_time.seconds(), 0).unwrap_or(Utc::now());
112+
113+
if let Some(cutoff_dt) = cutoff {
114+
if commit_dt < cutoff_dt {
115+
break; // revwalk va de más nuevo a más viejo; si ya pasó el corte, salimos
116+
}
117+
}
118+
119+
// Calcular diff stats respecto al primer padre
120+
let (files_changed, insertions, deletions) = diff_stats(repo, &commit);
121+
122+
let commit_id = format!("{:.8}", oid);
123+
let message = commit.message().unwrap_or("").lines().next().unwrap_or("").to_string();
124+
let date = commit_dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
125+
126+
let mut c = Commit::new(commit_id, message, commit_name.clone(), date);
127+
c.files_changed = files_changed;
128+
c.insertions = insertions;
129+
c.deletions = deletions;
130+
131+
report.total_commits += 1;
132+
report.total_insertions += insertions;
133+
report.total_deletions += deletions;
134+
report.commits.push(c);
135+
136+
// Acumular contribuidor
137+
let contrib = contributors
138+
.entry(commit_email.clone())
139+
.or_insert_with(|| Contributor::new(commit_name.clone(), commit_email.clone()));
140+
contrib.commits += 1;
141+
contrib.insertions += insertions;
142+
contrib.deletions += deletions;
143+
144+
let date_str = commit_dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
145+
if contrib.first_commit.is_none() {
146+
contrib.last_commit = Some(date_str.clone());
147+
}
148+
contrib.first_commit = Some(date_str);
149+
}
150+
151+
report.contributors = contributors.into_values().collect();
152+
report.total_contributors = report.contributors.len() as u32;
153+
154+
Ok(report)
155+
}
156+
157+
fn diff_stats(repo: &Repository, commit: &git2::Commit) -> (u32, u32, u32) {
158+
let tree = match commit.tree() {
159+
Ok(t) => t,
160+
Err(_) => return (0, 0, 0),
161+
};
162+
163+
let parent_tree = commit
164+
.parent(0)
165+
.ok()
166+
.and_then(|p| p.tree().ok());
167+
168+
let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
169+
Ok(d) => d,
170+
Err(_) => return (0, 0, 0),
171+
};
172+
173+
let stats = match diff.stats() {
174+
Ok(s) => s,
175+
Err(_) => return (0, 0, 0),
176+
};
177+
178+
(
179+
stats.files_changed() as u32,
180+
stats.insertions() as u32,
181+
stats.deletions() as u32,
182+
)
183+
}

src/analysis/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
pub mod commit;
22
pub mod contributor;
3+
pub mod git;
34
pub mod report;
45

56
pub use commit::*;
67
pub use contributor::*;
8+
pub use git::*;
79
pub use report::*;

0 commit comments

Comments
 (0)