Skip to main content

git_iris/git/
files.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};
5use git2::{DiffOptions, Repository, StatusOptions};
6use std::fs;
7use std::path::Path;
8
9/// Collects repository information about files and branches
10#[derive(Debug)]
11pub struct RepoFilesInfo {
12    pub branch: String,
13    pub recent_commits: Vec<RecentCommit>,
14    pub staged_files: Vec<StagedFile>,
15    pub file_paths: Vec<String>,
16}
17
18/// Retrieves the status of files in the repository.
19///
20/// # Returns
21///
22/// A Result containing a Vec of `StagedFile` objects or an error.
23pub fn get_file_statuses(repo: &Repository) -> Result<Vec<StagedFile>> {
24    log_debug!("Getting file statuses");
25    let mut staged_files = Vec::new();
26
27    let mut opts = StatusOptions::new();
28    opts.include_untracked(true);
29    let statuses = repo.statuses(Some(&mut opts))?;
30
31    for entry in statuses.iter() {
32        let path = entry.path().context("Could not get path")?;
33        let status = entry.status();
34
35        if status.is_index_new() || status.is_index_modified() || status.is_index_deleted() {
36            let change_type = if status.is_index_new() {
37                ChangeType::Added
38            } else if status.is_index_modified() {
39                ChangeType::Modified
40            } else {
41                ChangeType::Deleted
42            };
43
44            let should_exclude = should_exclude_file(path);
45            let diff = if should_exclude {
46                String::from("[Content excluded]")
47            } else {
48                get_diff_for_file(repo, path)?
49            };
50
51            let content =
52                if should_exclude || change_type != ChangeType::Modified || is_binary_diff(&diff) {
53                    None
54                } else {
55                    get_index_content_for_file(repo, path)?
56                };
57
58            staged_files.push(StagedFile {
59                path: path.to_string(),
60                change_type,
61                diff,
62                content,
63                content_excluded: should_exclude,
64            });
65        }
66    }
67
68    log_debug!("Found {} staged files", staged_files.len());
69    Ok(staged_files)
70}
71
72/// Retrieves the diff for a specific file.
73///
74/// # Arguments
75///
76/// * `repo` - The git repository
77/// * `path` - The path of the file to get the diff for.
78///
79/// # Returns
80///
81/// A Result containing the diff as a String or an error.
82pub fn get_diff_for_file(repo: &Repository, path: &str) -> Result<String> {
83    log_debug!("Getting diff for file: {}", path);
84    let mut diff_options = DiffOptions::new();
85    diff_options.pathspec(path);
86
87    let tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
88    let index = repo.index()?;
89    let diff = repo.diff_tree_to_index(tree.as_ref(), Some(&index), Some(&mut diff_options))?;
90
91    let mut diff_string = String::new();
92    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
93        let origin = match line.origin() {
94            '+' | '-' | ' ' => line.origin(),
95            _ => ' ',
96        };
97        diff_string.push(origin);
98        diff_string.push_str(&String::from_utf8_lossy(line.content()));
99        true
100    })?;
101
102    if is_binary_diff(&diff_string) {
103        Ok("[Binary file changed]".to_string())
104    } else {
105        log_debug!("Generated diff for {} ({} bytes)", path, diff_string.len());
106        Ok(diff_string)
107    }
108}
109
110fn get_index_content_for_file(repo: &Repository, path: &str) -> Result<Option<String>> {
111    let index = repo.index()?;
112    let Some(entry) = index.get_path(Path::new(path), 0) else {
113        return Ok(None);
114    };
115
116    let blob = repo.find_blob(entry.id)?;
117    match std::str::from_utf8(blob.content()) {
118        Ok(content) => Ok(Some(content.to_string())),
119        Err(_) => Ok(None),
120    }
121}
122
123/// Gets unstaged file changes from the repository
124///
125/// # Returns
126///
127/// A Result containing a Vec of `StagedFile` objects for unstaged changes or an error.
128pub fn get_unstaged_file_statuses(repo: &Repository) -> Result<Vec<StagedFile>> {
129    log_debug!("Getting unstaged file statuses");
130    let mut unstaged_files = Vec::new();
131
132    let mut opts = StatusOptions::new();
133    opts.include_untracked(true);
134    let statuses = repo.statuses(Some(&mut opts))?;
135
136    for entry in statuses.iter() {
137        let path = entry.path().context("Could not get path")?;
138        let status = entry.status();
139
140        // Look for changes in the working directory (unstaged)
141        if status.is_wt_new() || status.is_wt_modified() || status.is_wt_deleted() {
142            let change_type = if status.is_wt_new() {
143                ChangeType::Added
144            } else if status.is_wt_modified() {
145                ChangeType::Modified
146            } else {
147                ChangeType::Deleted
148            };
149
150            let should_exclude = should_exclude_file(path);
151            let diff = if should_exclude {
152                String::from("[Content excluded]")
153            } else {
154                get_diff_for_unstaged_file(repo, path)?
155            };
156
157            let content =
158                if should_exclude || change_type != ChangeType::Modified || is_binary_diff(&diff) {
159                    None
160                } else {
161                    let path_obj = Path::new(path);
162                    if path_obj.exists() {
163                        Some(fs::read_to_string(path_obj)?)
164                    } else {
165                        None
166                    }
167                };
168
169            unstaged_files.push(StagedFile {
170                path: path.to_string(),
171                change_type,
172                diff,
173                content,
174                content_excluded: should_exclude,
175            });
176        }
177    }
178
179    log_debug!("Found {} unstaged files", unstaged_files.len());
180    Ok(unstaged_files)
181}
182
183/// Gets the diff for an unstaged file
184///
185/// # Arguments
186///
187/// * `repo` - The git repository
188/// * `path` - The path of the file to get the diff for.
189///
190/// # Returns
191///
192/// A Result containing the diff as a String or an error.
193pub fn get_diff_for_unstaged_file(repo: &Repository, path: &str) -> Result<String> {
194    log_debug!("Getting unstaged diff for file: {}", path);
195    let mut diff_options = DiffOptions::new();
196    diff_options.pathspec(path);
197
198    // For unstaged changes, we compare the index (staged) to the working directory
199    let diff = repo.diff_index_to_workdir(None, Some(&mut diff_options))?;
200
201    let mut diff_string = String::new();
202    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
203        let origin = match line.origin() {
204            '+' | '-' | ' ' => line.origin(),
205            _ => ' ',
206        };
207        diff_string.push(origin);
208        diff_string.push_str(&String::from_utf8_lossy(line.content()));
209        true
210    })?;
211
212    if is_binary_diff(&diff_string) {
213        Ok("[Binary file changed]".to_string())
214    } else {
215        log_debug!(
216            "Generated unstaged diff for {} ({} bytes)",
217            path,
218            diff_string.len()
219        );
220        Ok(diff_string)
221    }
222}
223
224/// Gets only untracked files from the repository (new files not in the index)
225///
226/// # Returns
227///
228/// A Result containing a Vec of file paths for untracked files or an error.
229pub fn get_untracked_files(repo: &Repository) -> Result<Vec<String>> {
230    log_debug!("Getting untracked files");
231    let mut untracked = Vec::new();
232
233    let mut opts = StatusOptions::new();
234    opts.include_untracked(true);
235    opts.exclude_submodules(true);
236    let statuses = repo.statuses(Some(&mut opts))?;
237
238    for entry in statuses.iter() {
239        let status = entry.status();
240        // Only include files that are untracked (not in index, not ignored)
241        if status.is_wt_new()
242            && !status.is_index_new()
243            && let Some(path) = entry.path()
244        {
245            untracked.push(path.to_string());
246        }
247    }
248
249    log_debug!("Found {} untracked files", untracked.len());
250    Ok(untracked)
251}
252
253/// Gets all tracked files in the repository (from HEAD tree + index)
254///
255/// This returns all files that are tracked by git, which includes:
256/// - Files committed in HEAD
257/// - Files staged in the index (including newly added files)
258///
259/// # Returns
260///
261/// A Result containing a Vec of file paths or an error.
262pub fn get_all_tracked_files(repo: &Repository) -> Result<Vec<String>> {
263    log_debug!("Getting all tracked files");
264    let mut files = std::collections::HashSet::new();
265
266    // Get files from HEAD tree
267    if let Ok(head) = repo.head()
268        && let Ok(tree) = head.peel_to_tree()
269    {
270        tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
271            if entry.kind() == Some(git2::ObjectType::Blob) {
272                let path = if dir.is_empty() {
273                    entry.name().unwrap_or("").to_string()
274                } else {
275                    format!("{}{}", dir, entry.name().unwrap_or(""))
276                };
277                if !path.is_empty() {
278                    files.insert(path);
279                }
280            }
281            git2::TreeWalkResult::Ok
282        })?;
283    }
284
285    // Also include files from the index (staged files, including new files)
286    let index = repo.index()?;
287    for entry in index.iter() {
288        let path = String::from_utf8_lossy(&entry.path).to_string();
289        files.insert(path);
290    }
291
292    let mut result: Vec<_> = files.into_iter().collect();
293    result.sort();
294
295    log_debug!("Found {} tracked files", result.len());
296    Ok(result)
297}
298
299/// Gets the number of commits ahead and behind the upstream tracking branch
300///
301/// # Returns
302///
303/// A tuple of (ahead, behind) counts, or (0, 0) if no upstream
304pub fn get_ahead_behind(repo: &Repository) -> (usize, usize) {
305    log_debug!("Getting ahead/behind counts");
306
307    // Get the current branch
308    let Ok(head) = repo.head() else {
309        return (0, 0); // No HEAD
310    };
311
312    let Some(branch_name) = head.shorthand() else {
313        return (0, 0);
314    };
315
316    // Try to find the upstream branch
317    let Ok(branch) = repo.find_branch(branch_name, git2::BranchType::Local) else {
318        return (0, 0);
319    };
320
321    let Ok(upstream) = branch.upstream() else {
322        return (0, 0); // No upstream configured
323    };
324
325    // Get the OIDs for local and upstream
326    let Some(local_oid) = head.target() else {
327        return (0, 0);
328    };
329
330    let Some(upstream_oid) = upstream.get().target() else {
331        return (0, 0);
332    };
333
334    // Calculate ahead/behind
335    match repo.graph_ahead_behind(local_oid, upstream_oid) {
336        Ok((ahead, behind)) => {
337            log_debug!("Branch is {} ahead, {} behind upstream", ahead, behind);
338            (ahead, behind)
339        }
340        Err(_) => (0, 0),
341    }
342}