oyo_core/
multi.rs

1//! Multi-file diff support
2
3use crate::diff::DiffEngine;
4use crate::git::{ChangedFile, FileStatus};
5use crate::step::{DiffNavigator, StepDirection};
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9#[derive(Error, Debug)]
10pub enum MultiDiffError {
11    #[error("IO error: {0}")]
12    Io(#[from] std::io::Error),
13    #[error("Git error: {0}")]
14    Git(#[from] crate::git::GitError),
15}
16
17/// A file entry in a multi-file diff
18#[derive(Debug, Clone)]
19pub struct FileEntry {
20    pub path: PathBuf,
21    pub old_path: Option<PathBuf>,
22    pub display_name: String,
23    pub status: FileStatus,
24    pub insertions: usize,
25    pub deletions: usize,
26}
27
28/// Multi-file diff session
29pub struct MultiFileDiff {
30    /// All files being diffed
31    pub files: Vec<FileEntry>,
32    /// Currently selected file index
33    pub selected_index: usize,
34    /// Navigators for each file (lazy loaded)
35    navigators: Vec<Option<DiffNavigator>>,
36    /// Repository root (if in git mode)
37    #[allow(dead_code)]
38    repo_root: Option<PathBuf>,
39    /// Git diff mode (if in git mode)
40    git_mode: Option<GitDiffMode>,
41    /// Old contents for each file
42    old_contents: Vec<String>,
43    /// New contents for each file
44    new_contents: Vec<String>,
45}
46
47#[derive(Debug, Clone)]
48enum GitDiffMode {
49    Uncommitted,
50    Staged,
51    IndexRange { from: String, to_index: bool },
52    Range { from: String, to: String },
53}
54
55impl MultiFileDiff {
56    /// Create from a list of changed files (git mode)
57    pub fn from_git_changes(
58        repo_root: PathBuf,
59        changes: Vec<ChangedFile>,
60    ) -> Result<Self, MultiDiffError> {
61        let mut files = Vec::new();
62        let mut old_contents = Vec::new();
63        let mut new_contents = Vec::new();
64        let engine = DiffEngine::new().with_word_level(true);
65
66        for change in changes {
67            // Get old and new content
68            let old_content = match change.status {
69                FileStatus::Added | FileStatus::Untracked => String::new(),
70                _ => crate::git::get_head_content(&repo_root, &change.path).unwrap_or_default(),
71            };
72
73            let new_content = match change.status {
74                FileStatus::Deleted => String::new(),
75                _ => {
76                    let full_path = repo_root.join(&change.path);
77                    std::fs::read_to_string(&full_path).unwrap_or_default()
78                }
79            };
80
81            // Compute diff stats
82            let diff = engine.diff_strings(&old_content, &new_content);
83
84            files.push(FileEntry {
85                display_name: change.path.display().to_string(),
86                path: change.path,
87                old_path: change.old_path,
88                status: change.status,
89                insertions: diff.insertions,
90                deletions: diff.deletions,
91            });
92
93            old_contents.push(old_content);
94            new_contents.push(new_content);
95        }
96
97        let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
98
99        Ok(Self {
100            files,
101            selected_index: 0,
102            navigators,
103            repo_root: Some(repo_root),
104            git_mode: Some(GitDiffMode::Uncommitted),
105            old_contents,
106            new_contents,
107        })
108    }
109
110    /// Create from staged git changes (index vs HEAD)
111    pub fn from_git_staged(
112        repo_root: PathBuf,
113        changes: Vec<ChangedFile>,
114    ) -> Result<Self, MultiDiffError> {
115        let mut files = Vec::new();
116        let mut old_contents = Vec::new();
117        let mut new_contents = Vec::new();
118        let engine = DiffEngine::new().with_word_level(true);
119
120        for change in changes {
121            let old_path = change
122                .old_path
123                .clone()
124                .unwrap_or_else(|| change.path.clone());
125            let old_content = match change.status {
126                FileStatus::Added | FileStatus::Untracked => String::new(),
127                _ => crate::git::get_head_content(&repo_root, &old_path).unwrap_or_default(),
128            };
129
130            let new_content = match change.status {
131                FileStatus::Deleted => String::new(),
132                _ => crate::git::get_staged_content(&repo_root, &change.path).unwrap_or_default(),
133            };
134
135            let diff = engine.diff_strings(&old_content, &new_content);
136
137            files.push(FileEntry {
138                display_name: change.path.display().to_string(),
139                path: change.path,
140                old_path: change.old_path,
141                status: change.status,
142                insertions: diff.insertions,
143                deletions: diff.deletions,
144            });
145
146            old_contents.push(old_content);
147            new_contents.push(new_content);
148        }
149
150        let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
151
152        Ok(Self {
153            files,
154            selected_index: 0,
155            navigators,
156            repo_root: Some(repo_root),
157            git_mode: Some(GitDiffMode::Staged),
158            old_contents,
159            new_contents,
160        })
161    }
162
163    /// Create from a git range where one side is the staged index
164    pub fn from_git_index_range(
165        repo_root: PathBuf,
166        changes: Vec<ChangedFile>,
167        from: String,
168        to_index: bool,
169    ) -> Result<Self, MultiDiffError> {
170        let mut files = Vec::new();
171        let mut old_contents = Vec::new();
172        let mut new_contents = Vec::new();
173        let engine = DiffEngine::new().with_word_level(true);
174
175        for change in changes {
176            let old_path = change
177                .old_path
178                .clone()
179                .unwrap_or_else(|| change.path.clone());
180            let (old_content, new_content) = if to_index {
181                let old_content = match change.status {
182                    FileStatus::Added | FileStatus::Untracked => String::new(),
183                    _ => crate::git::get_file_at_commit(&repo_root, &from, &old_path)
184                        .unwrap_or_default(),
185                };
186                let new_content = match change.status {
187                    FileStatus::Deleted => String::new(),
188                    _ => {
189                        crate::git::get_staged_content(&repo_root, &change.path).unwrap_or_default()
190                    }
191                };
192                (old_content, new_content)
193            } else {
194                let old_content = match change.status {
195                    FileStatus::Added | FileStatus::Untracked => String::new(),
196                    _ => crate::git::get_staged_content(&repo_root, &old_path).unwrap_or_default(),
197                };
198                let new_content = match change.status {
199                    FileStatus::Deleted => String::new(),
200                    _ => crate::git::get_file_at_commit(&repo_root, &from, &change.path)
201                        .unwrap_or_default(),
202                };
203                (old_content, new_content)
204            };
205
206            let diff = engine.diff_strings(&old_content, &new_content);
207
208            files.push(FileEntry {
209                display_name: change.path.display().to_string(),
210                path: change.path,
211                old_path: change.old_path,
212                status: change.status,
213                insertions: diff.insertions,
214                deletions: diff.deletions,
215            });
216
217            old_contents.push(old_content);
218            new_contents.push(new_content);
219        }
220
221        let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
222
223        Ok(Self {
224            files,
225            selected_index: 0,
226            navigators,
227            repo_root: Some(repo_root),
228            git_mode: Some(GitDiffMode::IndexRange { from, to_index }),
229            old_contents,
230            new_contents,
231        })
232    }
233
234    /// Create from a git range (from..to)
235    pub fn from_git_range(
236        repo_root: PathBuf,
237        changes: Vec<ChangedFile>,
238        from: String,
239        to: String,
240    ) -> Result<Self, MultiDiffError> {
241        let mut files = Vec::new();
242        let mut old_contents = Vec::new();
243        let mut new_contents = Vec::new();
244        let engine = DiffEngine::new().with_word_level(true);
245
246        for change in changes {
247            let old_path = change
248                .old_path
249                .clone()
250                .unwrap_or_else(|| change.path.clone());
251            let old_content = match change.status {
252                FileStatus::Added | FileStatus::Untracked => String::new(),
253                _ => {
254                    crate::git::get_file_at_commit(&repo_root, &from, &old_path).unwrap_or_default()
255                }
256            };
257
258            let new_content = match change.status {
259                FileStatus::Deleted => String::new(),
260                _ => crate::git::get_file_at_commit(&repo_root, &to, &change.path)
261                    .unwrap_or_default(),
262            };
263
264            let diff = engine.diff_strings(&old_content, &new_content);
265
266            files.push(FileEntry {
267                display_name: change.path.display().to_string(),
268                path: change.path,
269                old_path: change.old_path,
270                status: change.status,
271                insertions: diff.insertions,
272                deletions: diff.deletions,
273            });
274
275            old_contents.push(old_content);
276            new_contents.push(new_content);
277        }
278
279        let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
280
281        Ok(Self {
282            files,
283            selected_index: 0,
284            navigators,
285            repo_root: Some(repo_root),
286            git_mode: Some(GitDiffMode::Range { from, to }),
287            old_contents,
288            new_contents,
289        })
290    }
291
292    /// Create from two directories
293    pub fn from_directories(old_dir: &Path, new_dir: &Path) -> Result<Self, MultiDiffError> {
294        let mut files = Vec::new();
295        let mut old_contents = Vec::new();
296        let mut new_contents = Vec::new();
297        let engine = DiffEngine::new().with_word_level(true);
298
299        // Collect all files from both directories
300        let mut all_files = std::collections::HashSet::new();
301
302        if old_dir.is_dir() {
303            collect_files(old_dir, old_dir, &mut all_files)?;
304        }
305        if new_dir.is_dir() {
306            collect_files(new_dir, new_dir, &mut all_files)?;
307        }
308
309        let mut all_files: Vec<_> = all_files.into_iter().collect();
310        all_files.sort();
311
312        for rel_path in all_files {
313            let old_path = old_dir.join(&rel_path);
314            let new_path = new_dir.join(&rel_path);
315
316            let old_exists = old_path.exists();
317            let new_exists = new_path.exists();
318
319            let status = if !old_exists {
320                FileStatus::Added
321            } else if !new_exists {
322                FileStatus::Deleted
323            } else {
324                FileStatus::Modified
325            };
326
327            let old_content = if old_exists {
328                std::fs::read_to_string(&old_path).unwrap_or_default()
329            } else {
330                String::new()
331            };
332
333            let new_content = if new_exists {
334                std::fs::read_to_string(&new_path).unwrap_or_default()
335            } else {
336                String::new()
337            };
338
339            // Skip if no changes
340            if old_content == new_content {
341                continue;
342            }
343
344            let diff = engine.diff_strings(&old_content, &new_content);
345
346            files.push(FileEntry {
347                display_name: rel_path.display().to_string(),
348                path: rel_path,
349                old_path: None,
350                status,
351                insertions: diff.insertions,
352                deletions: diff.deletions,
353            });
354
355            old_contents.push(old_content);
356            new_contents.push(new_content);
357        }
358
359        let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
360
361        Ok(Self {
362            files,
363            selected_index: 0,
364            navigators,
365            repo_root: None,
366            git_mode: None,
367            old_contents,
368            new_contents,
369        })
370    }
371
372    /// Create from a single file pair
373    pub fn from_file_pair(
374        _old_path: PathBuf,
375        new_path: PathBuf,
376        old_content: String,
377        new_content: String,
378    ) -> Self {
379        let engine = DiffEngine::new().with_word_level(true);
380        let diff = engine.diff_strings(&old_content, &new_content);
381
382        let files = vec![FileEntry {
383            display_name: new_path.display().to_string(),
384            path: new_path,
385            old_path: None,
386            status: FileStatus::Modified,
387            insertions: diff.insertions,
388            deletions: diff.deletions,
389        }];
390
391        Self {
392            files,
393            selected_index: 0,
394            navigators: vec![None],
395            repo_root: None,
396            git_mode: None,
397            old_contents: vec![old_content],
398            new_contents: vec![new_content],
399        }
400    }
401
402    /// Get the navigator for the currently selected file
403    pub fn current_navigator(&mut self) -> &mut DiffNavigator {
404        if self.navigators[self.selected_index].is_none() {
405            let engine = DiffEngine::new().with_word_level(true);
406            let diff = engine.diff_strings(
407                &self.old_contents[self.selected_index],
408                &self.new_contents[self.selected_index],
409            );
410            let navigator = DiffNavigator::new(
411                diff,
412                self.old_contents[self.selected_index].clone(),
413                self.new_contents[self.selected_index].clone(),
414            );
415            self.navigators[self.selected_index] = Some(navigator);
416        }
417        self.navigators[self.selected_index].as_mut().unwrap()
418    }
419
420    /// Get the current file entry
421    pub fn current_file(&self) -> Option<&FileEntry> {
422        self.files.get(self.selected_index)
423    }
424
425    /// Select next file
426    pub fn next_file(&mut self) -> bool {
427        if self.selected_index < self.files.len().saturating_sub(1) {
428            self.selected_index += 1;
429            true
430        } else {
431            false
432        }
433    }
434
435    /// Select previous file
436    pub fn prev_file(&mut self) -> bool {
437        if self.selected_index > 0 {
438            self.selected_index -= 1;
439            true
440        } else {
441            false
442        }
443    }
444
445    /// Select file by index
446    pub fn select_file(&mut self, index: usize) {
447        if index < self.files.len() {
448            self.selected_index = index;
449        }
450    }
451
452    /// Total number of files
453    pub fn file_count(&self) -> usize {
454        self.files.len()
455    }
456
457    /// Repository root path (git mode only)
458    pub fn repo_root(&self) -> Option<&Path> {
459        self.repo_root.as_deref()
460    }
461
462    /// True if this diff was created from git changes
463    pub fn is_git_mode(&self) -> bool {
464        self.repo_root.is_some()
465    }
466
467    /// Return a display-friendly git range for header usage (if applicable).
468    pub fn git_range_display(&self) -> Option<(String, String)> {
469        let mode = self.git_mode.as_ref()?;
470        match mode {
471            GitDiffMode::Range { from, to } => Some((format_ref(from), format_ref(to))),
472            GitDiffMode::IndexRange { from, to_index } => {
473                let staged = "STAGED".to_string();
474                if *to_index {
475                    Some((format_ref(from), staged))
476                } else {
477                    Some((staged, format_ref(from)))
478                }
479            }
480            _ => None,
481        }
482    }
483
484    /// Get the step direction of current navigator (if loaded)
485    pub fn current_step_direction(&self) -> StepDirection {
486        if let Some(Some(nav)) = self.navigators.get(self.selected_index) {
487            nav.state().step_direction
488        } else {
489            StepDirection::None
490        }
491    }
492
493    /// Check if we have multiple files
494    pub fn is_multi_file(&self) -> bool {
495        self.files.len() > 1
496    }
497
498    /// Get total stats across all files
499    pub fn total_stats(&self) -> (usize, usize) {
500        self.files.iter().fold((0, 0), |(ins, del), f| {
501            (ins + f.insertions, del + f.deletions)
502        })
503    }
504
505    /// Check if current file's old content is empty
506    pub fn current_old_is_empty(&self) -> bool {
507        self.old_contents
508            .get(self.selected_index)
509            .map(|s| s.is_empty())
510            .unwrap_or(true)
511    }
512
513    /// Check if current file's new content is empty
514    pub fn current_new_is_empty(&self) -> bool {
515        self.new_contents
516            .get(self.selected_index)
517            .map(|s| s.is_empty())
518            .unwrap_or(true)
519    }
520
521    /// Refresh all files from git (re-scan for uncommitted changes)
522    /// Returns true if successful, false if not in git mode
523    pub fn refresh_all_from_git(&mut self) -> bool {
524        let repo_root = match &self.repo_root {
525            Some(root) => root.clone(),
526            None => return false,
527        };
528        let mode = match &self.git_mode {
529            Some(mode) => mode.clone(),
530            None => return false,
531        };
532
533        // Get fresh list of changes
534        let changes = match mode {
535            GitDiffMode::Uncommitted => crate::git::get_uncommitted_changes(&repo_root),
536            GitDiffMode::Staged => crate::git::get_staged_changes(&repo_root),
537            GitDiffMode::Range { ref from, ref to } => {
538                crate::git::get_changes_between(&repo_root, from, to)
539            }
540            GitDiffMode::IndexRange { ref from, to_index } => {
541                crate::git::get_changes_between_index(&repo_root, from, !to_index)
542            }
543        };
544        let changes = match changes {
545            Ok(c) => c,
546            Err(_) => return false,
547        };
548
549        // Rebuild the entire diff state
550        let mut files = Vec::new();
551        let mut old_contents = Vec::new();
552        let mut new_contents = Vec::new();
553        let engine = DiffEngine::new().with_word_level(true);
554
555        for change in changes {
556            let old_path = change
557                .old_path
558                .clone()
559                .unwrap_or_else(|| change.path.clone());
560            let (old_content, new_content) =
561                match mode {
562                    GitDiffMode::Uncommitted => {
563                        let old_content = match change.status {
564                            FileStatus::Added | FileStatus::Untracked => String::new(),
565                            _ => crate::git::get_head_content(&repo_root, &old_path)
566                                .unwrap_or_default(),
567                        };
568                        let new_content = match change.status {
569                            FileStatus::Deleted => String::new(),
570                            _ => {
571                                let full_path = repo_root.join(&change.path);
572                                std::fs::read_to_string(&full_path).unwrap_or_default()
573                            }
574                        };
575                        (old_content, new_content)
576                    }
577                    GitDiffMode::Staged => {
578                        let old_content = match change.status {
579                            FileStatus::Added | FileStatus::Untracked => String::new(),
580                            _ => crate::git::get_head_content(&repo_root, &old_path)
581                                .unwrap_or_default(),
582                        };
583                        let new_content = match change.status {
584                            FileStatus::Deleted => String::new(),
585                            _ => crate::git::get_staged_content(&repo_root, &change.path)
586                                .unwrap_or_default(),
587                        };
588                        (old_content, new_content)
589                    }
590                    GitDiffMode::Range { ref from, ref to } => {
591                        let old_content = match change.status {
592                            FileStatus::Added | FileStatus::Untracked => String::new(),
593                            _ => crate::git::get_file_at_commit(&repo_root, from, &old_path)
594                                .unwrap_or_default(),
595                        };
596                        let new_content = match change.status {
597                            FileStatus::Deleted => String::new(),
598                            _ => crate::git::get_file_at_commit(&repo_root, to, &change.path)
599                                .unwrap_or_default(),
600                        };
601                        (old_content, new_content)
602                    }
603                    GitDiffMode::IndexRange { ref from, to_index } => {
604                        if to_index {
605                            let old_content = match change.status {
606                                FileStatus::Added | FileStatus::Untracked => String::new(),
607                                _ => crate::git::get_file_at_commit(&repo_root, from, &old_path)
608                                    .unwrap_or_default(),
609                            };
610                            let new_content = match change.status {
611                                FileStatus::Deleted => String::new(),
612                                _ => crate::git::get_staged_content(&repo_root, &change.path)
613                                    .unwrap_or_default(),
614                            };
615                            (old_content, new_content)
616                        } else {
617                            let old_content = match change.status {
618                                FileStatus::Added | FileStatus::Untracked => String::new(),
619                                _ => crate::git::get_staged_content(&repo_root, &old_path)
620                                    .unwrap_or_default(),
621                            };
622                            let new_content = match change.status {
623                                FileStatus::Deleted => String::new(),
624                                _ => crate::git::get_file_at_commit(&repo_root, from, &change.path)
625                                    .unwrap_or_default(),
626                            };
627                            (old_content, new_content)
628                        }
629                    }
630                };
631
632            let diff = engine.diff_strings(&old_content, &new_content);
633
634            files.push(FileEntry {
635                display_name: change.path.display().to_string(),
636                path: change.path,
637                old_path: change.old_path,
638                status: change.status,
639                insertions: diff.insertions,
640                deletions: diff.deletions,
641            });
642
643            old_contents.push(old_content);
644            new_contents.push(new_content);
645        }
646
647        // Update state
648        let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
649        self.files = files;
650        self.old_contents = old_contents;
651        self.new_contents = new_contents;
652        self.navigators = navigators;
653
654        // Clamp selected index to valid range
655        if self.selected_index >= self.files.len() {
656            self.selected_index = self.files.len().saturating_sub(1);
657        }
658
659        true
660    }
661
662    /// Refresh the current file from disk (re-read and re-diff)
663    pub fn refresh_current_file(&mut self) {
664        let idx = self.selected_index;
665        let file = &self.files[idx];
666        let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone());
667
668        // Get fresh content based on mode
669        let (old_content, new_content) = match (&self.repo_root, &self.git_mode) {
670            (Some(repo_root), Some(GitDiffMode::Uncommitted)) => {
671                let old_content = match file.status {
672                    FileStatus::Added | FileStatus::Untracked => String::new(),
673                    _ => crate::git::get_head_content(repo_root, &old_path).unwrap_or_default(),
674                };
675                let new_content = match file.status {
676                    FileStatus::Deleted => String::new(),
677                    _ => {
678                        let full_path = repo_root.join(&file.path);
679                        std::fs::read_to_string(&full_path).unwrap_or_default()
680                    }
681                };
682                (old_content, new_content)
683            }
684            (Some(repo_root), Some(GitDiffMode::Staged)) => {
685                let old_content = match file.status {
686                    FileStatus::Added | FileStatus::Untracked => String::new(),
687                    _ => crate::git::get_head_content(repo_root, &old_path).unwrap_or_default(),
688                };
689                let new_content = match file.status {
690                    FileStatus::Deleted => String::new(),
691                    _ => crate::git::get_staged_content(repo_root, &file.path).unwrap_or_default(),
692                };
693                (old_content, new_content)
694            }
695            (Some(repo_root), Some(GitDiffMode::Range { from, to })) => {
696                let old_content = match file.status {
697                    FileStatus::Added | FileStatus::Untracked => String::new(),
698                    _ => crate::git::get_file_at_commit(repo_root, from, &old_path)
699                        .unwrap_or_default(),
700                };
701                let new_content = match file.status {
702                    FileStatus::Deleted => String::new(),
703                    _ => crate::git::get_file_at_commit(repo_root, to, &file.path)
704                        .unwrap_or_default(),
705                };
706                (old_content, new_content)
707            }
708            (Some(repo_root), Some(GitDiffMode::IndexRange { from, to_index })) => {
709                if *to_index {
710                    let old_content = match file.status {
711                        FileStatus::Added | FileStatus::Untracked => String::new(),
712                        _ => crate::git::get_file_at_commit(repo_root, from, &old_path)
713                            .unwrap_or_default(),
714                    };
715                    let new_content = match file.status {
716                        FileStatus::Deleted => String::new(),
717                        _ => crate::git::get_staged_content(repo_root, &file.path)
718                            .unwrap_or_default(),
719                    };
720                    (old_content, new_content)
721                } else {
722                    let old_content = match file.status {
723                        FileStatus::Added | FileStatus::Untracked => String::new(),
724                        _ => {
725                            crate::git::get_staged_content(repo_root, &old_path).unwrap_or_default()
726                        }
727                    };
728                    let new_content = match file.status {
729                        FileStatus::Deleted => String::new(),
730                        _ => crate::git::get_file_at_commit(repo_root, from, &file.path)
731                            .unwrap_or_default(),
732                    };
733                    (old_content, new_content)
734                }
735            }
736            _ => {
737                let new_content = std::fs::read_to_string(&file.path).unwrap_or_default();
738                (self.old_contents[idx].clone(), new_content)
739            }
740        };
741
742        // Update stored content
743        self.old_contents[idx] = old_content;
744        self.new_contents[idx] = new_content;
745
746        // Recompute diff stats
747        let engine = DiffEngine::new().with_word_level(true);
748        let diff = engine.diff_strings(&self.old_contents[idx], &self.new_contents[idx]);
749
750        // Update file entry stats
751        self.files[idx].insertions = diff.insertions;
752        self.files[idx].deletions = diff.deletions;
753
754        // Clear the navigator so it gets rebuilt on next access
755        self.navigators[idx] = None;
756    }
757}
758
759fn collect_files(
760    dir: &Path,
761    base: &Path,
762    files: &mut std::collections::HashSet<PathBuf>,
763) -> Result<(), std::io::Error> {
764    for entry in std::fs::read_dir(dir)? {
765        let entry = entry?;
766        let path = entry.path();
767
768        // Skip hidden files and common ignore patterns
769        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
770            if name.starts_with('.') || name == "node_modules" || name == "target" {
771                continue;
772            }
773        }
774
775        if path.is_dir() {
776            collect_files(&path, base, files)?;
777        } else if path.is_file() {
778            if let Ok(rel) = path.strip_prefix(base) {
779                files.insert(rel.to_path_buf());
780            }
781        }
782    }
783    Ok(())
784}
785
786fn format_ref(reference: &str) -> String {
787    match reference {
788        "HEAD" => "HEAD".to_string(),
789        "INDEX" => "STAGED".to_string(),
790        _ => shorten_hash(reference),
791    }
792}
793
794fn shorten_hash(hash: &str) -> String {
795    hash.chars().take(7).collect()
796}