git-warp 0.1.0

High-performance, UX-focused Git worktree manager combining CoW speed with advanced features
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
use crate::error::{GitWarpError, Result};
use gix::Repository;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct WorktreeInfo {
    pub path: PathBuf,
    pub branch: String,
    pub head: String,
    pub is_primary: bool,
}

#[derive(Debug, Clone)]
pub struct BranchStatus {
    pub branch: String,
    pub path: PathBuf,
    pub has_remote: bool,
    pub is_merged: bool,
    pub is_identical: bool,
    pub has_uncommitted_changes: bool,
}

pub struct GitRepository {
    repo: Repository,
    repo_path: PathBuf,
}

impl GitRepository {
    /// Find and open the Git repository
    pub fn find() -> Result<Self> {
        let current_dir = std::env::current_dir()?;
        let repo = gix::discover(current_dir)
            .map_err(|_| GitWarpError::NotInGitRepository)?;
        
        let repo_path = repo.work_dir()
            .ok_or(GitWarpError::NotInGitRepository)?
            .to_path_buf();
        
        Ok(Self { repo, repo_path })
    }
    
    /// Open a specific Git repository
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        let repo_path = path.as_ref().to_path_buf();
        let repo = gix::open(&repo_path)
            .map_err(|_| GitWarpError::NotInGitRepository)?;
        
