ricecoder_files/
git.rs

1//! Git integration for version control and auto-commit functionality
2
3use crate::error::FileError;
4use crate::models::{FileOperation, GitStatus, OperationType};
5use git2::{Repository, Status, StatusOptions};
6use std::path::{Path, PathBuf};
7
8/// Integrates with git for version control and auto-commit functionality
9#[derive(Debug, Clone)]
10pub struct GitIntegration;
11
12impl GitIntegration {
13    /// Creates a new GitIntegration instance
14    pub fn new() -> Self {
15        GitIntegration
16    }
17
18    /// Checks the git status of a repository
19    ///
20    /// Returns information about modified, staged, and untracked files.
21    ///
22    /// # Arguments
23    ///
24    /// * `repo_path` - Path to the git repository
25    ///
26    /// # Returns
27    ///
28    /// A `GitStatus` containing the current branch and file statuses
29    ///
30    /// # Errors
31    ///
32    /// Returns `FileError::GitError` if the repository cannot be opened or status cannot be checked
33    pub fn check_status(repo_path: &Path) -> Result<GitStatus, FileError> {
34        let repo = Repository::open(repo_path)
35            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
36
37        let branch = Self::get_current_branch_internal(&repo)?;
38
39        let mut status_opts = StatusOptions::new();
40        status_opts.include_untracked(true);
41        status_opts.include_ignored(false);
42
43        let statuses = repo
44            .statuses(Some(&mut status_opts))
45            .map_err(|e| FileError::GitError(format!("Failed to get status: {}", e)))?;
46
47        let mut modified = Vec::new();
48        let mut staged = Vec::new();
49        let mut untracked = Vec::new();
50
51        for entry in statuses.iter() {
52            let path = PathBuf::from(entry.path().unwrap_or(""));
53            let status = entry.status();
54
55            if status.contains(Status::WT_MODIFIED) || status.contains(Status::WT_DELETED) {
56                modified.push(path);
57            } else if status.contains(Status::INDEX_NEW)
58                || status.contains(Status::INDEX_MODIFIED)
59                || status.contains(Status::INDEX_DELETED)
60            {
61                staged.push(path);
62            } else if status.contains(Status::WT_NEW) {
63                untracked.push(path);
64            }
65        }
66
67        Ok(GitStatus {
68            branch,
69            modified,
70            staged,
71            untracked,
72        })
73    }
74
75    /// Gets the current branch name
76    ///
77    /// # Arguments
78    ///
79    /// * `repo_path` - Path to the git repository
80    ///
81    /// # Returns
82    ///
83    /// The name of the current branch
84    ///
85    /// # Errors
86    ///
87    /// Returns `FileError::GitError` if the repository cannot be opened or branch cannot be determined
88    pub fn get_current_branch(repo_path: &Path) -> Result<String, FileError> {
89        let repo = Repository::open(repo_path)
90            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
91
92        Self::get_current_branch_internal(&repo)
93    }
94
95    /// Internal helper to get the current branch from a repository
96    fn get_current_branch_internal(repo: &Repository) -> Result<String, FileError> {
97        let head = repo
98            .head()
99            .map_err(|e| FileError::GitError(format!("Failed to get HEAD: {}", e)))?;
100
101        if let Some(name) = head.shorthand() {
102            Ok(name.to_string())
103        } else {
104            Ok("HEAD".to_string())
105        }
106    }
107
108    /// Stages files for commit
109    ///
110    /// # Arguments
111    ///
112    /// * `repo_path` - Path to the git repository
113    /// * `files` - Paths to files to stage
114    ///
115    /// # Errors
116    ///
117    /// Returns `FileError::GitError` if staging fails
118    pub fn stage_files(repo_path: &Path, files: &[PathBuf]) -> Result<(), FileError> {
119        let repo = Repository::open(repo_path)
120            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
121
122        let mut index = repo
123            .index()
124            .map_err(|e| FileError::GitError(format!("Failed to get index: {}", e)))?;
125
126        for file in files {
127            index
128                .add_path(file)
129                .map_err(|e| FileError::GitError(format!("Failed to stage file: {}", e)))?;
130        }
131
132        index
133            .write()
134            .map_err(|e| FileError::GitError(format!("Failed to write index: {}", e)))?;
135
136        Ok(())
137    }
138
139    /// Creates a commit with the given message
140    ///
141    /// # Arguments
142    ///
143    /// * `repo_path` - Path to the git repository
144    /// * `message` - Commit message
145    ///
146    /// # Errors
147    ///
148    /// Returns `FileError::GitError` if the commit fails
149    pub fn create_commit(repo_path: &Path, message: &str) -> Result<(), FileError> {
150        let repo = Repository::open(repo_path)
151            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
152
153        let signature = repo
154            .signature()
155            .map_err(|e| FileError::GitError(format!("Failed to get signature: {}", e)))?;
156
157        let tree_id = {
158            let mut index = repo
159                .index()
160                .map_err(|e| FileError::GitError(format!("Failed to get index: {}", e)))?;
161
162            index
163                .write_tree()
164                .map_err(|e| FileError::GitError(format!("Failed to write tree: {}", e)))?
165        };
166
167        let tree = repo
168            .find_tree(tree_id)
169            .map_err(|e| FileError::GitError(format!("Failed to find tree: {}", e)))?;
170
171        let parent_commit = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
172
173        let parents = if let Some(parent) = parent_commit {
174            vec![parent]
175        } else {
176            vec![]
177        };
178
179        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
180
181        repo.commit(
182            Some("HEAD"),
183            &signature,
184            &signature,
185            message,
186            &tree,
187            &parent_refs,
188        )
189        .map_err(|e| FileError::GitError(format!("Failed to create commit: {}", e)))?;
190
191        Ok(())
192    }
193
194    /// Reviews changes in the repository before committing
195    ///
196    /// Returns unified diffs for all modified files.
197    ///
198    /// # Arguments
199    ///
200    /// * `repo_path` - Path to the git repository
201    ///
202    /// # Returns
203    ///
204    /// A vector of FileDiff objects showing changes
205    ///
206    /// # Errors
207    ///
208    /// Returns `FileError::GitError` if the review fails
209    pub fn review_changes(repo_path: &Path) -> Result<Vec<crate::models::FileDiff>, FileError> {
210        let repo = Repository::open(repo_path)
211            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
212
213        let mut diffs = Vec::new();
214
215        // Get the HEAD tree
216        let head = repo
217            .head()
218            .map_err(|e| FileError::GitError(format!("Failed to get HEAD: {}", e)))?;
219
220        let head_tree = head
221            .peel_to_tree()
222            .map_err(|e| FileError::GitError(format!("Failed to get HEAD tree: {}", e)))?;
223
224        // Get the index (staged changes)
225        let mut index = repo
226            .index()
227            .map_err(|e| FileError::GitError(format!("Failed to get index: {}", e)))?;
228
229        let index_tree = index
230            .write_tree_to(&repo)
231            .map_err(|e| FileError::GitError(format!("Failed to write index tree: {}", e)))?;
232
233        let index_tree_obj = repo
234            .find_tree(index_tree)
235            .map_err(|e| FileError::GitError(format!("Failed to find index tree: {}", e)))?;
236
237        // Get diff between HEAD and index
238        let diff = repo
239            .diff_tree_to_tree(Some(&head_tree), Some(&index_tree_obj), None)
240            .map_err(|e| FileError::GitError(format!("Failed to generate diff: {}", e)))?;
241
242        // Convert git2 diff to our FileDiff format
243        for delta in diff.deltas() {
244            if let Some(path) = delta.new_file().path() {
245                let file_diff = crate::models::FileDiff {
246                    path: path.to_path_buf(),
247                    hunks: vec![],
248                    stats: crate::models::DiffStats {
249                        additions: 0,
250                        deletions: 0,
251                        files_changed: 1,
252                    },
253                };
254                diffs.push(file_diff);
255            }
256        }
257
258        Ok(diffs)
259    }
260
261    /// Accepts all staged changes and prepares for commit
262    ///
263    /// # Arguments
264    ///
265    /// * `repo_path` - Path to the git repository
266    ///
267    /// # Errors
268    ///
269    /// Returns `FileError::GitError` if accepting changes fails
270    pub fn accept_changes(repo_path: &Path) -> Result<(), FileError> {
271        // In a real implementation, this would mark changes as accepted
272        // For now, we just verify the repository is valid
273        let _repo = Repository::open(repo_path)
274            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
275
276        Ok(())
277    }
278
279    /// Rejects all staged changes and reverts them
280    ///
281    /// # Arguments
282    ///
283    /// * `repo_path` - Path to the git repository
284    ///
285    /// # Errors
286    ///
287    /// Returns `FileError::GitError` if rejecting changes fails
288    pub fn reject_changes(repo_path: &Path) -> Result<(), FileError> {
289        let repo = Repository::open(repo_path)
290            .map_err(|e| FileError::GitError(format!("Failed to open repository: {}", e)))?;
291
292        // Reset the index to HEAD
293        let head = repo
294            .head()
295            .map_err(|e| FileError::GitError(format!("Failed to get HEAD: {}", e)))?;
296
297        let head_commit = head
298            .peel_to_commit()
299            .map_err(|e| FileError::GitError(format!("Failed to get HEAD commit: {}", e)))?;
300
301        repo.reset(head_commit.as_object(), git2::ResetType::Mixed, None)
302            .map_err(|e| FileError::GitError(format!("Failed to reset changes: {}", e)))?;
303
304        Ok(())
305    }
306
307    /// Generates a descriptive commit message from file operations
308    ///
309    /// # Arguments
310    ///
311    /// * `operations` - List of file operations to summarize
312    ///
313    /// # Returns
314    ///
315    /// A descriptive commit message
316    pub fn generate_commit_message(operations: &[FileOperation]) -> String {
317        if operations.is_empty() {
318            return "Update files".to_string();
319        }
320
321        let mut creates = 0;
322        let mut updates = 0;
323        let mut deletes = 0;
324
325        for op in operations {
326            match op.operation {
327                OperationType::Create => creates += 1,
328                OperationType::Update => updates += 1,
329                OperationType::Delete => deletes += 1,
330                OperationType::Rename { .. } => updates += 1,
331            }
332        }
333
334        let mut parts = Vec::new();
335
336        if creates > 0 {
337            parts.push(format!("Create {} file(s)", creates));
338        }
339        if updates > 0 {
340            parts.push(format!("Update {} file(s)", updates));
341        }
342        if deletes > 0 {
343            parts.push(format!("Delete {} file(s)", deletes));
344        }
345
346        if parts.is_empty() {
347            "Update files".to_string()
348        } else {
349            parts.join(", ")
350        }
351    }
352}
353
354impl Default for GitIntegration {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_generate_commit_message_empty() {
366        let operations = vec![];
367        let message = GitIntegration::generate_commit_message(&operations);
368        assert_eq!(message, "Update files");
369    }
370
371    #[test]
372    fn test_generate_commit_message_creates() {
373        let operations = vec![
374            FileOperation {
375                path: PathBuf::from("file1.rs"),
376                operation: OperationType::Create,
377                content: Some("content".to_string()),
378                backup_path: None,
379                content_hash: None,
380            },
381            FileOperation {
382                path: PathBuf::from("file2.rs"),
383                operation: OperationType::Create,
384                content: Some("content".to_string()),
385                backup_path: None,
386                content_hash: None,
387            },
388        ];
389        let message = GitIntegration::generate_commit_message(&operations);
390        assert!(message.contains("Create 2 file(s)"));
391    }
392
393    #[test]
394    fn test_generate_commit_message_mixed() {
395        let operations = vec![
396            FileOperation {
397                path: PathBuf::from("file1.rs"),
398                operation: OperationType::Create,
399                content: Some("content".to_string()),
400                backup_path: None,
401                content_hash: None,
402            },
403            FileOperation {
404                path: PathBuf::from("file2.rs"),
405                operation: OperationType::Update,
406                content: Some("content".to_string()),
407                backup_path: None,
408                content_hash: None,
409            },
410            FileOperation {
411                path: PathBuf::from("file3.rs"),
412                operation: OperationType::Delete,
413                content: None,
414                backup_path: None,
415                content_hash: None,
416            },
417        ];
418        let message = GitIntegration::generate_commit_message(&operations);
419        assert!(message.contains("Create 1 file(s)"));
420        assert!(message.contains("Update 1 file(s)"));
421        assert!(message.contains("Delete 1 file(s)"));
422    }
423
424    #[test]
425    fn test_git_integration_new() {
426        let git = GitIntegration::new();
427        assert_eq!(format!("{:?}", git), "GitIntegration");
428    }
429
430    #[test]
431    fn test_git_integration_default() {
432        let git = GitIntegration::default();
433        assert_eq!(format!("{:?}", git), "GitIntegration");
434    }
435
436    #[test]
437    fn test_generate_commit_message_updates() {
438        let operations = vec![
439            FileOperation {
440                path: PathBuf::from("file1.rs"),
441                operation: OperationType::Update,
442                content: Some("content".to_string()),
443                backup_path: None,
444                content_hash: None,
445            },
446            FileOperation {
447                path: PathBuf::from("file2.rs"),
448                operation: OperationType::Update,
449                content: Some("content".to_string()),
450                backup_path: None,
451                content_hash: None,
452            },
453        ];
454        let message = GitIntegration::generate_commit_message(&operations);
455        assert_eq!(message, "Update 2 file(s)");
456    }
457
458    #[test]
459    fn test_generate_commit_message_deletes() {
460        let operations = vec![FileOperation {
461            path: PathBuf::from("file1.rs"),
462            operation: OperationType::Delete,
463            content: None,
464            backup_path: None,
465            content_hash: None,
466        }];
467        let message = GitIntegration::generate_commit_message(&operations);
468        assert_eq!(message, "Delete 1 file(s)");
469    }
470
471    #[test]
472    fn test_generate_commit_message_renames() {
473        let operations = vec![FileOperation {
474            path: PathBuf::from("file1.rs"),
475            operation: OperationType::Rename {
476                to: PathBuf::from("file2.rs"),
477            },
478            content: None,
479            backup_path: None,
480            content_hash: None,
481        }];
482        let message = GitIntegration::generate_commit_message(&operations);
483        assert!(message.contains("Update 1 file(s)"));
484    }
485
486    #[test]
487    fn test_accept_changes_invalid_repo() {
488        let result = GitIntegration::accept_changes(Path::new("/nonexistent/path"));
489        assert!(result.is_err());
490    }
491
492    #[test]
493    fn test_reject_changes_invalid_repo() {
494        let result = GitIntegration::reject_changes(Path::new("/nonexistent/path"));
495        assert!(result.is_err());
496    }
497
498    #[test]
499    fn test_review_changes_invalid_repo() {
500        let result = GitIntegration::review_changes(Path::new("/nonexistent/path"));
501        assert!(result.is_err());
502    }
503}