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    pub branch: Option<String>,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FileHeatmapEntry {
16    pub path: String,
17    pub commits: usize,
18    pub changes: usize,
19    pub insertions: usize,
20    pub deletions: usize,
21}
22
23#[derive(Debug, Default)]
24struct FileHeatmapStats {
25    commits: usize,
26    insertions: usize,
27    deletions: usize,
28}
29
30/// # Errors
31///
32/// Returns an error when the git repository cannot be found or its commit history cannot be read.
33pub fn recent_commits(limit: usize, worktree_path: &Path) -> Result<Vec<CommitSummary>> {
34    if limit == 0 {
35        return Ok(Vec::new());
36    }
37
38    let repo = Repository::discover(worktree_path).context("failed to locate git repository")?;
39
40    let mut revwalk = repo.revwalk().context("failed to create git revwalk")?;
41    revwalk
42        .set_sorting(Sort::TOPOLOGICAL | Sort::TIME)
43        .context("failed to configure git revwalk sorting")?;
44    revwalk
45        .push_head()
46        .context("failed to start git revwalk from HEAD")?;
47
48    let mut commits = Vec::with_capacity(limit);
49    for oid_result in revwalk.take(limit) {
50        let oid = oid_result.context("failed to walk git history")?;
51
52        let commit = repo
53            .find_commit(oid)
54            .with_context(|| format!("failed to load commit {oid}"))?;
55        let summary = commit
56            .summary()
57            .unwrap_or("(no commit message)")
58            .to_string();
59        let oid_text = oid.to_string();
60        let short_oid: String = oid_text.chars().take(12).collect();
61
62        let branch = find_branch_for_commit(&repo, oid);
63
64        commits.push(CommitSummary {
65            oid: oid_text,
66            short_oid,
67            summary,
68            branch,
69        });
70    }
71
72    Ok(commits)
73}
74
75#[derive(Debug, Clone)]
76pub struct BranchInfo {
77    pub name: String,
78    pub is_current: bool,
79    pub is_head_detached: bool,
80}
81
82/// # Errors
83///
84/// Returns an error when the git repository cannot be found or branches cannot be listed.
85pub fn list_branches(worktree_path: &Path) -> Result<Vec<BranchInfo>> {
86    let repo = Repository::discover(worktree_path).context("failed to locate git repository")?;
87
88    let current_branch = repo
89        .head()
90        .ok()
91        .and_then(|h| h.shorthand().map(String::from));
92    let is_detached = repo.head().ok().is_some_and(|h| h.target().is_none());
93
94    let mut branches = Vec::new();
95    for branch_result in repo.branches(None).context("failed to list branches")? {
96        let (branch, _) = branch_result.context("failed to read branch")?;
97        if let Some(name) = branch.name().ok().flatten() {
98            branches.push(BranchInfo {
99                name: name.to_string(),
100                is_current: current_branch.as_deref() == Some(name),
101                is_head_detached: is_detached,
102            });
103        }
104    }
105
106    branches.sort_by(|a, b| {
107        if a.is_current {
108            std::cmp::Ordering::Less
109        } else if b.is_current {
110            std::cmp::Ordering::Greater
111        } else {
112            a.name.cmp(&b.name)
113        }
114    });
115
116    Ok(branches)
117}
118
119/// # Errors
120///
121/// Returns an error when the git repository cannot be found or checkout fails.
122pub fn switch_branch(worktree_path: &Path, branch_name: &str) -> Result<()> {
123    let repo = Repository::discover(worktree_path).context("failed to locate git repository")?;
124
125    let (object, reference) = repo
126        .revparse_ext(branch_name)
127        .context("failed to resolve branch")?;
128
129    repo.checkout_tree(&object, None)
130        .context("failed to checkout branch")?;
131
132    match reference {
133        Some(gref) => repo.set_head(gref.name().context("invalid branch reference")?),
134        None => repo.set_head_detached(object.id()),
135    }
136    .context("failed to set HEAD")?;
137
138    Ok(())
139}
140
141fn find_branch_for_commit(repo: &Repository, oid: git2::Oid) -> Option<String> {
142    repo.branches(None).ok()?.flatten().find_map(|(branch, _)| {
143        branch
144            .get()
145            .target()
146            .filter(|target| *target == oid)
147            .and_then(|_| branch.name().ok().flatten().map(String::from))
148    })
149}
150
151/// # Errors
152///
153/// Returns an error when the git repository cannot be found or commit diffs cannot be read.
154pub fn file_heatmap(worktree_path: &Path) -> Result<Vec<FileHeatmapEntry>> {
155    let repo = Repository::discover(worktree_path).context("failed to locate git repository")?;
156    let mut revwalk = repo.revwalk().context("failed to create git revwalk")?;
157    revwalk
158        .set_sorting(Sort::TOPOLOGICAL | Sort::TIME)
159        .context("failed to configure git revwalk sorting")?;
160    revwalk
161        .push_head()
162        .context("failed to start git revwalk from HEAD")?;
163
164    let mut stats: HashMap<String, FileHeatmapStats> = HashMap::new();
165    for oid_result in revwalk {
166        let oid = oid_result.context("failed to walk git history")?;
167        let commit = repo
168            .find_commit(oid)
169            .with_context(|| format!("failed to load commit {oid}"))?;
170        collect_commit_file_heat(&repo, &commit, &mut stats)?;
171    }
172
173    let mut entries = stats
174        .into_iter()
175        .map(|(path, stats)| FileHeatmapEntry {
176            path,
177            commits: stats.commits,
178            changes: stats.insertions + stats.deletions,
179            insertions: stats.insertions,
180            deletions: stats.deletions,
181        })
182        .collect::<Vec<_>>();
183    entries.sort_by(|left, right| {
184        right
185            .changes
186            .cmp(&left.changes)
187            .then_with(|| right.commits.cmp(&left.commits))
188            .then_with(|| left.path.cmp(&right.path))
189    });
190    Ok(entries)
191}
192
193fn collect_commit_file_heat(
194    repo: &Repository,
195    commit: &Commit<'_>,
196    stats: &mut HashMap<String, FileHeatmapStats>,
197) -> Result<()> {
198    let new_tree = commit.tree().context("failed to read commit tree")?;
199    let old_tree = if commit.parent_count() == 0 {
200        None
201    } else {
202        Some(
203            commit
204                .parent(0)
205                .context("failed to read first parent")?
206                .tree()
207                .context("failed to read parent tree")?,
208        )
209    };
210    let mut options = DiffOptions::new();
211    options.context_lines(0).include_typechange(true);
212    let diff = repo
213        .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut options))
214        .context("failed to diff commit")?;
215
216    let mut touched_paths = Vec::new();
217    let mut line_changes = Vec::new();
218    diff.foreach(
219        &mut |delta, _progress| {
220            if let Some(path) = delta_path(&delta) {
221                touched_paths.push(path);
222            }
223            true
224        },
225        None,
226        None,
227        Some(&mut |delta, _hunk, line| {
228            if let Some(path) = delta_path(&delta) {
229                match line.origin() {
230                    '+' => line_changes.push((path, true)),
231                    '-' => line_changes.push((path, false)),
232                    _ => {}
233                }
234            }
235            true
236        }),
237    )
238    .context("failed to walk commit diff")?;
239
240    let mut touched = HashSet::new();
241    for path in touched_paths {
242        touched.insert(path);
243    }
244    for (path, insertion) in line_changes {
245        touched.insert(path.clone());
246        let entry = stats.entry(path).or_default();
247        if insertion {
248            entry.insertions += 1;
249        } else {
250            entry.deletions += 1;
251        }
252    }
253    for path in touched {
254        let entry = stats.entry(path).or_default();
255        entry.commits += 1;
256    }
257    Ok(())
258}
259
260fn delta_path(delta: &git2::DiffDelta<'_>) -> Option<String> {
261    delta
262        .new_file()
263        .path()
264        .or_else(|| delta.old_file().path())
265        .map(normalize_git_path)
266}
267
268fn normalize_git_path(path: &Path) -> String {
269    path.components()
270        .filter_map(|component| match component {
271            Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
272            _ => None,
273        })
274        .collect::<Vec<_>>()
275        .join("/")
276}
277
278#[cfg(test)]
279mod tests {
280    use super::{file_heatmap, normalize_git_path};
281    use anyhow::Result;
282    use git2::{Oid, Signature};
283    use std::fs;
284    use std::path::Path;
285    use tempfile::tempdir;
286
287    #[test]
288    fn normalize_git_path_uses_forward_slashes() {
289        assert_eq!(
290            normalize_git_path(Path::new("src/lib.rs")),
291            "src/lib.rs".to_string()
292        );
293    }
294
295    #[test]
296    fn file_heatmap_orders_files_by_line_churn() -> Result<()> {
297        let temp = tempdir()?;
298        let repo = git2::Repository::init(temp.path())?;
299        commit_file(&repo, temp.path(), "src/hot.rs", "fn one() {}\n", "hot one")?;
300        commit_file(&repo, temp.path(), "src/cold.rs", "fn cold() {}\n", "cold")?;
301        commit_file(
302            &repo,
303            temp.path(),
304            "src/hot.rs",
305            "fn one() {}\nfn two() {}\n",
306            "hot two",
307        )?;
308
309        let entries = file_heatmap(temp.path())?;
310
311        assert_eq!(entries[0].path, "src/hot.rs");
312        assert_eq!(entries[0].commits, 2);
313        assert!(entries[0].changes >= entries[1].changes);
314        Ok(())
315    }
316
317    fn commit_file(
318        repo: &git2::Repository,
319        root: &std::path::Path,
320        relative_path: &str,
321        content: &str,
322        message: &str,
323    ) -> Result<Oid> {
324        let path = root.join(relative_path);
325        if let Some(parent) = path.parent() {
326            fs::create_dir_all(parent)?;
327        }
328        fs::write(&path, content)?;
329
330        let mut index = repo.index()?;
331        index.add_path(std::path::Path::new(relative_path))?;
332        index.write()?;
333
334        let tree_oid = index.write_tree()?;
335        let tree = repo.find_tree(tree_oid)?;
336        let signature = Signature::now("Parley Test", "parley@example.com")?;
337        let parents = repo
338            .head()
339            .ok()
340            .and_then(|head| head.target())
341            .map(|oid| repo.find_commit(oid))
342            .transpose()?
343            .into_iter()
344            .collect::<Vec<_>>();
345        let parent_refs = parents.iter().collect::<Vec<_>>();
346        let oid = repo.commit(
347            Some("HEAD"),
348            &signature,
349            &signature,
350            message,
351            &tree,
352            &parent_refs,
353        )?;
354        Ok(oid)
355    }
356}