        Ok(Self { repo, repo_path })
    }
    
    /// Get the repository root path
    pub fn root_path(&self) -> &Path {
        &self.repo_path
    }
    
    /// List all worktrees
    pub fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
        use std::process::Command;
        
        // Use git command to list worktrees since gix doesn't have full worktree support yet
        let output = Command::new("git")
            .args(&["worktree", "list", "--porcelain"])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to list worktrees: {}", e))?;
            
        if !output.status.success() {
            return Err(anyhow::anyhow!("Git worktree list failed").into());
        }
        
        let output_str = String::from_utf8_lossy(&output.stdout);
        let mut worktrees = Vec::new();
        let mut current_worktree: Option<WorktreeInfo> = None;
        
        for line in output_str.lines() {
            if line.starts_with("worktree ") {
                // Save previous worktree if exists
                if let Some(wt) = current_worktree.take() {
                    worktrees.push(wt);
                }
                
                let path = line.strip_prefix("worktree ").unwrap_or("");
                current_worktree = Some(WorktreeInfo {
                    path: PathBuf::from(path),
                    branch: String::new(),
                    head: String::new(),
                    is_primary: false,
                });
            } else if line.starts_with("HEAD ") {
                if let Some(ref mut wt) = current_worktree {
                    wt.head = line.strip_prefix("HEAD ").unwrap_or("").to_string();
                }
            } else if line.starts_with("branch refs/heads/") {
                if let Some(ref mut wt) = current_worktree {
                    wt.branch = line.strip_prefix("branch refs/heads/").unwrap_or("").to_string();
                }
            } else if line == "bare" {
                if let Some(ref mut wt) = current_worktree {
                    wt.is_primary = true;
                }
            }
        }
        
        // Add the last worktree
        if let Some(wt) = current_worktree {
            worktrees.push(wt);
        }
        
        Ok(worktrees)
    }
    
    /// Create a new worktree and branch
    pub fn create_worktree_and_branch<P: AsRef<Path>>(
        &self,
        branch_name: &str,
        worktree_path: P,
        from_commit: Option<&str>,
    ) -> Result<()> {
        use std::process::Command;
        
        let worktree_path = worktree_path.as_ref();
        
        // Check if branch already exists
        if self.branch_exists(branch_name)? {
            // Create worktree from existing branch
            let mut cmd = Command::new("git");
            cmd.args(&["worktree", "add"])
                .arg(worktree_path)
                .arg(branch_name)
                .current_dir(&self.repo_path);
                
            let output = cmd.output()
                .map_err(|e| anyhow::anyhow!("Failed to create worktree: {}", e))?;
                
            if !output.status.success() {
                let error = String::from_utf8_lossy(&output.stderr);
                return Err(anyhow::anyhow!("Failed to create worktree: {}", error).into());
            }
        } else {
            // Create new branch and worktree
            let mut cmd = Command::new("git");
            cmd.args(&["worktree", "add", "-b", branch_name])
                .arg(worktree_path);
                
            if let Some(commit) = from_commit {
                cmd.arg(commit);
            } else {
                cmd.arg("HEAD");
            }
            
            cmd.current_dir(&self.repo_path);
            
            let output = cmd.output()
                .map_err(|e| anyhow::anyhow!("Failed to create worktree and branch: {}", e))?;
                
            if !output.status.success() {
                let error = String::from_utf8_lossy(&output.stderr);
                return Err(anyhow::anyhow!("Failed to create worktree and branch: {}", error).into());
            }
        }
        
        Ok(())
    }
    
    /// Remove a worktree
    pub fn remove_worktree<P: AsRef<Path>>(&self, worktree_path: P) -> Result<()> {
        use std::process::Command;
        
        let worktree_path = worktree_path.as_ref();
        
        // Remove the worktree using git
        let output = Command::new("git")
            .args(&["worktree", "remove"])
            .arg(worktree_path)
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to remove worktree: {}", e))?;
            
        if !output.status.success() {
            let error = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Failed to remove worktree: {}", error).into());
        }
        
        Ok(())
    }
    
    /// Delete a local branch
    pub fn delete_branch(&self, branch_name: &str, force: bool) -> Result<()> {
        use std::process::Command;
        
        let delete_flag = if force { "-D" } else { "-d" };
        
        let output = Command::new("git")
            .args(&["branch", delete_flag, branch_name])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to delete branch: {}", e))?;
            
        if !output.status.success() {
            let error = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Failed to delete branch {}: {}", branch_name, error).into());
        }
        
        Ok(())
    }
    
    /// Prune worktrees (clean up stale references)
    pub fn prune_worktrees(&self) -> Result<()> {
        use std::process::Command;
        
        let output = Command::new("git")
            .args(&["worktree", "prune"])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to prune worktrees: {}", e))?;
            
        if !output.status.success() {
            let error = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Failed to prune worktrees: {}", error).into());
        }
        
        Ok(())
    }
    
    /// Analyze branches for cleanup
    pub fn analyze_branches_for_cleanup(&self, worktrees: &[WorktreeInfo]) -> Result<Vec<BranchStatus>> {
        use std::process::Command;
        
        let mut branch_statuses = Vec::new();
        
        for worktree in worktrees {
            if worktree.is_primary || worktree.branch.is_empty() {
                continue;
            }
            
            let branch = &worktree.branch;
            let path = &worktree.path;
            
            // Check if branch has a remote
            let has_remote = {
                let output = Command::new("git")
                    .args(&["config", &format!("branch.{}.remote", branch)])
                    .current_dir(&self.repo_path)
                    .output()
                    .map_err(|e| anyhow::anyhow!("Failed to check remote: {}", e))?;
                    
                output.status.success() && !output.stdout.is_empty()
            };
            
            // Check if branch is merged to main/master
            let is_merged = {
                let main_branches = ["main", "master", "develop"];
                let mut merged = false;
                
                for main_branch in &main_branches {
                    let output = Command::new("git")
                        .args(&["merge-base", "--is-ancestor", branch, main_branch])
                        .current_dir(&self.repo_path)
                        .output();
                        
                    if let Ok(output) = output {
                        if output.status.success() {
                            merged = true;
                            break;
                        }
                    }
                }
                merged
            };
            
            // Check if branch is identical to main
            let is_identical = {
                let output = Command::new("git")
                    .args(&["diff", "--quiet", "main", branch])
                    .current_dir(&self.repo_path)
                    .output();
                    
                output.map(|o| o.status.success()).unwrap_or(false)
            };
            
            // Check for uncommitted changes
            let has_uncommitted_changes = {
                let output = Command::new("git")
                    .args(&["status", "--porcelain"])
                    .current_dir(path)
                    .output();
                    
                output.map(|o| !o.stdout.is_empty()).unwrap_or(false)
            };
            
            branch_statuses.push(BranchStatus {
                branch: branch.clone(),
                path: path.clone(),
                has_remote,
                is_merged,
                is_identical,
                has_uncommitted_changes,
            });
        }
        
        Ok(branch_statuses)
    }
    
    /// Fetch from remote
    pub fn fetch_branches(&self) -> Result<bool> {
        use std::process::Command;
        
        let output = Command::new("git")
            .args(&["fetch", "--all", "--prune"])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to fetch: {}", e))?;
            
        if !output.status.success() {
            let error = String::from_utf8_lossy(&output.stderr);
            log::warn!("Git fetch failed: {}", error);
            return Ok(false);
        }
        
        Ok(true)
    }
    
    /// Check if a branch exists
    pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
        use std::process::Command;
        
        let output = Command::new("git")
            .args(&["show-ref", "--verify", "--quiet", &format!("refs/heads/{}", branch_name)])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to check branch existence: {}", e))?;
            
        Ok(output.status.success())
    }
    
    /// Get the current HEAD commit
    pub fn get_head_commit(&self) -> Result<String> {
        use std::process::Command;
        
        let output = Command::new("git")
            .args(&["rev-parse", "HEAD"])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to get HEAD commit: {}", e))?;
            
        if !output.status.success() {
            let error = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Failed to get HEAD commit: {}", error).into());
        }
        
        let commit_hash = String::from_utf8_lossy(&output.stdout)
            .trim()
            .to_string();
            
        Ok(commit_hash)
    }
    
    /// Get the default worktree path for a branch
    pub fn get_worktree_path(&self, branch_name: &str) -> PathBuf {
        self.repo_path.join("../worktrees").join(branch_name)
    }
    
    /// Get the main branch name (main or master)
    pub fn get_main_branch(&self) -> Result<String> {
        use std::process::Command;
        
        // Try to get the default branch from remote
        let output = Command::new("git")
            .args(&["symbolic-ref", "refs/remotes/origin/HEAD"])
            .current_dir(&self.repo_path)
            .output();
            
        if let Ok(output) = output {
            if output.status.success() {
                let branch_ref = String::from_utf8_lossy(&output.stdout);
                if let Some(branch) = branch_ref.trim().strip_prefix("refs/remotes/origin/") {
                    return Ok(branch.to_string());
                }
            }
        }
        
        // Fallback: check if main exists, otherwise use master
        if self.branch_exists("main")? {
            Ok("main".to_string())
        } else {
            Ok("master".to_string())
        }
    }

    /// Check if a directory has uncommitted changes
    pub fn has_uncommitted_changes<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
        use std::process::Command;
        
        let output = Command::new("git")
            .args(&["status", "--porcelain"])
            .current_dir(path.as_ref())
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to check git status: {}", e))?;
            
        if !output.status.success() {
            let error = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Git status failed: {}", error).into());
        }
        
        Ok(!output.stdout.is_empty())
    }

    /// Check if a branch is merged into a target branch
    pub fn is_branch_merged(&self, branch: &str, target_branch: &str) -> Result<bool> {
        use std::process::Command;
        
        let output = Command::new("git")
            .args(&["merge-base", "--is-ancestor", branch, target_branch])
            .current_dir(&self.repo_path)
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to check merge status: {}", e))?;
            
        Ok(output.status.success())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;
    use std::process::Command;
    
    #[test]
    fn test_git_repo_operations() {
        // Create a temporary git repository for testing
        let temp_dir = tempdir().unwrap();
        let repo_path = temp_dir.path();
        
        // Initialize git repo
        Command::new("git")
            .args(&["init"])
            .current_dir(repo_path)
            .output()
            .unwrap();
        
        // Configure git
        Command::new("git")
            .args(&["config", "user.email", "test@example.com"])
            .current_dir(repo_path)
            .output()
            .unwrap();
        
        Command::new("git")
            .args(&["config", "user.name", "Test User"])
            .current_dir(repo_path)
            .output()
            .unwrap();
        
        // Create initial commit
        std::fs::write(repo_path.join("test.txt"), "test").unwrap();
        Command::new("git")
            .args(&["add", "."])
            .current_dir(repo_path)
            .output()
            .unwrap();
        
        Command::new("git")
            .args(&["commit", "-m", "Initial commit"])
            .current_dir(repo_path)
            .output()
            .unwrap();
        
        // Test opening the repository
        let git_repo = GitRepository::open(repo_path);
        assert!(git_repo.is_ok());
    }
}