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
8pub fn get_repository_url(repo_path: &Path) -> Option<String> {
11 let repo = Repository::discover(repo_path).ok()?;
12
13 if let Ok(remote) = repo.find_remote("origin")
15 && let Some(url) = remote.url()
16 {
17 return Some(url.to_string());
18 }
19
20 None
21}
22
23fn format_timestamp(time: git2::Time) -> String {
24 let timestamp = time.seconds();
25 let datetime = chrono::DateTime::from_timestamp(timestamp, 0)
26 .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
27 datetime.format("%Y-%m-%d %H:%M:%S").to_string()
28}
29
30pub fn get_all_commits_by_path(repo_path: &Path) -> Result<HashMap<String, Vec<Change>>> {
34 let repo = Repository::discover(repo_path).with_context(|| {
35 format!(
36 "failed to discover git repository at `{}`",
37 repo_path.display()
38 )
39 })?;
40
41 let mut revwalk = repo.revwalk()?;
42 revwalk.push_head()?;
43 revwalk.set_sorting(git2::Sort::TIME)?;
44
45 let mut path_changes: HashMap<String, Vec<Change>> = HashMap::new();
47
48 for oid in revwalk {
49 let oid = oid?;
50 let commit = repo.find_commit(oid)?;
51 let author = commit.author();
52 let message = commit.message().unwrap_or("").to_string();
53
54 let lines: Vec<&str> = message.lines().collect();
56 let title = lines.first().unwrap_or(&"").to_string();
57 let description = if lines.len() > 1 {
58 lines[1..].join("\n").trim().to_string()
59 } else {
60 String::new()
61 };
62
63 let change = Change {
64 title,
65 author_name: author.name().unwrap_or("").to_string(),
66 author_email: author.email().unwrap_or("").to_string(),
67 description,
68 date: format_timestamp(commit.time()),
69 hash: format!("{}", oid),
70 };
71
72 let affected_paths = get_affected_paths(&repo, &commit)?;
74
75 for file_path in affected_paths {
78 let path_obj = Path::new(&file_path);
79
80 for ancestor in path_obj.ancestors().skip(1) {
82 if ancestor == Path::new("") {
83 break;
84 }
85 let ancestor_str = ancestor.to_string_lossy().to_string();
86
87 let changes_list = path_changes.entry(ancestor_str).or_default();
89 if !changes_list.iter().any(|c| c.hash == change.hash) {
90 changes_list.push(change.clone());
91 }
92 }
93 }
94 }
95
96 Ok(path_changes)
97}
98
99fn get_affected_paths(repo: &Repository, commit: &git2::Commit) -> Result<Vec<String>> {
101 let mut paths = Vec::new();
102
103 if commit.parent_count() == 0 {
105 let tree = commit.tree()?;
106 collect_tree_paths(repo, &tree, "", &mut paths)?;
107 return Ok(paths);
108 }
109
110 let tree = commit.tree()?;
112 let parent = commit.parent(0)?;
113 let parent_tree = parent.tree()?;
114
115 let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)?;
116
117 diff.foreach(
118 &mut |delta, _| {
119 if let Some(path) = delta.new_file().path()
120 && let Some(path_str) = path.to_str()
121 {
122 paths.push(path_str.to_string());
123 }
124 if let Some(path) = delta.old_file().path()
125 && let Some(path_str) = path.to_str()
126 && !paths.contains(&path_str.to_string())
127 {
128 paths.push(path_str.to_string());
129 }
130 true
131 },
132 None,
133 None,
134 None,
135 )?;
136
137 Ok(paths)
138}
139
140fn collect_tree_paths(
142 repo: &Repository,
143 tree: &git2::Tree,
144 prefix: &str,
145 paths: &mut Vec<String>,
146) -> Result<()> {
147 for entry in tree.iter() {
148 if let Some(name) = entry.name() {
149 let path = if prefix.is_empty() {
150 name.to_string()
151 } else {
152 format!("{}/{}", prefix, name)
153 };
154
155 paths.push(path.clone());
156
157 if entry.kind() == Some(git2::ObjectType::Tree)
158 && let Ok(subtree) = entry.to_object(repo).and_then(|obj| obj.peel_to_tree())
159 {
160 collect_tree_paths(repo, &subtree, &path, paths)?;
161 }
162 }
163 }
164 Ok(())
165}