git_warp/
git.rs

1use crate::error::{GitWarpError, Result};
2use gix::Repository;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct WorktreeInfo {
7    pub path: PathBuf,
8    pub branch: String,
9    pub head: String,
10    pub is_primary: bool,
11}
12
13#[derive(Debug, Clone)]
14pub struct BranchStatus {
15    pub branch: String,
16    pub path: PathBuf,
17    pub has_remote: bool,
18    pub is_merged: bool,
19    pub is_identical: bool,
20    pub has_uncommitted_changes: bool,
21}
22
23pub struct GitRepository {
24    repo: Repository,
25    repo_path: PathBuf,
26}
27
28impl GitRepository {
29    /// Find and open the Git repository
30    pub fn find() -> Result<Self> {
31        let current_dir = std::env::current_dir()?;
32        let repo = gix::discover(current_dir)
33            .map_err(|_| GitWarpError::NotInGitRepository)?;
34        
35        let repo_path = repo.work_dir()
36            .ok_or(GitWarpError::NotInGitRepository)?
37            .to_path_buf();
38        
39        Ok(Self { repo, repo_path })
40    }
41    
42    /// Open a specific Git repository
43    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
44        let repo_path = path.as_ref().to_path_buf();
45        let repo = gix::open(&repo_path)
46            .map_err(|_| GitWarpError::NotInGitRepository)?;
47        
48        Ok(Self { repo, repo_path })
49    }
50    
51    /// Get the repository root path
52    pub fn root_path(&self) -> &Path {
53        &self.repo_path
54    }
55    
56    /// List all worktrees
57    pub fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
58        use std::process::Command;
59        
60        // Use git command to list worktrees since gix doesn't have full worktree support yet
61        let output = Command::new("git")
62            .args(&["worktree", "list", "--porcelain"])
63            .current_dir(&self.repo_path)
64            .output()
65            .map_err(|e| anyhow::anyhow!("Failed to list worktrees: {}", e))?;
66            
67        if !output.status.success() {
68            return Err(anyhow::anyhow!("Git worktree list failed").into());
69        }
70        
71        let output_str = String::from_utf8_lossy(&output.stdout);
72        let mut worktrees = Vec::new();
73        let mut current_worktree: Option<WorktreeInfo> = None;
74        
75        for line in output_str.lines() {
76            if line.starts_with("worktree ") {
77                // Save previous worktree if exists
78                if let Some(wt) = current_worktree.take() {
79                    worktrees.push(wt);
80                }
81                
82                let path = line.strip_prefix("worktree ").unwrap_or("");
83                current_worktree = Some(WorktreeInfo {
84                    path: PathBuf::from(path),
85                    branch: String::new(),
86                    head: String::new(),
87                    is_primary: false,
88                });
89            } else if line.starts_with("HEAD ") {
90                if let Some(ref mut wt) = current_worktree {
91                    wt.head = line.strip_prefix("HEAD ").unwrap_or("").to_string();
92                }
93            } else if line.starts_with("branch refs/heads/") {
94                if let Some(ref mut wt) = current_worktree {
95                    wt.branch = line.strip_prefix("branch refs/heads/").unwrap_or("").to_string();
96                }
97            } else if line == "bare" {
98                if let Some(ref mut wt) = current_worktree {
99                    wt.is_primary = true;
100                }
101            }
102        }
103        
104        // Add the last worktree
105        if let Some(wt) = current_worktree {
106            worktrees.push(wt);
107        }
108        
109        Ok(worktrees)
110    }
111    
112    /// Create a new worktree and branch
113    pub fn create_worktree_and_branch<P: AsRef<Path>>(
114        &self,
115        branch_name: &str,
116        worktree_path: P,
117        from_commit: Option<&str>,
118    ) -> Result<()> {
119        use std::process::Command;
120        
121        let worktree_path = worktree_path.as_ref();
122        
123        // Check if branch already exists
124        if self.branch_exists(branch_name)? {
125            // Create worktree from existing branch
126            let mut cmd = Command::new("git");
127            cmd.args(&["worktree", "add"])
128                .arg(worktree_path)
129                .arg(branch_name)
130                .current_dir(&self.repo_path);
131                
132            let output = cmd.output()
133                .map_err(|e| anyhow::anyhow!("Failed to create worktree: {}", e))?;
134                
135            if !output.status.success() {
136                let error = String::from_utf8_lossy(&output.stderr);
137                return Err(anyhow::anyhow!("Failed to create worktree: {}", error).into());
138            }
139        } else {
140            // Create new branch and worktree
141            let mut cmd = Command::new("git");
142            cmd.args(&["worktree", "add", "-b", branch_name])
143                .arg(worktree_path);
144                
145            if let Some(commit) = from_commit {
146                cmd.arg(commit);
147            } else {
148                cmd.arg("HEAD");
149            }
150            
151            cmd.current_dir(&self.repo_path);
152            
153            let output = cmd.output()
154                .map_err(|e| anyhow::anyhow!("Failed to create worktree and branch: {}", e))?;
155                
156            if !output.status.success() {
157                let error = String::from_utf8_lossy(&output.stderr);
158                return Err(anyhow::anyhow!("Failed to create worktree and branch: {}", error).into());
159            }
160        }
161        
162        Ok(())
163    }
164    
165    /// Remove a worktree
166    pub fn remove_worktree<P: AsRef<Path>>(&self, worktree_path: P) -> Result<()> {
167        use std::process::Command;
168        
169        let worktree_path = worktree_path.as_ref();
170        
171        // Remove the worktree using git
172        let output = Command::new("git")
173            .args(&["worktree", "remove"])
174            .arg(worktree_path)
175            .current_dir(&self.repo_path)
176            .output()
177            .map_err(|e| anyhow::anyhow!("Failed to remove worktree: {}", e))?;
178            
179        if !output.status.success() {
180            let error = String::from_utf8_lossy(&output.stderr);
181            return Err(anyhow::anyhow!("Failed to remove worktree: {}", error).into());
182        }
183        
184        Ok(())
185    }
186    
187    /// Delete a local branch
188    pub fn delete_branch(&self, branch_name: &str, force: bool) -> Result<()> {
189        use std::process::Command;
190        
191        let delete_flag = if force { "-D" } else { "-d" };
192        
193        let output = Command::new("git")
194            .args(&["branch", delete_flag, branch_name])
195            .current_dir(&self.repo_path)
196            .output()
197            .map_err(|e| anyhow::anyhow!("Failed to delete branch: {}", e))?;
198            
199        if !output.status.success() {
200            let error = String::from_utf8_lossy(&output.stderr);
201            return Err(anyhow::anyhow!("Failed to delete branch {}: {}", branch_name, error).into());
202        }
203        
204        Ok(())
205    }
206    
207    /// Prune worktrees (clean up stale references)
208    pub fn prune_worktrees(&self) -> Result<()> {
209        use std::process::Command;
210        
211        let output = Command::new("git")
212            .args(&["worktree", "prune"])
213            .current_dir(&self.repo_path)
214            .output()
215            .map_err(|e| anyhow::anyhow!("Failed to prune worktrees: {}", e))?;
216            
217        if !output.status.success() {
218            let error = String::from_utf8_lossy(&output.stderr);
219            return Err(anyhow::anyhow!("Failed to prune worktrees: {}", error).into());
220        }
221        
222        Ok(())
223    }
224    
225    /// Analyze branches for cleanup
226    pub fn analyze_branches_for_cleanup(&self, worktrees: &[WorktreeInfo]) -> Result<Vec<BranchStatus>> {
227        use std::process::Command;
228        
229        let mut branch_statuses = Vec::new();
230        
231        for worktree in worktrees {
232            if worktree.is_primary || worktree.branch.is_empty() {
233                continue;
234            }
235            
236            let branch = &worktree.branch;
237            let path = &worktree.path;
238            
239            // Check if branch has a remote
240            let has_remote = {
241                let output = Command::new("git")
242                    .args(&["config", &format!("branch.{}.remote", branch)])
243                    .current_dir(&self.repo_path)
244                    .output()
245                    .map_err(|e| anyhow::anyhow!("Failed to check remote: {}", e))?;
246                    
247                output.status.success() && !output.stdout.is_empty()
248            };
249            
250            // Check if branch is merged to main/master
251            let is_merged = {
252                let main_branches = ["main", "master", "develop"];
253                let mut merged = false;
254                
255                for main_branch in &main_branches {
256                    let output = Command::new("git")
257                        .args(&["merge-base", "--is-ancestor", branch, main_branch])
258                        .current_dir(&self.repo_path)
259                        .output();
260                        
261                    if let Ok(output) = output {
262                        if output.status.success() {
263                            merged = true;
264                            break;
265                        }
266                    }
267                }
268                merged
269            };
270            
271            // Check if branch is identical to main
272            let is_identical = {
273                let output = Command::new("git")
274                    .args(&["diff", "--quiet", "main", branch])
275                    .current_dir(&self.repo_path)
276                    .output();
277                    
278                output.map(|o| o.status.success()).unwrap_or(false)
279            };
280            
281            // Check for uncommitted changes
282            let has_uncommitted_changes = {
283                let output = Command::new("git")
284                    .args(&["status", "--porcelain"])
285                    .current_dir(path)
286                    .output();
287                    
288                output.map(|o| !o.stdout.is_empty()).unwrap_or(false)
289            };
290            
291            branch_statuses.push(BranchStatus {
292                branch: branch.clone(),
293                path: path.clone(),
294                has_remote,
295                is_merged,
296                is_identical,
297                has_uncommitted_changes,
298            });
299        }
300        
301        Ok(branch_statuses)
302    }
303    
304    /// Fetch from remote
305    pub fn fetch_branches(&self) -> Result<bool> {
306        use std::process::Command;
307        
308        let output = Command::new("git")
309            .args(&["fetch", "--all", "--prune"])
310            .current_dir(&self.repo_path)
311            .output()
312            .map_err(|e| anyhow::anyhow!("Failed to fetch: {}", e))?;
313            
314        if !output.status.success() {
315            let error = String::from_utf8_lossy(&output.stderr);
316            log::warn!("Git fetch failed: {}", error);
317            return Ok(false);
318        }
319        
320        Ok(true)
321    }
322    
323    /// Check if a branch exists
324    pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
325        use std::process::Command;
326        
327        let output = Command::new("git")
328            .args(&["show-ref", "--verify", "--quiet", &format!("refs/heads/{}", branch_name)])
329            .current_dir(&self.repo_path)
330            .output()
331            .map_err(|e| anyhow::anyhow!("Failed to check branch existence: {}", e))?;
332            
333        Ok(output.status.success())
334    }
335    
336    /// Get the current HEAD commit
337    pub fn get_head_commit(&self) -> Result<String> {
338        use std::process::Command;
339        
340        let output = Command::new("git")
341            .args(&["rev-parse", "HEAD"])
342            .current_dir(&self.repo_path)
343            .output()
344            .map_err(|e| anyhow::anyhow!("Failed to get HEAD commit: {}", e))?;
345            
346        if !output.status.success() {
347            let error = String::from_utf8_lossy(&output.stderr);
348            return Err(anyhow::anyhow!("Failed to get HEAD commit: {}", error).into());
349        }
350        
351        let commit_hash = String::from_utf8_lossy(&output.stdout)
352            .trim()
353            .to_string();
354            
355        Ok(commit_hash)
356    }
357    
358    /// Get the default worktree path for a branch
359    pub fn get_worktree_path(&self, branch_name: &str) -> PathBuf {
360        self.repo_path.join("../worktrees").join(branch_name)
361    }
362    
363    /// Get the main branch name (main or master)
364    pub fn get_main_branch(&self) -> Result<String> {
365        use std::process::Command;
366        
367        // Try to get the default branch from remote
368        let output = Command::new("git")
369            .args(&["symbolic-ref", "refs/remotes/origin/HEAD"])
370            .current_dir(&self.repo_path)
371            .output();
372            
373        if let Ok(output) = output {
374            if output.status.success() {
375                let branch_ref = String::from_utf8_lossy(&output.stdout);
376                if let Some(branch) = branch_ref.trim().strip_prefix("refs/remotes/origin/") {
377                    return Ok(branch.to_string());
378                }
379            }
380        }
381        
382        // Fallback: check if main exists, otherwise use master
383        if self.branch_exists("main")? {
384            Ok("main".to_string())
385        } else {
386            Ok("master".to_string())
387        }
388    }
389
390    /// Check if a directory has uncommitted changes
391    pub fn has_uncommitted_changes<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
392        use std::process::Command;
393        
394        let output = Command::new("git")
395            .args(&["status", "--porcelain"])
396            .current_dir(path.as_ref())
397            .output()
398            .map_err(|e| anyhow::anyhow!("Failed to check git status: {}", e))?;
399            
400        if !output.status.success() {
401            let error = String::from_utf8_lossy(&output.stderr);
402            return Err(anyhow::anyhow!("Git status failed: {}", error).into());
403        }
404        
405        Ok(!output.stdout.is_empty())
406    }
407
408    /// Check if a branch is merged into a target branch
409    pub fn is_branch_merged(&self, branch: &str, target_branch: &str) -> Result<bool> {
410        use std::process::Command;
411        
412        let output = Command::new("git")
413            .args(&["merge-base", "--is-ancestor", branch, target_branch])
414            .current_dir(&self.repo_path)
415            .output()
416            .map_err(|e| anyhow::anyhow!("Failed to check merge status: {}", e))?;
417            
418        Ok(output.status.success())
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use tempfile::tempdir;
426    use std::process::Command;
427    
428    #[test]
429    fn test_git_repo_operations() {
430        // Create a temporary git repository for testing
431        let temp_dir = tempdir().unwrap();
432        let repo_path = temp_dir.path();
433        
434        // Initialize git repo
435        Command::new("git")
436            .args(&["init"])
437            .current_dir(repo_path)
438            .output()
439            .unwrap();
440        
441        // Configure git
442        Command::new("git")
443            .args(&["config", "user.email", "test@example.com"])
444            .current_dir(repo_path)
445            .output()
446            .unwrap();
447        
448        Command::new("git")
449            .args(&["config", "user.name", "Test User"])
450            .current_dir(repo_path)
451            .output()
452            .unwrap();
453        
454        // Create initial commit
455        std::fs::write(repo_path.join("test.txt"), "test").unwrap();
456        Command::new("git")
457            .args(&["add", "."])
458            .current_dir(repo_path)
459            .output()
460            .unwrap();
461        
462        Command::new("git")
463            .args(&["commit", "-m", "Initial commit"])
464            .current_dir(repo_path)
465            .output()
466            .unwrap();
467        
468        // Test opening the repository
469        let git_repo = GitRepository::open(repo_path);
470        assert!(git_repo.is_ok());
471    }
472}