git_iris/git/
commit.rs

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/// Results from a commit operation
9#[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/// Collects information about a specific commit
20#[derive(Debug)]
21pub struct CommitInfo {
22    pub branch: String,
23    pub commit: RecentCommit,
24    pub file_paths: Vec<String>,
25}
26
27/// Commits changes to the repository.
28///
29/// # Arguments
30///
31/// * `repo` - The git repository
32/// * `message` - The commit message.
33/// * `is_remote` - Whether the repository is remote.
34///
35/// # Returns
36///
37/// A Result containing the `CommitResult` or an error.
38pub 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
104/// Retrieves commits between two Git references.
105///
106/// # Arguments
107///
108/// * `repo` - The git repository
109/// * `from` - The starting Git reference.
110/// * `to` - The ending Git reference.
111/// * `callback` - A callback function to process each commit.
112///
113/// # Returns
114///
115/// A Result containing a Vec of processed commits or an error.
116pub 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
147/// Retrieves the files changed in a specific commit
148///
149/// # Arguments
150///
151/// * `repo` - The git repository
152/// * `commit_id` - The ID of the commit to analyze.
153///
154/// # Returns
155///
156/// A Result containing a Vec of `StagedFile` objects for the commit or an error.
157pub fn get_commit_files(repo: &Repository, commit_id: &str) -> Result<Vec<StagedFile>> {
158    log_debug!("Getting files for commit: {}", commit_id);
159
160    // Parse the commit ID
161    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    // Get statistics for each file and convert to our StagedFile format
178    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, // Skip other types of changes
186                };
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(), // Will be populated later
194                    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    // Get the diff for each file
207    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
248/// Extract commit info without crossing async boundaries
249pub fn extract_commit_info(repo: &Repository, commit_id: &str, branch: &str) -> Result<CommitInfo> {
250    // Parse the commit ID
251    let obj = repo.revparse_single(commit_id)?;
252    let commit = obj.peel_to_commit()?;
253
254    // Extract commit information
255    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    // Create the recent commit object
262    let recent_commit = RecentCommit {
263        hash: commit_hash,
264        message: commit_message,
265        author: author_name,
266        timestamp: commit_time,
267    };
268
269    // Get file paths from this commit
270    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
279/// Gets just the file paths for a specific commit (not the full content)
280pub fn get_file_paths_for_commit(repo: &Repository, commit_id: &str) -> Result<Vec<String>> {
281    // Parse the commit ID
282    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    // Create diff between trees
297    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
298
299    // Extract file paths
300    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                    _ => {} // Skip other types of changes
308                }
309            }
310            true
311        },
312        None,
313        None,
314        None,
315    )?;
316
317    Ok(file_paths)
318}
319
320/// Gets the date of a commit in YYYY-MM-DD format
321///
322/// # Arguments
323///
324/// * `repo` - The git repository
325/// * `commit_ish` - A commit-ish reference (hash, tag, branch, etc.)
326///
327/// # Returns
328///
329/// A Result containing the formatted date string or an error
330pub fn get_commit_date(repo: &Repository, commit_ish: &str) -> Result<String> {
331    // Resolve the commit-ish to an actual commit
332    let obj = repo.revparse_single(commit_ish)?;
333    let commit = obj.peel_to_commit()?;
334
335    // Get the commit time
336    let time = commit.time();
337
338    // Convert to a chrono::DateTime for easier formatting
339    let datetime = chrono::DateTime::<chrono::Utc>::from_timestamp(time.seconds(), 0)
340        .ok_or_else(|| anyhow!("Invalid timestamp"))?;
341
342    // Format as YYYY-MM-DD
343    Ok(datetime.format("%Y-%m-%d").to_string())
344}