Skip to main content

parley/git/
history.rs

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
29/// # Errors
30///
31/// Returns an error when the git repository cannot be found or its commit history cannot be read.
32pub 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
68/// # Errors
69///
70/// Returns an error when the git repository cannot be found or commit diffs cannot be read.
71pub 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}