git_quick_add/git/
status.rs

1use crate::models::path_items::PathItems;
2use git2::{Repository, Status};
3use std::process;
4
5/// Gets the file paths of the changes in your repo.
6pub fn get_paths(repo: &Repository) -> Result<Vec<PathItems>, git2::Error> {
7    let statuses = repo.statuses(None)?;
8
9    if statuses.is_empty() {
10        println!("{}", console::style("✔ working tree clean ✔").green());
11        return Ok(vec![]);
12    }
13
14    let mut items: Vec<PathItems> = vec![];
15
16    for diff_entry in statuses.iter() {
17        if diff_entry.status() == Status::IGNORED {
18            continue;
19        }
20
21        let path_items = diff_entry
22            // 1. Try to get the HEAD → index diff
23            .head_to_index()
24            // If the file differs between HEAD and Index, grab the new file path. (This means the file has been staged.)
25            .and_then(|d| {
26                Some(PathItems {
27                    path: String::from(d.new_file().path()?.display().to_string()),
28                    is_staged: true,
29                    is_selected: false,
30                })
31            })
32            // 2. Otherwise, try index → workdir diff (This means the file has unstaged changes.)
33            .or_else(|| {
34                Option::from(
35                    diff_entry
36                        .index_to_workdir()
37                        .and_then(|d| {
38                            Some(PathItems {
39                                path: String::from(d.new_file().path()?.display().to_string()),
40                                is_staged: false,
41                                is_selected: false,
42                            })
43                        })
44                        // 3. If still nothing, try the "old" file's path (maybe a deletion/rename)
45                        // If the file is gone in workdir (deleted) or renamed, take the old file path
46                        .or_else(|| {
47                            diff_entry.index_to_workdir().and_then(|d| {
48                                Some(PathItems {
49                                    path: String::from(d.old_file().path()?.display().to_string()),
50                                    is_staged: false,
51                                    is_selected: false,
52                                })
53                            })
54                        })
55                        // 4. If nothing worked, fallback to "<unknown>"
56                        .unwrap_or_else(|| PathItems {
57                            path: String::from("<unknown>"),
58                            is_staged: false,
59                            is_selected: false,
60                        }),
61                )
62            })
63            .unwrap();
64
65        items.push(path_items);
66    }
67
68    // If the only changes are ignored files, exit
69    if items.is_empty() {
70        println!("{}", console::style("✔ working tree clean ✔").green());
71        process::exit(1)
72    }
73
74    Ok(items)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use git2::{Oid, Repository, Signature};
81    use std::fs::File;
82    use std::io::Write;
83    use std::path::Path;
84    use tempfile::TempDir;
85
86    /// Helper to initialize a new git repository in a temp dir
87    fn init_repo() -> (TempDir, Repository) {
88        let tmp_dir = TempDir::new().expect("create temp dir");
89        let repo = Repository::init(tmp_dir.path()).expect("init repo");
90        (tmp_dir, repo)
91    }
92
93    /// Helper to commit a file to the repo
94    fn commit_file(repo: &Repository, file_path: &str, content: &str, message: &str) -> Oid {
95        let mut file = File::create(repo.workdir().unwrap().join(file_path)).unwrap();
96        file.write_all(content.as_bytes()).unwrap();
97
98        let mut index = repo.index().unwrap();
99        index.add_path(Path::new(file_path)).unwrap();
100        let oid = index.write_tree().unwrap();
101
102        let sig = Signature::now("Test", "test@example.com").unwrap();
103        let tree = repo.find_tree(oid).unwrap();
104
105        let parent_commit = repo
106            .head()
107            .ok()
108            .and_then(|h| h.target())
109            .and_then(|oid| repo.find_commit(oid).ok());
110
111        let commit_oid = if let Some(parent) = parent_commit {
112            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
113                .unwrap()
114        } else {
115            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
116                .unwrap()
117        };
118        commit_oid
119    }
120
121    #[test]
122    fn test_get_paths_empty_worktree() {
123        let (_tmp, repo) = init_repo();
124
125        // No files, clean worktree
126        let statuses = get_paths(&repo).unwrap();
127        assert!(statuses.is_empty());
128    }
129
130    #[test]
131    fn test_get_paths_unstaged_file() {
132        let (_tmp, repo) = init_repo();
133
134        // Create a file but do not stage it
135        let file_path = "foo.txt";
136        let file_full_path = repo.workdir().unwrap().join(file_path);
137        let mut file = File::create(&file_full_path).unwrap();
138        writeln!(file, "hello world").unwrap();
139
140        // Now, get_paths should return one PathItems with is_staged == false
141        let paths = get_paths(&repo).unwrap();
142        assert_eq!(paths.len(), 1);
143        let item = &paths[0];
144        assert_eq!(item.path, file_path);
145        assert!(!item.is_staged);
146        assert!(!item.is_selected);
147    }
148
149    #[test]
150    fn test_get_paths_staged_file() {
151        let (_tmp, repo) = init_repo();
152
153        // Create and stage a file
154        let file_path = "bar.txt";
155        let file_full_path = repo.workdir().unwrap().join(file_path);
156        let mut file = File::create(&file_full_path).unwrap();
157        writeln!(file, "hello staged").unwrap();
158
159        let mut index = repo.index().unwrap();
160        index.add_path(Path::new(file_path)).unwrap();
161        index.write().unwrap();
162
163        // Now, get_paths should return one PathItems with is_staged == true
164        let paths = get_paths(&repo).unwrap();
165        assert_eq!(paths.len(), 1);
166        let item = &paths[0];
167        assert_eq!(item.path, file_path);
168        assert!(item.is_staged);
169        assert!(!item.is_selected);
170    }
171
172    #[test]
173    fn test_get_paths_staged_and_unstaged() {
174        let (_tmp, repo) = init_repo();
175
176        // Commit an initial file
177        commit_file(&repo, "init.txt", "init", "init commit");
178
179        // Add and stage a file
180        let staged_path = "staged.txt";
181        let staged_full_path = repo.workdir().unwrap().join(staged_path);
182        let mut staged_file = File::create(&staged_full_path).unwrap();
183        writeln!(staged_file, "staged content").unwrap();
184
185        let mut index = repo.index().unwrap();
186        index.add_path(Path::new(staged_path)).unwrap();
187        index.write().unwrap();
188
189        // Add an unstaged file
190        let unstaged_path = "unstaged.txt";
191        let unstaged_full_path = repo.workdir().unwrap().join(unstaged_path);
192        let mut unstaged_file = File::create(&unstaged_full_path).unwrap();
193        writeln!(unstaged_file, "unstaged content").unwrap();
194
195        // Now, get_paths should return two PathItems
196        let mut paths = get_paths(&repo).unwrap();
197        paths.sort_by(|a, b| a.path.cmp(&b.path));
198        assert_eq!(paths.len(), 2);
199
200        let staged = paths.iter().find(|p| p.path == staged_path).unwrap();
201        assert!(staged.is_staged);
202
203        let unstaged = paths.iter().find(|p| p.path == unstaged_path).unwrap();
204        assert!(!unstaged.is_staged);
205    }
206}