eazygit/commands/git_commands/
stage.rs

1//! Stage and unstage file commands.
2//!
3//! These commands handle staging and unstaging files with optimistic UI updates
4//! for immediate visual feedback.
5
6use crate::commands::{Command, CommandResult};
7use crate::services::GitService;
8use crate::app::{AppState, Action, reducer};
9use crate::errors::CommandError;
10use tracing::instrument;
11
12/// Command to stage one or more files
13pub struct StageFilesCommand {
14    pub paths: Vec<String>,
15}
16
17impl Command for StageFilesCommand {
18    #[instrument(skip(self, git, state), fields(paths = ?self.paths))]
19    fn execute(
20        &self,
21        git: &GitService,
22        state: &AppState,
23    ) -> Result<CommandResult, CommandError> {
24        let mut staged_count = 0;
25        let mut errors = Vec::new();
26        
27        for path in &self.paths {
28            match git.stage_file(&state.repo_path, path) {
29                Ok(_) => staged_count += 1,
30                Err(e) => errors.push(format!("stage error: {e}")),
31            }
32        }
33        
34        let mut new_state = state.clone();
35        
36        if !errors.is_empty() {
37            new_state = reducer(new_state, Action::SetStatusError(Some(errors.join(", "))));
38        }
39        
40        if staged_count > 0 {
41            // Apply optimistic update: mark staged files as staged in status_entries
42            // This provides immediate visual feedback before the async refresh completes
43            for entry in new_state.status_entries.iter_mut() {
44                if self.paths.contains(&entry.path) {
45                    entry.staged = true;
46                    // Preserve unstaged state - file might have both staged and unstaged changes
47                }
48            }
49            
50            new_state = reducer(new_state, Action::SetFeedback(Some(
51                format!("Staged {} file(s)", staged_count)
52            )));
53            new_state = reducer(new_state, Action::ClearFileSelects);
54            new_state = reducer(new_state, Action::SetRefreshing(true));
55        }
56        
57        Ok(CommandResult::StateUpdate(new_state))
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::commands::Command as CommandTrait;
65    use tempfile::tempdir;
66    use std::fs;
67    use std::process::Command;
68
69    fn setup_test_repo() -> (std::path::PathBuf, GitService) {
70        let temp_dir = tempdir().expect("Failed to create temp directory");
71        let repo_path = temp_dir.path().to_path_buf();
72        
73        Command::new("git")
74            .arg("init")
75            .arg(&repo_path)
76            .output()
77            .expect("Failed to init repo");
78        
79        Command::new("git")
80            .args(["-C", repo_path.to_str().unwrap(), "config", "user.name", "Test"])
81            .output()
82            .expect("Failed to set user.name");
83        
84        Command::new("git")
85            .args(["-C", repo_path.to_str().unwrap(), "config", "user.email", "test@test.com"])
86            .output()
87            .expect("Failed to set user.email");
88        
89        // Create initial commit
90        let test_file = repo_path.join("existing.txt");
91        fs::write(&test_file, "content").expect("Failed to write");
92        Command::new("git")
93            .args(["-C", repo_path.to_str().unwrap(), "add", "existing.txt"])
94            .output()
95            .expect("Failed to stage");
96        Command::new("git")
97            .args(["-C", repo_path.to_str().unwrap(), "commit", "-m", "Initial"])
98            .output()
99            .expect("Failed to commit");
100        
101        std::mem::forget(temp_dir);
102        let git_service = GitService::new();
103        (repo_path, git_service)
104    }
105
106    #[test]
107    fn test_stage_files_command_structure() {
108        let command = StageFilesCommand {
109            paths: vec!["test.txt".to_string()],
110        };
111        assert_eq!(command.paths.len(), 1);
112    }
113
114    #[test]
115    fn test_stage_files_execution() {
116        let (repo_path, git_service) = setup_test_repo();
117        let mut state = AppState::new();
118        state.repo_path = repo_path.to_str().unwrap().to_string();
119        
120        // Create test file
121        let test_file = repo_path.join("test.txt");
122        fs::write(&test_file, "content").expect("Failed to write");
123        
124        let command = StageFilesCommand {
125            paths: vec!["test.txt".to_string()],
126        };
127        
128        let result = CommandTrait::execute(&command, &git_service, &state);
129        assert!(result.is_ok());
130        
131        // Verify file is staged
132        let status = git_service.status_porcelain(repo_path.to_str().unwrap())
133            .expect("Failed to get status");
134        assert!(status.contains("A ") || status.contains("test.txt"));
135        
136        let _ = fs::remove_dir_all(&repo_path);
137    }
138
139    #[test]
140    fn test_unstage_files_execution() {
141        let (repo_path, git_service) = setup_test_repo();
142        let mut state = AppState::new();
143        state.repo_path = repo_path.to_str().unwrap().to_string();
144        
145        // Create and stage file
146        let test_file = repo_path.join("test.txt");
147        fs::write(&test_file, "content").expect("Failed to write");
148        git_service.stage_file(repo_path.to_str().unwrap(), "test.txt")
149            .expect("Failed to stage");
150        
151        let command = UnstageFilesCommand {
152            paths: vec!["test.txt".to_string()],
153        };
154        
155        let result = CommandTrait::execute(&command, &git_service, &state);
156        assert!(result.is_ok());
157        
158        // Verify file is unstaged
159        let status = git_service.status_porcelain(repo_path.to_str().unwrap())
160            .expect("Failed to get status");
161        assert!(!status.contains("A "));
162        
163        let _ = fs::remove_dir_all(&repo_path);
164    }
165
166    #[test]
167    fn test_command_error_handling() {
168        let (repo_path, git_service) = setup_test_repo();
169        let mut state = AppState::new();
170        state.repo_path = repo_path.to_str().unwrap().to_string();
171        
172        // Try to stage non-existent file
173        let command = StageFilesCommand {
174            paths: vec!["nonexistent.txt".to_string()],
175        };
176        
177        // This might succeed (git add creates empty file) or fail
178        let result = CommandTrait::execute(&command, &git_service, &state);
179        // Both outcomes are acceptable
180        assert!(result.is_ok() || result.is_err());
181        
182        let _ = fs::remove_dir_all(&repo_path);
183    }
184}
185
186/// Command to unstage one or more files
187pub struct UnstageFilesCommand {
188    pub paths: Vec<String>,
189}
190
191impl Command for UnstageFilesCommand {
192    #[instrument(skip(self, git, state), fields(paths = ?self.paths))]
193    fn execute(
194        &self,
195        git: &GitService,
196        state: &AppState,
197    ) -> Result<CommandResult, CommandError> {
198        let mut unstaged_count = 0;
199        let mut errors = Vec::new();
200        
201        for path in &self.paths {
202            match git.unstage_file(&state.repo_path, path) {
203                Ok(_) => unstaged_count += 1,
204                Err(e) => errors.push(format!("unstage error: {e}")),
205            }
206        }
207        
208        let mut new_state = state.clone();
209        
210        if !errors.is_empty() {
211            new_state = reducer(new_state, Action::SetStatusError(Some(errors.join(", "))));
212        }
213        
214        if unstaged_count > 0 {
215            // Apply optimistic update: mark unstaged files as unstaged in status_entries
216            // This provides immediate visual feedback before the async refresh completes
217            for entry in new_state.status_entries.iter_mut() {
218                if self.paths.contains(&entry.path) {
219                    entry.staged = false;
220                    // Preserve unstaged state - status refresh will set correct state
221                }
222            }
223            
224            new_state = reducer(new_state, Action::SetFeedback(Some(
225                format!("Unstaged {} file(s)", unstaged_count)
226            )));
227            new_state = reducer(new_state, Action::ClearFileSelects);
228            new_state = reducer(new_state, Action::SetRefreshing(true));
229        }
230        
231        Ok(CommandResult::StateUpdate(new_state))
232    }
233}
234