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