git_quick_add/
lib.rs

1use dialoguer::MultiSelect;
2use git2::{Repository, Status};
3use std::{path::Path, process};
4
5#[derive(Clone, Debug)]
6pub struct PathItems {
7    path: String,
8    is_staged: bool,
9    is_selected: bool,
10}
11
12// Step 1
13/// Gets the file paths of the changes in your repo.
14pub fn get_paths(repo: &Repository) -> Result<Vec<PathItems>, git2::Error> {
15    let statuses = repo.statuses(None)?;
16
17    if statuses.is_empty() {
18        println!("{}", console::style("✔ working tree clean ✔").green());
19        return Ok(vec![]);
20    }
21
22    let mut items: Vec<PathItems> = vec![];
23
24    for diff_entry in statuses.iter() {
25        if diff_entry.status() == Status::IGNORED {
26            continue;
27        }
28
29        let path_items = diff_entry
30            // 1. Try to get the HEAD → index diff
31            .head_to_index()
32            // If the file differs between HEAD and Index, grab the new file path. (This means the file has been staged.)
33            .and_then(|d| {
34                Some(PathItems {
35                    path: String::from(d.new_file().path()?.display().to_string()),
36                    is_staged: true,
37                    is_selected: false,
38                })
39            })
40            // 2. Otherwise, try index → workdir diff (This means the file has unstaged changes.)
41            .or_else(|| {
42                Option::from(
43                    diff_entry
44                        .index_to_workdir()
45                        .and_then(|d| {
46                            Some(PathItems {
47                                path: String::from(d.new_file().path()?.display().to_string()),
48                                is_staged: false,
49                                is_selected: false,
50                            })
51                        })
52                        // 3. If still nothing, try the "old" file's path (maybe a deletion/rename)
53                        // If the file is gone in workdir (deleted) or renamed, take the old file path
54                        .or_else(|| {
55                            diff_entry.index_to_workdir().and_then(|d| {
56                                Some(PathItems {
57                                    path: String::from(d.old_file().path()?.display().to_string()),
58                                    is_staged: false,
59                                    is_selected: false,
60                                })
61                            })
62                        })
63                        // 4. If nothing worked, fallback to "<unknown>"
64                        .unwrap_or_else(|| PathItems {
65                            path: String::from("<unknown>"),
66                            is_staged: false,
67                            is_selected: false,
68                        }),
69                )
70            })
71            .unwrap();
72
73        items.push(path_items);
74    }
75
76    // If the only changes are ignored files, exit
77    if items.is_empty() {
78        println!("{}", console::style("✔ working tree clean ✔").green());
79        process::exit(1)
80    }
81
82    Ok(items)
83}
84
85// Step 2
86/// Prompts the user to select files to stage and returns the selected file paths.
87/// If no files are selected, the program exits.
88/// # Arguments
89/// * `repo` - A reference to the git repository.
90/// # Returns
91/// A vector of selected file paths as strings.
92pub fn choose_files(mut path_items: Vec<PathItems>) -> Vec<PathItems> {
93    // TODO: Include the status of each file in the prompt (e.g., "M", "A", "D", "??")
94    let list_of_paths: Vec<String> = path_items.iter().map(|p| p.path.clone()).collect();
95    let list_of_preselected: Vec<bool> = path_items.iter().map(|p| p.is_staged).collect();
96
97    let selections = MultiSelect::new()
98        .with_prompt("Choose files to stage - (use Space to select - press Enter to submit)")
99        .items(list_of_paths)
100        .defaults(&list_of_preselected)
101        .interact()
102        .unwrap_or_else(|_| {
103            eprintln!("{}", console::style("Error selecting files").red());
104            process::exit(1)
105        });
106
107    for index in selections {
108        path_items[index].is_selected = true;
109    }
110
111    path_items
112}
113
114// Step 3
115/// Stages the selected files in the git repository.
116/// If staging fails, the program exits.
117/// # Arguments
118/// * `repo` - A reference to the git repository.
119/// * `paths` - A vector of file paths to stage.
120pub fn git_add_selected(repo: &Repository, paths: &Vec<PathItems>) -> Result<(), git2::Error> {
121    let mut index = repo.index()?;
122
123    println!("{}", console::style("Changes Made:").bold());
124
125    let mut logs = vec![];
126
127    for item in paths {
128        // if the item is staged and not selected, we need to unstage it
129        if item.is_staged && !item.is_selected {
130            let target = repo.head()?.peel(git2::ObjectType::Commit)?;
131            repo.reset_default(Some(&target), &[&item.path])?;
132
133            logs.push(format!(
134                " - {} {}",
135                console::style("Unstaged:").yellow(),
136                item.path.clone()
137            ));
138        } else if !item.is_staged && item.is_selected {
139            let p = Path::new(&item.path);
140
141            index.add_path(p).unwrap_or_else(|error| {
142                eprintln!(
143                    "{} {} - {}",
144                    console::style("Failed to add path for").red(),
145                    console::style(&item.path).yellow(),
146                    error
147                );
148            });
149
150            logs.push(format!(
151                " - {} {}",
152                console::style("Staged:").green(),
153                item.path
154            ));
155
156            index.write().unwrap_or_else(|error| {
157                eprintln!(
158                    "{} {} - {}",
159                    console::style("Failed to write index for").red(),
160                    console::style(&item.path).yellow(),
161                    error
162                );
163            });
164        } else {
165            if item.is_staged {
166                logs.push(format!(
167                    " - {} {}",
168                    console::style("Staged:").green(),
169                    item.path.clone()
170                ));
171            } else {
172                logs.push(format!(
173                    " - {} {}",
174                    console::style("Unstaged:").yellow(),
175                    item.path.clone()
176                ));
177            }
178        }
179    }
180
181    println!("{}", logs.join("\n"));
182
183    Ok(())
184}
185
186// Tests
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use git2::{Oid, Repository, Signature};
191    use std::fs::File;
192    use std::io::Write;
193    use tempfile::TempDir;
194
195    /// Helper to initialize a new git repository in a temp dir
196    fn init_repo() -> (TempDir, Repository) {
197        let tmp_dir = TempDir::new().expect("create temp dir");
198        let repo = Repository::init(tmp_dir.path()).expect("init repo");
199        (tmp_dir, repo)
200    }
201
202    /// Helper to commit a file to the repo
203    fn commit_file(repo: &Repository, file_path: &str, content: &str, message: &str) -> Oid {
204        let mut file = File::create(repo.workdir().unwrap().join(file_path)).unwrap();
205        file.write_all(content.as_bytes()).unwrap();
206
207        let mut index = repo.index().unwrap();
208        index.add_path(Path::new(file_path)).unwrap();
209        let oid = index.write_tree().unwrap();
210
211        let sig = Signature::now("Test", "test@example.com").unwrap();
212        let tree = repo.find_tree(oid).unwrap();
213
214        let parent_commit = repo
215            .head()
216            .ok()
217            .and_then(|h| h.target())
218            .and_then(|oid| repo.find_commit(oid).ok());
219
220        let commit_oid = if let Some(parent) = parent_commit {
221            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
222                .unwrap()
223        } else {
224            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
225                .unwrap()
226        };
227        commit_oid
228    }
229
230    #[test]
231    fn test_get_paths_empty_worktree() {
232        let (_tmp, repo) = init_repo();
233
234        // No files, clean worktree
235        let statuses = get_paths(&repo).unwrap();
236        assert!(statuses.is_empty());
237    }
238
239    #[test]
240    fn test_get_paths_unstaged_file() {
241        let (_tmp, repo) = init_repo();
242
243        // Create a file but do not stage it
244        let file_path = "foo.txt";
245        let file_full_path = repo.workdir().unwrap().join(file_path);
246        let mut file = File::create(&file_full_path).unwrap();
247        writeln!(file, "hello world").unwrap();
248
249        // Now, get_paths should return one PathItems with is_staged == false
250        let paths = get_paths(&repo).unwrap();
251        assert_eq!(paths.len(), 1);
252        let item = &paths[0];
253        assert_eq!(item.path, file_path);
254        assert!(!item.is_staged);
255        assert!(!item.is_selected);
256    }
257
258    #[test]
259    fn test_get_paths_staged_file() {
260        let (_tmp, repo) = init_repo();
261
262        // Create and stage a file
263        let file_path = "bar.txt";
264        let file_full_path = repo.workdir().unwrap().join(file_path);
265        let mut file = File::create(&file_full_path).unwrap();
266        writeln!(file, "hello staged").unwrap();
267
268        let mut index = repo.index().unwrap();
269        index.add_path(Path::new(file_path)).unwrap();
270        index.write().unwrap();
271
272        // Now, get_paths should return one PathItems with is_staged == true
273        let paths = get_paths(&repo).unwrap();
274        assert_eq!(paths.len(), 1);
275        let item = &paths[0];
276        assert_eq!(item.path, file_path);
277        assert!(item.is_staged);
278        assert!(!item.is_selected);
279    }
280
281    #[test]
282    fn test_get_paths_staged_and_unstaged() {
283        let (_tmp, repo) = init_repo();
284
285        // Commit an initial file
286        commit_file(&repo, "init.txt", "init", "init commit");
287
288        // Add and stage a file
289        let staged_path = "staged.txt";
290        let staged_full_path = repo.workdir().unwrap().join(staged_path);
291        let mut staged_file = File::create(&staged_full_path).unwrap();
292        writeln!(staged_file, "staged content").unwrap();
293
294        let mut index = repo.index().unwrap();
295        index.add_path(Path::new(staged_path)).unwrap();
296        index.write().unwrap();
297
298        // Add an unstaged file
299        let unstaged_path = "unstaged.txt";
300        let unstaged_full_path = repo.workdir().unwrap().join(unstaged_path);
301        let mut unstaged_file = File::create(&unstaged_full_path).unwrap();
302        writeln!(unstaged_file, "unstaged content").unwrap();
303
304        // Now, get_paths should return two PathItems
305        let mut paths = get_paths(&repo).unwrap();
306        paths.sort_by(|a, b| a.path.cmp(&b.path));
307        assert_eq!(paths.len(), 2);
308
309        let staged = paths.iter().find(|p| p.path == staged_path).unwrap();
310        assert!(staged.is_staged);
311
312        let unstaged = paths.iter().find(|p| p.path == unstaged_path).unwrap();
313        assert!(!unstaged.is_staged);
314    }
315}