Skip to main content

git_iris/git/
commit.rs

1use crate::context::{ChangeType, RecentCommit, StagedFile};
2use crate::git::utils::{is_binary_diff, should_exclude_file};
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/// Amends the previous commit with staged changes and a new message.
105///
106/// This replaces HEAD with a new commit that has:
107/// - HEAD's parent as its parent
108/// - The current staged index as its tree
109/// - The new message provided
110///
111/// # Arguments
112///
113/// * `repo` - The git repository
114/// * `message` - The new commit message
115/// * `is_remote` - Whether the repository is remote
116///
117/// # Returns
118///
119/// A Result containing the `CommitResult` or an error.
120pub fn amend_commit(repo: &Repository, message: &str, is_remote: bool) -> Result<CommitResult> {
121    if is_remote {
122        return Err(anyhow!(
123            "Cannot amend a commit in a remote repository in read-only mode"
124        ));
125    }
126
127    let signature = repo.signature()?;
128    let mut index = repo.index()?;
129    let tree_id = index.write_tree()?;
130    let tree = repo.find_tree(tree_id)?;
131
132    // Get the current HEAD commit (the one we're amending)
133    let head_commit = repo.head()?.peel_to_commit()?;
134
135    // Amend the HEAD commit with the new tree and message
136    let commit_oid = head_commit.amend(
137        Some("HEAD"),     // Update the HEAD reference
138        Some(&signature), // New author (use current)
139        Some(&signature), // New committer (use current)
140        None,             // Keep original encoding
141        Some(message),    // New message
142        Some(&tree),      // New tree (includes staged changes)
143    )?;
144
145    let branch_name = repo.head()?.shorthand().unwrap_or("HEAD").to_string();
146    let commit = repo.find_commit(commit_oid)?;
147    let short_hash = commit.id().to_string()[..7].to_string();
148
149    // Calculate diff stats from the original parent to the new tree
150    let mut files_changed = 0;
151    let mut insertions = 0;
152    let mut deletions = 0;
153    let new_files = Vec::new();
154
155    // Use the first parent for diff (or empty tree if initial commit)
156    let parent_tree = if head_commit.parent_count() > 0 {
157        Some(head_commit.parent(0)?.tree()?)
158    } else {
159        None
160    };
161    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
162
163    diff.print(git2::DiffFormat::NameStatus, |_, _, line| {
164        files_changed += 1;
165        if line.origin() == '+' {
166            insertions += 1;
167        } else if line.origin() == '-' {
168            deletions += 1;
169        }
170        true
171    })?;
172
173    log_debug!(
174        "Amended commit {} -> {} with {} files changed",
175        &head_commit.id().to_string()[..7],
176        short_hash,
177        files_changed
178    );
179
180    Ok(CommitResult {
181        branch: branch_name,
182        commit_hash: short_hash,
183        files_changed,
184        insertions,
185        deletions,
186        new_files,
187    })
188}
189
190/// Gets the message of the HEAD commit.
191///
192/// # Arguments
193///
194/// * `repo` - The git repository
195///
196/// # Returns
197///
198/// A Result containing the commit message or an error.
199pub fn get_head_commit_message(repo: &Repository) -> Result<String> {
200    let head_commit = repo.head()?.peel_to_commit()?;
201    Ok(head_commit.message().unwrap_or_default().to_string())
202}
203
204/// Retrieves commits between two Git references.
205///
206/// # Arguments
207///
208/// * `repo` - The git repository
209/// * `from` - The starting Git reference.
210/// * `to` - The ending Git reference.
211/// * `callback` - A callback function to process each commit.
212///
213/// # Returns
214///
215/// A Result containing a Vec of processed commits or an error.
216pub fn get_commits_between_with_callback<T, F>(
217    repo: &Repository,
218    from: &str,
219    to: &str,
220    mut callback: F,
221) -> Result<Vec<T>>
222where
223    F: FnMut(&RecentCommit) -> Result<T>,
224{
225    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
226    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
227
228    let mut revwalk = repo.revwalk()?;
229    revwalk.push(to_commit.id())?;
230    revwalk.hide(from_commit.id())?;
231
232    revwalk
233        .filter_map(std::result::Result::ok)
234        .map(|id| {
235            let commit = repo.find_commit(id)?;
236            let recent_commit = RecentCommit {
237                hash: commit.id().to_string(),
238                message: commit.message().unwrap_or_default().to_string(),
239                author: commit.author().name().unwrap_or_default().to_string(),
240                timestamp: commit.time().seconds().to_string(),
241            };
242            callback(&recent_commit)
243        })
244        .collect()
245}
246
247/// Retrieves the files changed in a specific commit
248///
249/// # Arguments
250///
251/// * `repo` - The git repository
252/// * `commit_id` - The ID of the commit to analyze.
253///
254/// # Returns
255///
256/// A Result containing a Vec of `StagedFile` objects for the commit or an error.
257pub fn get_commit_files(repo: &Repository, commit_id: &str) -> Result<Vec<StagedFile>> {
258    log_debug!("Getting files for commit: {}", commit_id);
259
260    // Parse the commit ID
261    let obj = repo.revparse_single(commit_id)?;
262    let commit = obj.peel_to_commit()?;
263
264    let commit_tree = commit.tree()?;
265    let parent_commit = if commit.parent_count() > 0 {
266        Some(commit.parent(0)?)
267    } else {
268        None
269    };
270
271    let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
272
273    let mut commit_files = Vec::new();
274
275    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
276
277    // Get statistics for each file and convert to our StagedFile format
278    diff.foreach(
279        &mut |delta, _| {
280            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
281                let change_type = match delta.status() {
282                    git2::Delta::Added => ChangeType::Added,
283                    git2::Delta::Modified => ChangeType::Modified,
284                    git2::Delta::Deleted => ChangeType::Deleted,
285                    _ => return true, // Skip other types of changes
286                };
287
288                let should_exclude = should_exclude_file(path);
289
290                commit_files.push(StagedFile {
291                    path: path.to_string(),
292                    change_type,
293                    diff: String::new(), // Will be populated later
294                    content: None,
295                    content_excluded: should_exclude,
296                });
297            }
298            true
299        },
300        None,
301        None,
302        None,
303    )?;
304
305    // Get the diff for each file
306    for file in &mut commit_files {
307        if file.content_excluded {
308            file.diff = String::from("[Content excluded]");
309            continue;
310        }
311
312        let mut diff_options = git2::DiffOptions::new();
313        diff_options.pathspec(&file.path);
314
315        let file_diff = repo.diff_tree_to_tree(
316            parent_tree.as_ref(),
317            Some(&commit_tree),
318            Some(&mut diff_options),
319        )?;
320
321        let mut diff_string = String::new();
322        file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
323            let origin = match line.origin() {
324                '+' | '-' | ' ' => line.origin(),
325                _ => ' ',
326            };
327            diff_string.push(origin);
328            diff_string.push_str(&String::from_utf8_lossy(line.content()));
329            true
330        })?;
331
332        if is_binary_diff(&diff_string) {
333            file.diff = "[Binary file changed]".to_string();
334        } else {
335            file.diff = diff_string;
336        }
337    }
338
339    log_debug!("Found {} files in commit", commit_files.len());
340    Ok(commit_files)
341}
342
343/// Extract commit info without crossing async boundaries
344pub fn extract_commit_info(repo: &Repository, commit_id: &str, branch: &str) -> Result<CommitInfo> {
345    // Parse the commit ID
346    let obj = repo.revparse_single(commit_id)?;
347    let commit = obj.peel_to_commit()?;
348
349    // Extract commit information
350    let commit_author = commit.author();
351    let author_name = commit_author.name().unwrap_or_default().to_string();
352    let commit_message = commit.message().unwrap_or_default().to_string();
353    let commit_time = commit.time().seconds().to_string();
354    let commit_hash = commit.id().to_string();
355
356    // Create the recent commit object
357    let recent_commit = RecentCommit {
358        hash: commit_hash,
359        message: commit_message,
360        author: author_name,
361        timestamp: commit_time,
362    };
363
364    // Get file paths from this commit
365    let file_paths = get_file_paths_for_commit(repo, commit_id)?;
366
367    Ok(CommitInfo {
368        branch: branch.to_string(),
369        commit: recent_commit,
370        file_paths,
371    })
372}
373
374/// Gets just the file paths for a specific commit (not the full content)
375pub fn get_file_paths_for_commit(repo: &Repository, commit_id: &str) -> Result<Vec<String>> {
376    // Parse the commit ID
377    let obj = repo.revparse_single(commit_id)?;
378    let commit = obj.peel_to_commit()?;
379
380    let commit_tree = commit.tree()?;
381    let parent_commit = if commit.parent_count() > 0 {
382        Some(commit.parent(0)?)
383    } else {
384        None
385    };
386
387    let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
388
389    let mut file_paths = Vec::new();
390
391    // Create diff between trees
392    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
393
394    // Extract file paths
395    diff.foreach(
396        &mut |delta, _| {
397            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
398                match delta.status() {
399                    git2::Delta::Added | git2::Delta::Modified | git2::Delta::Deleted => {
400                        file_paths.push(path.to_string());
401                    }
402                    _ => {} // Skip other types of changes
403                }
404            }
405            true
406        },
407        None,
408        None,
409        None,
410    )?;
411
412    Ok(file_paths)
413}
414
415/// Gets the date of a commit in YYYY-MM-DD format
416///
417/// # Arguments
418///
419/// * `repo` - The git repository
420/// * `commit_ish` - A commit-ish reference (hash, tag, branch, etc.)
421///
422/// # Returns
423///
424/// A Result containing the formatted date string or an error
425pub fn get_commit_date(repo: &Repository, commit_ish: &str) -> Result<String> {
426    // Resolve the commit-ish to an actual commit
427    let obj = repo.revparse_single(commit_ish)?;
428    let commit = obj.peel_to_commit()?;
429
430    // Get the commit time
431    let time = commit.time();
432
433    // Convert to a chrono::DateTime for easier formatting
434    let datetime = chrono::DateTime::<chrono::Utc>::from_timestamp(time.seconds(), 0)
435        .ok_or_else(|| anyhow!("Invalid timestamp"))?;
436
437    // Format as YYYY-MM-DD
438    Ok(datetime.format("%Y-%m-%d").to_string())
439}
440
441/// Gets the files changed between two branches
442///
443/// # Arguments
444///
445/// * `repo` - The git repository
446/// * `base_branch` - The base branch (e.g., "main")
447/// * `target_branch` - The target branch (e.g., "feature-branch")
448///
449/// # Returns
450///
451/// A Result containing a Vec of `StagedFile` objects for the branch comparison or an error.
452pub fn get_branch_diff_files(
453    repo: &Repository,
454    base_branch: &str,
455    target_branch: &str,
456) -> Result<Vec<StagedFile>> {
457    log_debug!(
458        "Getting files changed between branches: {} -> {}",
459        base_branch,
460        target_branch
461    );
462
463    // Resolve branch references
464    let base_commit = repo.revparse_single(base_branch)?.peel_to_commit()?;
465    let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
466
467    // Find the merge-base (common ancestor) between the branches
468    // This gives us the point where the target branch diverged from the base branch
469    let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
470    let merge_base_commit = repo.find_commit(merge_base_oid)?;
471
472    log_debug!("Using merge-base {} for comparison", merge_base_oid);
473
474    let base_tree = merge_base_commit.tree()?;
475    let target_tree = target_commit.tree()?;
476
477    let mut branch_files = Vec::new();
478
479    // Create diff between the merge-base tree and target tree
480    // This shows only changes made in the target branch since it diverged
481    let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), None)?;
482    diff.foreach(
483        &mut |delta, _| collect_delta_file(&delta, &mut branch_files),
484        None,
485        None,
486        None,
487    )?;
488
489    // Get the diff for each file
490    for file in &mut branch_files {
491        populate_branch_file(repo, &base_tree, &target_tree, file)?;
492    }
493
494    log_debug!(
495        "Found {} files changed between branches (using merge-base)",
496        branch_files.len()
497    );
498    Ok(branch_files)
499}
500
501fn collect_delta_file(delta: &git2::DiffDelta<'_>, branch_files: &mut Vec<StagedFile>) -> bool {
502    if let Some(file) = staged_file_from_delta(delta) {
503        branch_files.push(file);
504    }
505    true
506}
507
508fn staged_file_from_delta(delta: &git2::DiffDelta<'_>) -> Option<StagedFile> {
509    let path = delta.new_file().path()?.to_str()?;
510    let change_type = change_type_from_delta(delta.status())?;
511
512    Some(StagedFile {
513        path: path.to_string(),
514        change_type,
515        diff: String::new(),
516        content: None,
517        content_excluded: should_exclude_file(path),
518    })
519}
520
521fn change_type_from_delta(delta: git2::Delta) -> Option<ChangeType> {
522    match delta {
523        git2::Delta::Added => Some(ChangeType::Added),
524        git2::Delta::Modified => Some(ChangeType::Modified),
525        git2::Delta::Deleted => Some(ChangeType::Deleted),
526        _ => None,
527    }
528}
529
530fn populate_branch_file(
531    repo: &Repository,
532    base_tree: &git2::Tree<'_>,
533    target_tree: &git2::Tree<'_>,
534    file: &mut StagedFile,
535) -> Result<()> {
536    file.diff = branch_file_diff(repo, base_tree, target_tree, file)?;
537    file.content = branch_file_content(repo, target_tree, file);
538    Ok(())
539}
540
541fn branch_file_diff(
542    repo: &Repository,
543    base_tree: &git2::Tree<'_>,
544    target_tree: &git2::Tree<'_>,
545    file: &StagedFile,
546) -> Result<String> {
547    if file.content_excluded {
548        return Ok(String::from("[Content excluded]"));
549    }
550
551    let mut diff_options = git2::DiffOptions::new();
552    diff_options.pathspec(&file.path);
553
554    let file_diff =
555        repo.diff_tree_to_tree(Some(base_tree), Some(target_tree), Some(&mut diff_options))?;
556    let mut diff_string = String::new();
557    file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
558        diff_string.push(patch_line_origin(line.origin()));
559        diff_string.push_str(&String::from_utf8_lossy(line.content()));
560        true
561    })?;
562
563    if is_binary_diff(&diff_string) {
564        Ok("[Binary file changed]".to_string())
565    } else {
566        Ok(diff_string)
567    }
568}
569
570fn patch_line_origin(origin: char) -> char {
571    match origin {
572        '+' | '-' | ' ' => origin,
573        _ => ' ',
574    }
575}
576
577fn branch_file_content(
578    repo: &Repository,
579    target_tree: &git2::Tree<'_>,
580    file: &StagedFile,
581) -> Option<String> {
582    if !matches!(file.change_type, ChangeType::Added | ChangeType::Modified) {
583        return None;
584    }
585
586    target_tree
587        .get_path(std::path::Path::new(&file.path))
588        .ok()
589        .and_then(|entry| entry.to_object(repo).ok())
590        .and_then(|object| object.as_blob().map(|blob| blob.content().to_vec()))
591        .and_then(|content| String::from_utf8(content).ok())
592}
593
594/// Extract branch comparison info without crossing async boundaries
595pub fn extract_branch_diff_info(
596    repo: &Repository,
597    base_branch: &str,
598    target_branch: &str,
599) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
600    // Get the target branch name for display
601    let display_branch = format!("{base_branch} -> {target_branch}");
602
603    // Get commits between the branches using merge-base
604    let base_commit = repo.revparse_single(base_branch)?.peel_to_commit()?;
605    let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
606
607    // Find the merge-base (common ancestor) between the branches
608    let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
609    log_debug!("Using merge-base {} for commit history", merge_base_oid);
610
611    let mut revwalk = repo.revwalk()?;
612    revwalk.push(target_commit.id())?;
613    revwalk.hide(merge_base_oid)?; // Hide the merge-base commit itself
614
615    let recent_commits: Result<Vec<RecentCommit>> = revwalk
616        .take(10) // Limit to 10 most recent commits in the branch
617        .map(|oid| {
618            let oid = oid?;
619            let commit = repo.find_commit(oid)?;
620            let author = commit.author();
621            Ok(RecentCommit {
622                hash: oid.to_string(),
623                message: commit.message().unwrap_or_default().to_string(),
624                author: author.name().unwrap_or_default().to_string(),
625                timestamp: commit.time().seconds().to_string(),
626            })
627        })
628        .collect();
629
630    let recent_commits = recent_commits?;
631
632    // Get file paths from the diff for metadata
633    let diff_files = get_branch_diff_files(repo, base_branch, target_branch)?;
634    let file_paths: Vec<String> = diff_files.iter().map(|file| file.path.clone()).collect();
635
636    Ok((display_branch, recent_commits, file_paths))
637}
638
639/// Gets commits between two references with their messages for PR descriptions
640///
641/// # Arguments
642///
643/// * `repo` - The git repository
644/// * `from` - The starting Git reference (exclusive)
645/// * `to` - The ending Git reference (inclusive)
646///
647/// # Returns
648///
649/// A Result containing a Vec of formatted commit messages or an error.
650pub fn get_commits_for_pr(repo: &Repository, from: &str, to: &str) -> Result<Vec<String>> {
651    log_debug!("Getting commits for PR between {} and {}", from, to);
652
653    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
654    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
655
656    let mut revwalk = repo.revwalk()?;
657    revwalk.push(to_commit.id())?;
658    revwalk.hide(from_commit.id())?;
659
660    let commits: Result<Vec<String>> = revwalk
661        .map(|oid| {
662            let oid = oid?;
663            let commit = repo.find_commit(oid)?;
664            let message = commit.message().unwrap_or_default();
665            // Get just the first line (title) of the commit message
666            let title = message.lines().next().unwrap_or_default();
667            Ok(format!("{}: {}", &oid.to_string()[..7], title))
668        })
669        .collect();
670
671    let mut result = commits?;
672    result.reverse(); // Show commits in chronological order
673
674    log_debug!("Found {} commits for PR", result.len());
675    Ok(result)
676}
677
678/// Gets the files changed in a commit range (similar to branch diff but for commit range)
679///
680/// # Arguments
681///
682/// * `repo` - The git repository
683/// * `from` - The starting Git reference (exclusive)
684/// * `to` - The ending Git reference (inclusive)
685///
686/// # Returns
687///
688/// A Result containing a Vec of `StagedFile` objects for the commit range or an error.
689pub fn get_commit_range_files(repo: &Repository, from: &str, to: &str) -> Result<Vec<StagedFile>> {
690    log_debug!("Getting files changed in commit range: {} -> {}", from, to);
691
692    // Resolve commit references
693    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
694    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
695
696    let from_tree = from_commit.tree()?;
697    let to_tree = to_commit.tree()?;
698
699    let mut range_files = Vec::new();
700
701    // Create diff between the from and to trees
702    let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
703
704    // Get statistics for each file and convert to our StagedFile format
705    diff.foreach(
706        &mut |delta, _| {
707            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
708                let change_type = match delta.status() {
709                    git2::Delta::Added => ChangeType::Added,
710                    git2::Delta::Modified => ChangeType::Modified,
711                    git2::Delta::Deleted => ChangeType::Deleted,
712                    _ => return true, // Skip other types of changes
713                };
714
715                let should_exclude = should_exclude_file(path);
716
717                range_files.push(StagedFile {
718                    path: path.to_string(),
719                    change_type,
720                    diff: String::new(), // Will be populated later
721                    content: None,
722                    content_excluded: should_exclude,
723                });
724            }
725            true
726        },
727        None,
728        None,
729        None,
730    )?;
731
732    // Get the diff for each file
733    for file in &mut range_files {
734        if file.content_excluded {
735            file.diff = String::from("[Content excluded]");
736            continue;
737        }
738
739        let mut diff_options = git2::DiffOptions::new();
740        diff_options.pathspec(&file.path);
741
742        let file_diff =
743            repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_options))?;
744
745        let mut diff_string = String::new();
746        file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
747            let origin = match line.origin() {
748                '+' | '-' | ' ' => line.origin(),
749                _ => ' ',
750            };
751            diff_string.push(origin);
752            diff_string.push_str(&String::from_utf8_lossy(line.content()));
753            true
754        })?;
755
756        if is_binary_diff(&diff_string) {
757            file.diff = "[Binary file changed]".to_string();
758        } else {
759            file.diff = diff_string;
760        }
761
762        // Get file content from to commit if it's a modified or added file
763        if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
764            && let Ok(entry) = to_tree.get_path(std::path::Path::new(&file.path))
765            && let Ok(object) = entry.to_object(repo)
766            && let Some(blob) = object.as_blob()
767            && let Ok(content) = std::str::from_utf8(blob.content())
768        {
769            file.content = Some(content.to_string());
770        }
771    }
772
773    log_debug!("Found {} files changed in commit range", range_files.len());
774    Ok(range_files)
775}
776
777/// Extract commit range info without crossing async boundaries
778pub fn extract_commit_range_info(
779    repo: &Repository,
780    from: &str,
781    to: &str,
782) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
783    // Get the range name for display
784    let display_range = format!("{from}..{to}");
785
786    // Get commits in the range
787    let recent_commits: Result<Vec<RecentCommit>> =
788        get_commits_between_with_callback(repo, from, to, |commit| Ok(commit.clone()));
789    let recent_commits = recent_commits?;
790
791    // Get file paths from the range for metadata
792    let range_files = get_commit_range_files(repo, from, to)?;
793    let file_paths: Vec<String> = range_files.iter().map(|file| file.path.clone()).collect();
794
795    Ok((display_range, recent_commits, file_paths))
796}