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
30pub 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
82pub 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
119pub 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
151pub 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}