1use crate::context::{ChangeType, RecentCommit, StagedFile};
2use crate::git::utils::is_binary_diff;
3use crate::log_debug;
4use anyhow::{Context, Result, anyhow};
5use chrono;
6use git2::{FileMode, Repository, Status};
7
8#[derive(Debug)]
10pub struct CommitResult {
11 pub branch: String,
12 pub commit_hash: String,
13 pub files_changed: usize,
14 pub insertions: usize,
15 pub deletions: usize,
16 pub new_files: Vec<(String, FileMode)>,
17}
18
19#[derive(Debug)]
21pub struct CommitInfo {
22 pub branch: String,
23 pub commit: RecentCommit,
24 pub file_paths: Vec<String>,
25}
26
27pub fn commit(repo: &Repository, message: &str, is_remote: bool) -> Result<CommitResult> {
39 if is_remote {
40 return Err(anyhow!(
41 "Cannot commit to a remote repository in read-only mode"
42 ));
43 }
44
45 let signature = repo.signature()?;
46 let mut index = repo.index()?;
47 let tree_id = index.write_tree()?;
48 let tree = repo.find_tree(tree_id)?;
49 let parent_commit = repo.head()?.peel_to_commit()?;
50 let commit_oid = repo.commit(
51 Some("HEAD"),
52 &signature,
53 &signature,
54 message,
55 &tree,
56 &[&parent_commit],
57 )?;
58
59 let branch_name = repo.head()?.shorthand().unwrap_or("HEAD").to_string();
60 let commit = repo.find_commit(commit_oid)?;
61 let short_hash = commit.id().to_string()[..7].to_string();
62
63 let mut files_changed = 0;
64 let mut insertions = 0;
65 let mut deletions = 0;
66 let mut new_files = Vec::new();
67
68 let diff = repo.diff_tree_to_tree(Some(&parent_commit.tree()?), Some(&tree), None)?;
69
70 diff.print(git2::DiffFormat::NameStatus, |_, _, line| {
71 files_changed += 1;
72 if line.origin() == '+' {
73 insertions += 1;
74 } else if line.origin() == '-' {
75 deletions += 1;
76 }
77 true
78 })?;
79
80 let statuses = repo.statuses(None)?;
81 for entry in statuses.iter() {
82 if entry.status().contains(Status::INDEX_NEW) {
83 new_files.push((
84 entry.path().context("Could not get path")?.to_string(),
85 entry
86 .index_to_workdir()
87 .context("Could not get index to workdir")?
88 .new_file()
89 .mode(),
90 ));
91 }
92 }
93
94 Ok(CommitResult {
95 branch: branch_name,
96 commit_hash: short_hash,
97 files_changed,
98 insertions,
99 deletions,
100 new_files,
101 })
102}
103
104pub fn get_commits_between_with_callback<T, F>(
117 repo: &Repository,
118 from: &str,
119 to: &str,
120 mut callback: F,
121) -> Result<Vec<T>>
122where
123 F: FnMut(&RecentCommit) -> Result<T>,
124{
125 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
126 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
127
128 let mut revwalk = repo.revwalk()?;
129 revwalk.push(to_commit.id())?;
130 revwalk.hide(from_commit.id())?;
131
132 revwalk
133 .filter_map(std::result::Result::ok)
134 .map(|id| {
135 let commit = repo.find_commit(id)?;
136 let recent_commit = RecentCommit {
137 hash: commit.id().to_string(),
138 message: commit.message().unwrap_or_default().to_string(),
139 author: commit.author().name().unwrap_or_default().to_string(),
140 timestamp: commit.time().seconds().to_string(),
141 };
142 callback(&recent_commit)
143 })
144 .collect()
145}
146
147pub fn get_commit_files(repo: &Repository, commit_id: &str) -> Result<Vec<StagedFile>> {
158 log_debug!("Getting files for commit: {}", commit_id);
159
160 let obj = repo.revparse_single(commit_id)?;
162 let commit = obj.peel_to_commit()?;
163
164 let commit_tree = commit.tree()?;
165 let parent_commit = if commit.parent_count() > 0 {
166 Some(commit.parent(0)?)
167 } else {
168 None
169 };
170
171 let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
172
173 let mut commit_files = Vec::new();
174
175 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
176
177 diff.foreach(
179 &mut |delta, _| {
180 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
181 let change_type = match delta.status() {
182 git2::Delta::Added => ChangeType::Added,
183 git2::Delta::Modified => ChangeType::Modified,
184 git2::Delta::Deleted => ChangeType::Deleted,
185 _ => return true, };
187
188 let should_exclude = crate::file_analyzers::should_exclude_file(path);
189
190 commit_files.push(StagedFile {
191 path: path.to_string(),
192 change_type,
193 diff: String::new(), analysis: Vec::new(),
195 content: None,
196 content_excluded: should_exclude,
197 });
198 }
199 true
200 },
201 None,
202 None,
203 None,
204 )?;
205
206 for file in &mut commit_files {
208 if file.content_excluded {
209 file.diff = String::from("[Content excluded]");
210 file.analysis = vec!["[Analysis excluded]".to_string()];
211 continue;
212 }
213
214 let mut diff_options = git2::DiffOptions::new();
215 diff_options.pathspec(&file.path);
216
217 let file_diff = repo.diff_tree_to_tree(
218 parent_tree.as_ref(),
219 Some(&commit_tree),
220 Some(&mut diff_options),
221 )?;
222
223 let mut diff_string = String::new();
224 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
225 let origin = match line.origin() {
226 '+' | '-' | ' ' => line.origin(),
227 _ => ' ',
228 };
229 diff_string.push(origin);
230 diff_string.push_str(&String::from_utf8_lossy(line.content()));
231 true
232 })?;
233
234 if is_binary_diff(&diff_string) {
235 file.diff = "[Binary file changed]".to_string();
236 } else {
237 file.diff = diff_string;
238 }
239
240 let analyzer = crate::file_analyzers::get_analyzer(&file.path);
241 file.analysis = analyzer.analyze(&file.path, file);
242 }
243
244 log_debug!("Found {} files in commit", commit_files.len());
245 Ok(commit_files)
246}
247
248pub fn extract_commit_info(repo: &Repository, commit_id: &str, branch: &str) -> Result<CommitInfo> {
250 let obj = repo.revparse_single(commit_id)?;
252 let commit = obj.peel_to_commit()?;
253
254 let commit_author = commit.author();
256 let author_name = commit_author.name().unwrap_or_default().to_string();
257 let commit_message = commit.message().unwrap_or_default().to_string();
258 let commit_time = commit.time().seconds().to_string();
259 let commit_hash = commit.id().to_string();
260
261 let recent_commit = RecentCommit {
263 hash: commit_hash,
264 message: commit_message,
265 author: author_name,
266 timestamp: commit_time,
267 };
268
269 let file_paths = get_file_paths_for_commit(repo, commit_id)?;
271
272 Ok(CommitInfo {
273 branch: branch.to_string(),
274 commit: recent_commit,
275 file_paths,
276 })
277}
278
279pub fn get_file_paths_for_commit(repo: &Repository, commit_id: &str) -> Result<Vec<String>> {
281 let obj = repo.revparse_single(commit_id)?;
283 let commit = obj.peel_to_commit()?;
284
285 let commit_tree = commit.tree()?;
286 let parent_commit = if commit.parent_count() > 0 {
287 Some(commit.parent(0)?)
288 } else {
289 None
290 };
291
292 let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
293
294 let mut file_paths = Vec::new();
295
296 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
298
299 diff.foreach(
301 &mut |delta, _| {
302 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
303 match delta.status() {
304 git2::Delta::Added | git2::Delta::Modified | git2::Delta::Deleted => {
305 file_paths.push(path.to_string());
306 }
307 _ => {} }
309 }
310 true
311 },
312 None,
313 None,
314 None,
315 )?;
316
317 Ok(file_paths)
318}
319
320pub fn get_commit_date(repo: &Repository, commit_ish: &str) -> Result<String> {
331 let obj = repo.revparse_single(commit_ish)?;
333 let commit = obj.peel_to_commit()?;
334
335 let time = commit.time();
337
338 let datetime = chrono::DateTime::<chrono::Utc>::from_timestamp(time.seconds(), 0)
340 .ok_or_else(|| anyhow!("Invalid timestamp"))?;
341
342 Ok(datetime.format("%Y-%m-%d").to_string())
344}