features_cli/
git_helper.rs1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::models::Change;
7
8fn format_timestamp(time: git2::Time) -> String {
9 let timestamp = time.seconds();
10 let datetime = chrono::DateTime::from_timestamp(timestamp, 0)
11 .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
12 datetime.format("%Y-%m-%d %H:%M:%S").to_string()
13}
14
15pub fn get_all_commits_by_path(repo_path: &Path) -> Result<HashMap<String, Vec<Change>>> {
19 let repo = Repository::discover(repo_path).with_context(|| {
20 format!(
21 "failed to discover git repository at `{}`",
22 repo_path.display()
23 )
24 })?;
25
26 let mut revwalk = repo.revwalk()?;
27 revwalk.push_head()?;
28 revwalk.set_sorting(git2::Sort::TIME)?;
29
30 let mut path_changes: HashMap<String, Vec<Change>> = HashMap::new();
32
33 for oid in revwalk {
34 let oid = oid?;
35 let commit = repo.find_commit(oid)?;
36 let author = commit.author();
37 let message = commit.message().unwrap_or("").to_string();
38
39 let lines: Vec<&str> = message.lines().collect();
41 let title = lines.first().unwrap_or(&"").to_string();
42 let description = if lines.len() > 1 {
43 lines[1..].join("\n").trim().to_string()
44 } else {
45 String::new()
46 };
47
48 let change = Change {
49 title,
50 author_name: author.name().unwrap_or("Unknown").to_string(),
51 author_email: author.email().unwrap_or("").to_string(),
52 description,
53 date: format_timestamp(commit.time()),
54 hash: format!("{}", oid),
55 };
56
57 let affected_paths = get_affected_paths(&repo, &commit)?;
59
60 for file_path in affected_paths {
63 let path_obj = Path::new(&file_path);
64
65 for ancestor in path_obj.ancestors().skip(1) {
67 if ancestor == Path::new("") {
68 break;
69 }
70 let ancestor_str = ancestor.to_string_lossy().to_string();
71
72 let changes_list = path_changes.entry(ancestor_str).or_default();
74 if !changes_list.iter().any(|c| c.hash == change.hash) {
75 changes_list.push(change.clone());
76 }
77 }
78 }
79 }
80
81 Ok(path_changes)
82}
83
84fn get_affected_paths(repo: &Repository, commit: &git2::Commit) -> Result<Vec<String>> {
86 let mut paths = Vec::new();
87
88 if commit.parent_count() == 0 {
90 let tree = commit.tree()?;
91 collect_tree_paths(repo, &tree, "", &mut paths)?;
92 return Ok(paths);
93 }
94
95 let tree = commit.tree()?;
97 let parent = commit.parent(0)?;
98 let parent_tree = parent.tree()?;
99
100 let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)?;
101
102 diff.foreach(
103 &mut |delta, _| {
104 if let Some(path) = delta.new_file().path()
105 && let Some(path_str) = path.to_str()
106 {
107 paths.push(path_str.to_string());
108 }
109 if let Some(path) = delta.old_file().path()
110 && let Some(path_str) = path.to_str()
111 && !paths.contains(&path_str.to_string())
112 {
113 paths.push(path_str.to_string());
114 }
115 true
116 },
117 None,
118 None,
119 None,
120 )?;
121
122 Ok(paths)
123}
124
125fn collect_tree_paths(
127 repo: &Repository,
128 tree: &git2::Tree,
129 prefix: &str,
130 paths: &mut Vec<String>,
131) -> Result<()> {
132 for entry in tree.iter() {
133 if let Some(name) = entry.name() {
134 let path = if prefix.is_empty() {
135 name.to_string()
136 } else {
137 format!("{}/{}", prefix, name)
138 };
139
140 paths.push(path.clone());
141
142 if entry.kind() == Some(git2::ObjectType::Tree)
143 && let Ok(subtree) = entry.to_object(repo).and_then(|obj| obj.peel_to_tree())
144 {
145 collect_tree_paths(repo, &subtree, &path, paths)?;
146 }
147 }
148 }
149 Ok(())
150}