gitai/git/
commit.rs

1use crate::core::context::{ChangeType, RecentCommit, StagedFile};
2use crate::git::utils::is_binary_diff;
3use crate::{analyzer, 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    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 = analyzer::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 = analyzer::get_analyzer(&file.path);
241        file.analysis = analyzer.analyze(&file.path, file);
242    }
243
244    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}
345
346/// Gets the files changed between two branches
347///
348/// # Arguments
349///
350/// * `repo` - The git repository
351/// * `base_branch` - The base branch (e.g., "main")
352/// * `target_branch` - The target branch (e.g., "feature-branch")
353///
354/// # Returns
355///
356/// A Result containing a Vec of `StagedFile` objects for the branch comparison or an error.
357pub fn get_branch_diff_files(
358    repo: &Repository,
359    base_branch: &str,
360    target_branch: &str,
361) -> Result<Vec<StagedFile>> {
362    debug!(
363        "Getting files changed between branches: {} -> {}",
364        base_branch, target_branch
365    );
366
367    // Resolve branch references
368    let base_commit = resolve_branch(repo, base_branch)?;
369    let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
370
371    // Find the merge-base (common ancestor) between the branches
372    // This gives us the point where the target branch diverged from the base branch
373    let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
374    let merge_base_commit = repo.find_commit(merge_base_oid)?;
375
376    debug!("Using merge-base {} for comparison", merge_base_oid);
377
378    let base_tree = merge_base_commit.tree()?;
379    let target_tree = target_commit.tree()?;
380
381    let mut branch_files = Vec::new();
382
383    // Create diff between the merge-base tree and target tree
384    // This shows only changes made in the target branch since it diverged
385    let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), None)?;
386
387    // Get statistics for each file and convert to our StagedFile format
388    diff.foreach(
389        &mut |delta, _| {
390            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
391                let change_type = match delta.status() {
392                    git2::Delta::Added => ChangeType::Added,
393                    git2::Delta::Modified => ChangeType::Modified,
394                    git2::Delta::Deleted => ChangeType::Deleted,
395                    _ => return true, // Skip other types of changes
396                };
397
398                let should_exclude = analyzer::should_exclude_file(path);
399
400                branch_files.push(StagedFile {
401                    path: path.to_string(),
402                    change_type,
403                    diff: String::new(), // Will be populated later
404                    analysis: Vec::new(),
405                    content: None,
406                    content_excluded: should_exclude,
407                });
408            }
409            true
410        },
411        None,
412        None,
413        None,
414    )?;
415
416    // Get the diff for each file
417    for file in &mut branch_files {
418        if file.content_excluded {
419            file.diff = String::from("[Content excluded]");
420            file.analysis = vec!["[Analysis excluded]".to_string()];
421            continue;
422        }
423
424        let mut diff_options = git2::DiffOptions::new();
425        diff_options.pathspec(&file.path);
426
427        let file_diff = repo.diff_tree_to_tree(
428            Some(&base_tree),
429            Some(&target_tree),
430            Some(&mut diff_options),
431        )?;
432
433        let mut diff_string = String::new();
434        file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
435            let origin = match line.origin() {
436                '+' | '-' | ' ' => line.origin(),
437                _ => ' ',
438            };
439            diff_string.push(origin);
440            diff_string.push_str(&String::from_utf8_lossy(line.content()));
441            true
442        })?;
443
444        if is_binary_diff(&diff_string) {
445            file.diff = "[Binary file changed]".to_string();
446        } else {
447            file.diff = diff_string;
448        }
449
450        // Get file content from target branch if it's a modified or added file
451        if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
452            && let Ok(entry) = target_tree.get_path(std::path::Path::new(&file.path))
453            && let Ok(object) = entry.to_object(repo)
454            && let Some(blob) = object.as_blob()
455            && let Ok(content) = std::str::from_utf8(blob.content())
456        {
457            file.content = Some(content.to_string());
458        }
459
460        let analyzer = analyzer::get_analyzer(&file.path);
461        file.analysis = analyzer.analyze(&file.path, file);
462    }
463
464    debug!(
465        "Found {} files changed between branches (using merge-base)",
466        branch_files.len()
467    );
468    Ok(branch_files)
469}
470
471/// Extract branch comparison info without crossing async boundaries
472pub fn extract_branch_diff_info(
473    repo: &Repository,
474    base_branch: &str,
475    target_branch: &str,
476) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
477    // Get the target branch name for display
478    let display_branch = format!("{base_branch} -> {target_branch}");
479
480    // Resolve branch references without fallback for explicit branch comparison
481    // This ensures that nonexistent branches will cause an error as expected by tests
482    let base_commit = resolve_branch_strict(repo, base_branch)?;
483    let target_commit = resolve_branch_strict(repo, target_branch)?;
484
485    // Find the merge-base (common ancestor) between the branches
486    let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
487    debug!("Using merge-base {} for commit history", merge_base_oid);
488
489    let mut revwalk = repo.revwalk()?;
490    revwalk.push(target_commit.id())?;
491    revwalk.hide(merge_base_oid)?; // Hide the merge-base commit itself
492
493    let recent_commits: Result<Vec<RecentCommit>> = revwalk
494        .take(10) // Limit to 10 most recent commits in the branch
495        .map(|oid| {
496            let oid = oid?;
497            let commit = repo.find_commit(oid)?;
498            let author = commit.author();
499            Ok(RecentCommit {
500                hash: oid.to_string(),
501                message: commit.message().unwrap_or_default().to_string(),
502                author: author.name().unwrap_or_default().to_string(),
503                timestamp: commit.time().seconds().to_string(),
504            })
505        })
506        .collect();
507
508    let recent_commits = recent_commits?;
509
510    // Get file paths from the diff for metadata
511    let diff_files = get_branch_diff_files(repo, base_branch, target_branch)?;
512    let file_paths: Vec<String> = diff_files.iter().map(|file| file.path.clone()).collect();
513
514    Ok((display_branch, recent_commits, file_paths))
515}
516
517/// Gets commits between two references with their messages for PR descriptions
518///
519/// # Arguments
520///
521/// * `repo` - The git repository
522/// * `from` - The starting Git reference (exclusive)
523/// * `to` - The ending Git reference (inclusive)
524///
525/// # Returns
526///
527/// A Result containing a Vec of formatted commit messages or an error.
528pub fn get_commits_for_pr(repo: &Repository, from: &str, to: &str) -> Result<Vec<String>> {
529    debug!("Getting commits for PR between {} and {}", from, to);
530
531    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
532    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
533
534    let mut revwalk = repo.revwalk()?;
535    revwalk.push(to_commit.id())?;
536    revwalk.hide(from_commit.id())?;
537
538    let commits: Result<Vec<String>> = revwalk
539        .map(|oid| {
540            let oid = oid?;
541            let commit = repo.find_commit(oid)?;
542            let message = commit.message().unwrap_or_default();
543            // Get just the first line (title) of the commit message
544            let title = message.lines().next().unwrap_or_default();
545            Ok(format!("{}: {}", &oid.to_string()[..7], title))
546        })
547        .collect();
548
549    let mut result = commits?;
550    result.reverse(); // Show commits in chronological order
551
552    debug!("Found {} commits for PR", result.len());
553    Ok(result)
554}
555
556/// Gets the files changed in a commit range (similar to branch diff but for commit range)
557///
558/// # Arguments
559///
560/// * `repo` - The git repository
561/// * `from` - The starting Git reference (exclusive)
562/// * `to` - The ending Git reference (inclusive)
563///
564/// # Returns
565///
566/// A Result containing a Vec of `StagedFile` objects for the commit range or an error.
567pub fn get_commit_range_files(repo: &Repository, from: &str, to: &str) -> Result<Vec<StagedFile>> {
568    debug!("Getting files changed in commit range: {} -> {}", from, to);
569
570    // Resolve commit references
571    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
572    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
573
574    let from_tree = from_commit.tree()?;
575    let to_tree = to_commit.tree()?;
576
577    let mut range_files = Vec::new();
578
579    // Create diff between the from and to trees
580    let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
581
582    // Get statistics for each file and convert to our StagedFile format
583    diff.foreach(
584        &mut |delta, _| {
585            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
586                let change_type = match delta.status() {
587                    git2::Delta::Added => ChangeType::Added,
588                    git2::Delta::Modified => ChangeType::Modified,
589                    git2::Delta::Deleted => ChangeType::Deleted,
590                    _ => return true, // Skip other types of changes
591                };
592
593                let should_exclude = analyzer::should_exclude_file(path);
594
595                range_files.push(StagedFile {
596                    path: path.to_string(),
597                    change_type,
598                    diff: String::new(), // Will be populated later
599                    analysis: Vec::new(),
600                    content: None,
601                    content_excluded: should_exclude,
602                });
603            }
604            true
605        },
606        None,
607        None,
608        None,
609    )?;
610
611    // Get the diff for each file
612    for file in &mut range_files {
613        if file.content_excluded {
614            file.diff = String::from("[Content excluded]");
615            file.analysis = vec!["[Analysis excluded]".to_string()];
616            continue;
617        }
618
619        let mut diff_options = git2::DiffOptions::new();
620        diff_options.pathspec(&file.path);
621
622        let file_diff =
623            repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_options))?;
624
625        let mut diff_string = String::new();
626        file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
627            let origin = match line.origin() {
628                '+' | '-' | ' ' => line.origin(),
629                _ => ' ',
630            };
631            diff_string.push(origin);
632            diff_string.push_str(&String::from_utf8_lossy(line.content()));
633            true
634        })?;
635
636        if is_binary_diff(&diff_string) {
637            file.diff = "[Binary file changed]".to_string();
638        } else {
639            file.diff = diff_string;
640        }
641
642        // Get file content from to commit if it's a modified or added file
643        if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
644            && let Ok(entry) = to_tree.get_path(std::path::Path::new(&file.path))
645            && let Ok(object) = entry.to_object(repo)
646            && let Some(blob) = object.as_blob()
647            && let Ok(content) = std::str::from_utf8(blob.content())
648        {
649            file.content = Some(content.to_string());
650        }
651
652        let analyzer = analyzer::get_analyzer(&file.path);
653        file.analysis = analyzer.analyze(&file.path, file);
654    }
655
656    debug!("Found {} files changed in commit range", range_files.len());
657    Ok(range_files)
658}
659
660/// Extract commit range info without crossing async boundaries
661pub fn extract_commit_range_info(
662    repo: &Repository,
663    from: &str,
664    to: &str,
665) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
666    // Get the range name for display
667    let display_range = format!("{from}..{to}");
668
669    // Get commits in the range
670    let recent_commits: Result<Vec<RecentCommit>> =
671        get_commits_between_with_callback(repo, from, to, |commit| Ok(commit.clone()));
672    let recent_commits = recent_commits?;
673
674    // Get file paths from the range for metadata
675    let range_files = get_commit_range_files(repo, from, to)?;
676    let file_paths: Vec<String> = range_files.iter().map(|file| file.path.clone()).collect();
677
678    Ok((display_range, recent_commits, file_paths))
679}
680
681/// Helper function to strictly resolve a branch reference without fallbacks
682fn resolve_branch_strict<'a>(
683    repo: &'a Repository,
684    branch_name: &'a str,
685) -> Result<git2::Commit<'a>> {
686    match repo.revparse_single(branch_name) {
687        Ok(obj) => Ok(obj.peel_to_commit()?),
688        Err(e) => Err(anyhow!(
689            "Could not resolve branch reference '{branch_name}': {e}"
690        )),
691    }
692}
693
694/// Helper function to resolve a branch reference, trying common default names if the specified branch doesn't exist
695fn resolve_branch<'a>(repo: &'a Repository, branch_name: &'a str) -> Result<git2::Commit<'a>> {
696    // Try the specified branch first
697    if let Ok(obj) = repo.revparse_single(branch_name) {
698        Ok(obj.peel_to_commit()?)
699    } else {
700        debug!(
701            "Branch '{}' not found, trying common alternatives",
702            branch_name
703        );
704
705        // Common default branch names to try
706        let possible_branches = ["main", "master", "develop", "development"];
707
708        for &possible_branch in &possible_branches {
709            if possible_branch != branch_name {
710                match repo.revparse_single(possible_branch) {
711                    Ok(obj) => {
712                        debug!("Using alternative branch '{}'", possible_branch);
713                        return Ok(obj.peel_to_commit()?);
714                    }
715                    Err(_) => {
716                        debug!("Alternative branch '{}' not found", possible_branch);
717                    }
718                }
719            }
720        }
721
722        Err(anyhow!(
723            "Could not resolve branch reference '{branch_name}'. Tried alternatives: {possible_branches:?}"
724        ))
725    }
726}