features_cli/
git_helper.rs1use anyhow::{Context, Result};
2use git2::Repository;
3use std::path::Path;
4
5use crate::models::Change;
6
7pub fn get_commits_for_path(repo_path: &Path, feature_path: &str) -> Result<Vec<Change>> {
8 let repo = Repository::discover(repo_path).with_context(|| {
9 format!(
10 "failed to discover git repository at `{}`",
11 repo_path.display()
12 )
13 })?;
14
15 let repo_workdir = repo
17 .workdir()
18 .context("repository has no working directory")?;
19 let feature_abs_path = std::fs::canonicalize(feature_path)
20 .with_context(|| format!("failed to canonicalize path `{}`", feature_path))?;
21
22 let relative_path = feature_abs_path
23 .strip_prefix(repo_workdir)
24 .with_context(|| format!("path `{}` is not within repository", feature_path))?;
25
26 let relative_path_str = relative_path.to_string_lossy();
27
28 let mut revwalk = repo.revwalk()?;
29 revwalk.push_head()?;
30 revwalk.set_sorting(git2::Sort::TIME)?;
31
32 let mut changes = Vec::new();
33
34 for oid in revwalk {
35 let oid = oid?;
36 let commit = repo.find_commit(oid)?;
37
38 if commit_affects_path(&repo, &commit, &relative_path_str)? {
40 let author = commit.author();
41 let message = commit.message().unwrap_or("").to_string();
42
43 let lines: Vec<&str> = message.lines().collect();
45 let title = lines.first().unwrap_or(&"").to_string();
46 let description = if lines.len() > 1 {
47 lines[1..].join("\n").trim().to_string()
48 } else {
49 String::new()
50 };
51
52 changes.push(Change {
53 title,
54 author_name: author.name().unwrap_or("Unknown").to_string(),
55 author_email: author.email().unwrap_or("").to_string(),
56 description,
57 date: format_timestamp(commit.time()),
58 hash: format!("{}", oid),
59 });
60 }
61 }
62
63 Ok(changes)
64}
65
66fn commit_affects_path(repo: &Repository, commit: &git2::Commit, path: &str) -> Result<bool> {
67 if commit.parent_count() == 0 {
69 let tree = commit.tree()?;
70 return Ok(tree_contains_path(&tree, path));
71 }
72
73 let tree = commit.tree()?;
75 let parent = commit.parent(0)?;
76 let parent_tree = parent.tree()?;
77
78 let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)?;
79
80 let mut affects_path = false;
81 diff.foreach(
82 &mut |delta, _| {
83 if let Some(path_str) = delta.new_file().path()
84 && path_str.starts_with(path)
85 {
86 affects_path = true;
87 }
88 if let Some(path_str) = delta.old_file().path()
89 && path_str.starts_with(path)
90 {
91 affects_path = true;
92 }
93 true
94 },
95 None,
96 None,
97 None,
98 )?;
99
100 Ok(affects_path)
101}
102
103fn tree_contains_path(tree: &git2::Tree, path: &str) -> bool {
104 tree.get_path(Path::new(path)).is_ok()
105}
106
107fn format_timestamp(time: git2::Time) -> String {
108 let timestamp = time.seconds();
109 let datetime = chrono::DateTime::from_timestamp(timestamp, 0)
110 .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
111 datetime.format("%Y-%m-%d %H:%M:%S").to_string()
112}