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 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 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 pub fn root_path(&self) -> &Path {
53 &self.repo_path
54 }
55
56 pub fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
58 use std::process::Command;
59
60 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 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 if let Some(wt) = current_worktree {
106 worktrees.push(wt);
107 }
108
109 Ok(worktrees)
110 }
111
112 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 if self.branch_exists(branch_name)? {
125 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn get_worktree_path(&self, branch_name: &str) -> PathBuf {
360 self.repo_path.join("../worktrees").join(branch_name)
361 }
362
363 pub fn get_main_branch(&self) -> Result<String> {
365 use std::process::Command;
366
367 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 if self.branch_exists("main")? {
384 Ok("main".to_string())
385 } else {
386 Ok("master".to_string())
387 }
388 }
389
390 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 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 let temp_dir = tempdir().unwrap();
432 let repo_path = temp_dir.path();
433
434 Command::new("git")
436 .args(&["init"])
437 .current_dir(repo_path)
438 .output()
439 .unwrap();
440
441 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 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 let git_repo = GitRepository::open(repo_path);
470 assert!(git_repo.is_ok());
471 }
472}