1use anyhow::{Context, Result};
2use git2::{Commit, DiffOptions, Repository, Sort};
3use std::collections::{HashMap, HashSet};
4use std::path::{Component, Path};
5
6#[derive(Debug, Clone)]
7pub struct CommitSummary {
8 pub oid: String,
9 pub short_oid: String,
10 pub summary: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct FileHeatmapEntry {
15 pub path: String,
16 pub commits: usize,
17 pub changes: usize,
18 pub insertions: usize,
19 pub deletions: usize,
20}
21
22#[derive(Debug, Default)]
23struct FileHeatmapStats {
24 commits: usize,
25 insertions: usize,
26 deletions: usize,
27}
28
29pub fn recent_commits(limit: usize) -> Result<Vec<CommitSummary>> {
33 if limit == 0 {
34 return Ok(Vec::new());
35 }
36
37 let repo = Repository::discover(".").context("failed to locate git repository")?;
38 let mut revwalk = repo.revwalk().context("failed to create git revwalk")?;
39 revwalk
40 .set_sorting(Sort::TOPOLOGICAL | Sort::TIME)
41 .context("failed to configure git revwalk sorting")?;
42 revwalk
43 .push_head()
44 .context("failed to start git revwalk from HEAD")?;
45
46 let mut commits = Vec::with_capacity(limit);
47 for oid_result in revwalk.take(limit) {
48 let oid = oid_result.context("failed to walk git history")?;
49 let commit = repo
50 .find_commit(oid)
51 .with_context(|| format!("failed to load commit {oid}"))?;
52 let summary = commit
53 .summary()
54 .unwrap_or("(no commit message)")
55 .to_string();
56 let oid_text = oid.to_string();
57 let short_oid: String = oid_text.chars().take(12).collect();
58 commits.push(CommitSummary {
59 oid: oid_text,
60 short_oid,
61 summary,
62 });
63 }
64
65 Ok(commits)
66}
67
68pub fn file_heatmap() -> Result<Vec<FileHeatmapEntry>> {
72 let repo = Repository::discover(".").context("failed to locate git repository")?;
73 let mut revwalk = repo.revwalk().context("failed to create git revwalk")?;
74 revwalk
75 .set_sorting(Sort::TOPOLOGICAL | Sort::TIME)
76 .context("failed to configure git revwalk sorting")?;
77 revwalk
78 .push_head()
79 .context("failed to start git revwalk from HEAD")?;
80
81 let mut stats: HashMap<String, FileHeatmapStats> = HashMap::new();
82 for oid_result in revwalk {
83 let oid = oid_result.context("failed to walk git history")?;
84 let commit = repo
85 .find_commit(oid)
86 .with_context(|| format!("failed to load commit {oid}"))?;
87 collect_commit_file_heat(&repo, &commit, &mut stats)?;
88 }
89
90 let mut entries = stats
91 .into_iter()
92 .map(|(path, stats)| FileHeatmapEntry {
93 path,
94 commits: stats.commits,
95 changes: stats.insertions + stats.deletions,
96 insertions: stats.insertions,
97 deletions: stats.deletions,
98 })
99 .collect::<Vec<_>>();
100 entries.sort_by(|left, right| {
101 right
102 .changes
103 .cmp(&left.changes)
104 .then_with(|| right.commits.cmp(&left.commits))
105 .then_with(|| left.path.cmp(&right.path))
106 });
107 Ok(entries)
108}
109
110fn collect_commit_file_heat(
111 repo: &Repository,
112 commit: &Commit<'_>,
113 stats: &mut HashMap<String, FileHeatmapStats>,
114) -> Result<()> {
115 let new_tree = commit.tree().context("failed to read commit tree")?;
116 let old_tree = if commit.parent_count() == 0 {
117 None
118 } else {
119 Some(
120 commit
121 .parent(0)
122 .context("failed to read first parent")?
123 .tree()
124 .context("failed to read parent tree")?,
125 )
126 };
127 let mut options = DiffOptions::new();
128 options.context_lines(0).include_typechange(true);
129 let diff = repo
130 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut options))
131 .context("failed to diff commit")?;
132
133 let mut touched_paths = Vec::new();
134 let mut line_changes = Vec::new();
135 diff.foreach(
136 &mut |delta, _progress| {
137 if let Some(path) = delta_path(&delta) {
138 touched_paths.push(path);
139 }
140 true
141 },
142 None,
143 None,
144 Some(&mut |delta, _hunk, line| {
145 if let Some(path) = delta_path(&delta) {
146 match line.origin() {
147 '+' => line_changes.push((path, true)),
148 '-' => line_changes.push((path, false)),
149 _ => {}
150 }
151 }
152 true
153 }),
154 )
155 .context("failed to walk commit diff")?;
156
157 let mut touched = HashSet::new();
158 for path in touched_paths {
159 touched.insert(path);
160 }
161 for (path, insertion) in line_changes {
162 touched.insert(path.clone());
163 let entry = stats.entry(path).or_default();
164 if insertion {
165 entry.insertions += 1;
166 } else {
167 entry.deletions += 1;
168 }
169 }
170 for path in touched {
171 let entry = stats.entry(path).or_default();
172 entry.commits += 1;
173 }
174 Ok(())
175}
176
177fn delta_path(delta: &git2::DiffDelta<'_>) -> Option<String> {
178 delta
179 .new_file()
180 .path()
181 .or_else(|| delta.old_file().path())
182 .map(normalize_git_path)
183}
184
185fn normalize_git_path(path: &Path) -> String {
186 path.components()
187 .filter_map(|component| match component {
188 Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
189 _ => None,
190 })
191 .collect::<Vec<_>>()
192 .join("/")
193}
194
195#[cfg(test)]
196mod tests {
197 use super::{file_heatmap, normalize_git_path};
198 use anyhow::Result;
199 use git2::{Oid, Signature};
200 use std::fs;
201 use std::path::Path;
202 use tempfile::tempdir;
203
204 #[test]
205 fn normalize_git_path_uses_forward_slashes() {
206 assert_eq!(
207 normalize_git_path(Path::new("src/lib.rs")),
208 "src/lib.rs".to_string()
209 );
210 }
211
212 #[test]
213 fn file_heatmap_orders_files_by_line_churn() -> Result<()> {
214 let temp = tempdir()?;
215 let repo = git2::Repository::init(temp.path())?;
216 commit_file(&repo, temp.path(), "src/hot.rs", "fn one() {}\n", "hot one")?;
217 commit_file(&repo, temp.path(), "src/cold.rs", "fn cold() {}\n", "cold")?;
218 commit_file(
219 &repo,
220 temp.path(),
221 "src/hot.rs",
222 "fn one() {}\nfn two() {}\n",
223 "hot two",
224 )?;
225
226 let previous_dir = std::env::current_dir()?;
227 std::env::set_current_dir(temp.path())?;
228 let entries = file_heatmap();
229 std::env::set_current_dir(previous_dir)?;
230 let entries = entries?;
231
232 assert_eq!(entries[0].path, "src/hot.rs");
233 assert_eq!(entries[0].commits, 2);
234 assert!(entries[0].changes >= entries[1].changes);
235 Ok(())
236 }
237
238 fn commit_file(
239 repo: &git2::Repository,
240 root: &std::path::Path,
241 relative_path: &str,
242 content: &str,
243 message: &str,
244 ) -> Result<Oid> {
245 let path = root.join(relative_path);
246 if let Some(parent) = path.parent() {
247 fs::create_dir_all(parent)?;
248 }
249 fs::write(&path, content)?;
250
251 let mut index = repo.index()?;
252 index.add_path(std::path::Path::new(relative_path))?;
253 index.write()?;
254
255 let tree_oid = index.write_tree()?;
256 let tree = repo.find_tree(tree_oid)?;
257 let signature = Signature::now("Parley Test", "parley@example.com")?;
258 let parents = repo
259 .head()
260 .ok()
261 .and_then(|head| head.target())
262 .map(|oid| repo.find_commit(oid))
263 .transpose()?
264 .into_iter()
265 .collect::<Vec<_>>();
266 let parent_refs = parents.iter().collect::<Vec<_>>();
267 let oid = repo.commit(
268 Some("HEAD"),
269 &signature,
270 &signature,
271 message,
272 &tree,
273 &parent_refs,
274 )?;
275 Ok(oid)
276 }
277}