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
483    // Get statistics for each file and convert to our StagedFile format
484    diff.foreach(
485        &mut |delta, _| {
486            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
487                let change_type = match delta.status() {
488                    git2::Delta::Added => ChangeType::Added,
489                    git2::Delta::Modified => ChangeType::Modified,
490                    git2::Delta::Deleted => ChangeType::Deleted,
491                    _ => return true, // Skip other types of changes
492                };
493
494                let should_exclude = should_exclude_file(path);
495
496                branch_files.push(StagedFile {
497                    path: path.to_string(),
498                    change_type,
499                    diff: String::new(), // Will be populated later
500                    content: None,
501                    content_excluded: should_exclude,
502                });
503            }
504            true
505        },
506        None,
507        None,
508        None,
509    )?;
510
511    // Get the diff for each file
512    for file in &mut branch_files {
513        if file.content_excluded {
514            file.diff = String::from("[Content excluded]");
515            continue;
516        }
517
518        let mut diff_options = git2::DiffOptions::new();
519        diff_options.pathspec(&file.path);
520
521        let file_diff = repo.diff_tree_to_tree(
522            Some(&base_tree),
523            Some(&target_tree),
524            Some(&mut diff_options),
525        )?;
526
527        let mut diff_string = String::new();
528        file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
529            let origin = match line.origin() {
530                '+' | '-' | ' ' => line.origin(),
531                _ => ' ',
532            };
533            diff_string.push(origin);
534            diff_string.push_str(&String::from_utf8_lossy(line.content()));
535            true
536        })?;
537
538        if is_binary_diff(&diff_string) {
539            file.diff = "[Binary file changed]".to_string();
540        } else {
541            file.diff = diff_string;
542        }
543
544        // Get file content from target branch if it's a modified or added file
545        if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
546            && let Ok(entry) = target_tree.get_path(std::path::Path::new(&file.path))
547            && let Ok(object) = entry.to_object(repo)
548            && let Some(blob) = object.as_blob()
549            && let Ok(content) = std::str::from_utf8(blob.content())
550        {
551            file.content = Some(content.to_string());
552        }
553    }
554
555    log_debug!(
556        "Found {} files changed between branches (using merge-base)",
557        branch_files.len()
558    );
559    Ok(branch_files)
560}
561
562/// Extract branch comparison info without crossing async boundaries
563pub fn extract_branch_diff_info(
564    repo: &Repository,
565    base_branch: &str,
566    target_branch: &str,
567) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
568    // Get the target branch name for display
569    let display_branch = format!("{base_branch} -> {target_branch}");
570
571    // Get commits between the branches using merge-base
572    let base_commit = repo.revparse_single(base_branch)?.peel_to_commit()?;
573    let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
574
575    // Find the merge-base (common ancestor) between the branches
576    let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
577    log_debug!("Using merge-base {} for commit history", merge_base_oid);
578
579    let mut revwalk = repo.revwalk()?;
580    revwalk.push(target_commit.id())?;
581    revwalk.hide(merge_base_oid)?; // Hide the merge-base commit itself
582
583    let recent_commits: Result<Vec<RecentCommit>> = revwalk
584        .take(10) // Limit to 10 most recent commits in the branch
585        .map(|oid| {
586            let oid = oid?;
587            let commit = repo.find_commit(oid)?;
588            let author = commit.author();
589            Ok(RecentCommit {
590                hash: oid.to_string(),
591                message: commit.message().unwrap_or_default().to_string(),
592                author: author.name().unwrap_or_default().to_string(),
593                timestamp: commit.time().seconds().to_string(),
594            })
595        })
596        .collect();
597
598    let recent_commits = recent_commits?;
599
600    // Get file paths from the diff for metadata
601    let diff_files = get_branch_diff_files(repo, base_branch, target_branch)?;
602    let file_paths: Vec<String> = diff_files.iter().map(|file| file.path.clone()).collect();
603
604    Ok((display_branch, recent_commits, file_paths))
605}
606
607/// Gets commits between two references with their messages for PR descriptions
608///
609/// # Arguments
610///
611/// * `repo` - The git repository
612/// * `from` - The starting Git reference (exclusive)
613/// * `to` - The ending Git reference (inclusive)
614///
615/// # Returns
616///
617/// A Result containing a Vec of formatted commit messages or an error.
618pub fn get_commits_for_pr(repo: &Repository, from: &str, to: &str) -> Result<Vec<String>> {
619    log_debug!("Getting commits for PR between {} and {}", from, to);
620
621    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
622    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
623
624    let mut revwalk = repo.revwalk()?;
625    revwalk.push(to_commit.id())?;
626    revwalk.hide(from_commit.id())?;
627
628    let commits: Result<Vec<String>> = revwalk
629        .map(|oid| {
630            let oid = oid?;
631            let commit = repo.find_commit(oid)?;
632            let message = commit.message().unwrap_or_default();
633            // Get just the first line (title) of the commit message
634            let title = message.lines().next().unwrap_or_default();
635            Ok(format!("{}: {}", &oid.to_string()[..7], title))
636        })
637        .collect();
638
639    let mut result = commits?;
640    result.reverse(); // Show commits in chronological order
641
642    log_debug!("Found {} commits for PR", result.len());
643    Ok(result)
644}
645
646/// Gets the files changed in a commit range (similar to branch diff but for commit range)
647///
648/// # Arguments
649///
650/// * `repo` - The git repository
651/// * `from` - The starting Git reference (exclusive)
652/// * `to` - The ending Git reference (inclusive)
653///
654/// # Returns
655///
656/// A Result containing a Vec of `StagedFile` objects for the commit range or an error.
657pub fn get_commit_range_files(repo: &Repository, from: &str, to: &str) -> Result<Vec<StagedFile>> {
658    log_debug!("Getting files changed in commit range: {} -> {}", from, to);
659
660    // Resolve commit references
661    let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
662    let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
663
664    let from_tree = from_commit.tree()?;
665    let to_tree = to_commit.tree()?;
666
667    let mut range_files = Vec::new();
668
669    // Create diff between the from and to trees
670    let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
671
672    // Get statistics for each file and convert to our StagedFile format
673    diff.foreach(
674        &mut |delta, _| {
675            if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
676                let change_type = match delta.status() {
677                    git2::Delta::Added => ChangeType::Added,
678                    git2::Delta::Modified => ChangeType::Modified,
679                    git2::Delta::Deleted => ChangeType::Deleted,
680                    _ => return true, // Skip other types of changes
681                };
682
683                let should_exclude = should_exclude_file(path);
684
685                range_files.push(StagedFile {
686                    path: path.to_string(),
687                    change_type,
688                    diff: String::new(), // Will be populated later
689                    content: None,
690                    content_excluded: should_exclude,
691                });
692            }
693            true
694        },
695        None,
696        None,
697        None,
698    )?;
699
700    // Get the diff for each file
701    for file in &mut range_files {
702        if file.content_excluded {
703            file.diff = String::from("[Content excluded]");
704            continue;
705        }
706
707        let mut diff_options = git2::DiffOptions::new();
708        diff_options.pathspec(&file.path);
709
710        let file_diff =
711            repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_options))?;
712
713        let mut diff_string = String::new();
714        file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
715            let origin = match line.origin() {
716                '+' | '-' | ' ' => line.origin(),
717                _ => ' ',
718            };
719            diff_string.push(origin);
720            diff_string.push_str(&String::from_utf8_lossy(line.content()));
721            true
722        })?;
723
724        if is_binary_diff(&diff_string) {
725            file.diff = "[Binary file changed]".to_string();
726        } else {
727            file.diff = diff_string;
728        }
729
730        // Get file content from to commit if it's a modified or added file
731        if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
732            && let Ok(entry) = to_tree.get_path(std::path::Path::new(&file.path))
733            && let Ok(object) = entry.to_object(repo)
734            && let Some(blob) = object.as_blob()
735            && let Ok(content) = std::str::from_utf8(blob.content())
736        {
737            file.content = Some(content.to_string());
738        }
739    }
740
741    log_debug!("Found {} files changed in commit range", range_files.len());
742    Ok(range_files)
743}
744
745/// Extract commit range info without crossing async boundaries
746pub fn extract_commit_range_info(
747    repo: &Repository,
748    from: &str,
749    to: &str,
750) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
751    // Get the range name for display
752    let display_range = format!("{from}..{to}");
753
754    // Get commits in the range
755    let recent_commits: Result<Vec<RecentCommit>> =
756        get_commits_between_with_callback(repo, from, to, |commit| Ok(commit.clone()));
757    let recent_commits = recent_commits?;
758
759    // Get file paths from the range for metadata
760    let range_files = get_commit_range_files(repo, from, to)?;
761    let file_paths: Vec<String> = range_files.iter().map(|file| file.path.clone()).collect();
762
763    Ok((display_range, recent_commits, file_paths))
764}