1use std::path::{Path, PathBuf};
2
3use super::command::GitCommand;
4use super::error::GitError;
5
6#[derive(Debug, Clone)]
8pub struct WorktreeEntry {
9 pub path: PathBuf,
10 pub head: String,
11 pub branch: Option<String>,
12 pub is_bare: bool,
13 pub is_locked: bool,
14 pub lock_reason: Option<String>,
15}
16
17#[derive(Debug, Clone)]
20pub struct GitRepo {
21 path: PathBuf,
22}
23
24impl GitRepo {
25 pub fn new(path: impl Into<PathBuf>) -> Self {
26 Self { path: path.into() }
27 }
28
29 pub fn path(&self) -> &Path {
30 &self.path
31 }
32
33 fn git(&self) -> GitCommand<'_> {
34 GitCommand::new(&self.path)
35 }
36
37 pub fn is_git_repo(&self) -> bool {
41 self.path.join(".git").exists()
42 }
43
44 pub fn default_branch(&self) -> Result<String, GitError> {
46 let output = self
48 .git()
49 .args(&["symbolic-ref", "refs/remotes/origin/HEAD", "--short"])
50 .run_unchecked()?;
51
52 if output.exit_code == 0 {
53 let branch = output.stdout.trim().to_string();
54 return Ok(branch
56 .strip_prefix("origin/")
57 .unwrap_or(&branch)
58 .to_string());
59 }
60
61 for name in &["main", "master"] {
63 let output = self
64 .git()
65 .args(&["rev-parse", "--verify", name])
66 .run_unchecked()?;
67 if output.exit_code == 0 {
68 return Ok(name.to_string());
69 }
70 }
71
72 Err(GitError::CommandFailed {
73 command: "detect default branch".to_string(),
74 stderr: "Could not determine default branch. No origin/HEAD, main, or master found."
75 .to_string(),
76 })
77 }
78
79 pub fn is_dirty(&self) -> Result<bool, GitError> {
83 let output = self.git().args(&["status", "--porcelain"]).run()?;
84 Ok(!output.stdout.trim().is_empty())
85 }
86
87 pub fn change_count(&self) -> Result<usize, GitError> {
89 let output = self.git().args(&["status", "--porcelain"]).run()?;
90 Ok(output.stdout.trim().lines().count())
91 }
92
93 pub fn current_branch(&self) -> Result<String, GitError> {
95 let output = self.git().args(&["branch", "--show-current"]).run()?;
96 Ok(output.stdout.trim().to_string())
97 }
98
99 pub fn ahead_behind(&self, base: &str) -> Result<(u32, u32), GitError> {
101 let output = self
102 .git()
103 .args(&[
104 "rev-list",
105 "--left-right",
106 "--count",
107 &format!("{base}...HEAD"),
108 ])
109 .run_unchecked()?;
110
111 if output.exit_code != 0 {
112 return Ok((0, 0)); }
114
115 let parts: Vec<&str> = output.stdout.trim().split('\t').collect();
116 if parts.len() == 2 {
117 let behind = parts[0].parse().unwrap_or(0);
118 let ahead = parts[1].parse().unwrap_or(0);
119 Ok((ahead, behind))
120 } else {
121 Ok((0, 0))
122 }
123 }
124
125 pub fn worktree_add(&self, path: &Path, branch: &str, base: &str) -> Result<(), GitError> {
129 self.git()
130 .args(&[
131 "worktree",
132 "add",
133 &path.to_string_lossy(),
134 "-b",
135 branch,
136 base,
137 ])
138 .run()?;
139 Ok(())
140 }
141
142 pub fn worktree_remove(&self, path: &Path, force: bool) -> Result<(), GitError> {
144 let mut cmd = self.git().args(&["worktree", "remove"]);
145 if force {
146 cmd = cmd.arg("--force");
147 }
148 cmd.arg(&path.to_string_lossy()).run()?;
149 Ok(())
150 }
151
152 pub fn worktree_lock(&self, path: &Path, reason: &str) -> Result<(), GitError> {
154 self.git()
155 .args(&[
156 "worktree",
157 "lock",
158 &path.to_string_lossy(),
159 "--reason",
160 reason,
161 ])
162 .run()?;
163 Ok(())
164 }
165
166 pub fn worktree_unlock(&self, path: &Path) -> Result<(), GitError> {
168 self.git()
169 .args(&["worktree", "unlock", &path.to_string_lossy()])
170 .run()?;
171 Ok(())
172 }
173
174 pub fn worktree_prune(&self) -> Result<(), GitError> {
176 self.git().args(&["worktree", "prune"]).run()?;
177 Ok(())
178 }
179
180 pub fn worktree_list(&self) -> Result<Vec<WorktreeEntry>, GitError> {
182 let output = self
183 .git()
184 .args(&["worktree", "list", "--porcelain"])
185 .run()?;
186
187 Ok(parse_worktree_porcelain(&output.stdout))
188 }
189
190 pub fn branch_delete(&self, name: &str, force: bool) -> Result<(), GitError> {
194 let flag = if force { "-D" } else { "-d" };
195 self.git().args(&["branch", flag, name]).run()?;
196 Ok(())
197 }
198
199 pub fn ref_exists(&self, refspec: &str) -> Result<bool, GitError> {
201 let output = self
202 .git()
203 .args(&["rev-parse", "--verify", refspec])
204 .run_unchecked()?;
205 Ok(output.exit_code == 0)
206 }
207
208 pub fn push_tracking(&self, branch: &str) -> Result<(), GitError> {
212 self.git().args(&["push", "-u", "origin", branch]).run()?;
213 Ok(())
214 }
215
216 pub fn fetch(&self) -> Result<(), GitError> {
218 self.git().args(&["fetch", "origin"]).run()?;
219 Ok(())
220 }
221
222 pub fn resolve_start_point(&self, branch: &str) -> String {
226 let remote_ref = format!("origin/{}", branch);
227 if self.ref_exists(&remote_ref).unwrap_or(false) {
228 remote_ref
229 } else {
230 branch.to_string()
231 }
232 }
233
234 pub fn pull_rebase(&self) -> Result<(), GitError> {
236 self.git().args(&["pull", "--rebase"]).run()?;
237 Ok(())
238 }
239
240 pub fn rebase_abort(&self) -> Result<(), GitError> {
242 self.git().args(&["rebase", "--abort"]).run()?;
243 Ok(())
244 }
245
246 pub fn reset_hard(&self) -> Result<(), GitError> {
248 self.git().args(&["reset", "--hard", "HEAD"]).run()?;
249 Ok(())
250 }
251
252 pub fn reset_hard_to(&self, target: &str) -> Result<(), GitError> {
254 self.git().args(&["reset", "--hard", target]).run()?;
255 Ok(())
256 }
257
258 pub fn clean_untracked(&self) -> Result<(), GitError> {
260 self.git().args(&["clean", "-fd"]).run()?;
261 Ok(())
262 }
263
264 pub fn rebase(&self, target: &str) -> Result<(), GitError> {
266 self.git().args(&["rebase", target]).run()?;
267 Ok(())
268 }
269
270 pub fn add(&self, path: &str) -> Result<(), GitError> {
272 self.git().args(&["add", path]).run()?;
273 Ok(())
274 }
275
276 pub fn commit(&self, message: &str) -> Result<(), GitError> {
278 self.git().args(&["commit", "-m", message]).run()?;
279 Ok(())
280 }
281
282 pub fn push(&self) -> Result<(), GitError> {
284 self.git().args(&["push"]).run()?;
285 Ok(())
286 }
287
288 pub fn remote_url(&self) -> Result<Option<String>, GitError> {
290 let output = self
291 .git()
292 .args(&["remote", "get-url", "origin"])
293 .run_unchecked()?;
294
295 if output.exit_code == 0 {
296 Ok(Some(output.stdout.trim().to_string()))
297 } else {
298 Ok(None)
299 }
300 }
301}
302
303pub fn clone_repo(url: &str, target: &Path) -> Result<(), GitError> {
305 super::command::git_global(&["clone", url, &target.to_string_lossy()])?;
306 Ok(())
307}
308
309pub fn check_git_version() -> Result<String, GitError> {
311 let output = super::command::git_global(&["--version"])?;
312 let version_str = output.stdout.trim();
313
314 let version = version_str
316 .strip_prefix("git version ")
317 .unwrap_or(version_str);
318
319 let parts: Vec<u32> = version.split('.').filter_map(|p| p.parse().ok()).collect();
320
321 let (major, minor) = match parts.as_slice() {
322 [major, minor, ..] => (*major, *minor),
323 [major] => (*major, 0),
324 _ => {
325 return Err(GitError::CommandFailed {
326 command: "git --version".to_string(),
327 stderr: format!("Could not parse git version: {version}"),
328 });
329 }
330 };
331
332 if major < 2 || (major == 2 && minor < 22) {
333 return Err(GitError::VersionTooOld {
334 found: version.to_string(),
335 required: "2.22".to_string(),
336 });
337 }
338
339 Ok(version.to_string())
340}
341
342fn parse_worktree_porcelain(output: &str) -> Vec<WorktreeEntry> {
356 let mut entries = Vec::new();
357 let mut current_path: Option<PathBuf> = None;
358 let mut current_head = String::new();
359 let mut current_branch: Option<String> = None;
360 let mut is_bare = false;
361 let mut is_locked = false;
362 let mut lock_reason: Option<String> = None;
363
364 for line in output.lines() {
365 if line.is_empty() {
366 if let Some(path) = current_path.take() {
368 entries.push(WorktreeEntry {
369 path,
370 head: std::mem::take(&mut current_head),
371 branch: current_branch.take(),
372 is_bare,
373 is_locked,
374 lock_reason: lock_reason.take(),
375 });
376 }
377 is_bare = false;
378 is_locked = false;
379 } else if let Some(path) = line.strip_prefix("worktree ") {
380 current_path = Some(PathBuf::from(path));
381 } else if let Some(head) = line.strip_prefix("HEAD ") {
382 current_head = head.to_string();
383 } else if let Some(branch) = line.strip_prefix("branch ") {
384 current_branch = Some(
386 branch
387 .strip_prefix("refs/heads/")
388 .unwrap_or(branch)
389 .to_string(),
390 );
391 } else if line == "bare" {
392 is_bare = true;
393 } else if line == "locked" {
394 is_locked = true;
395 } else if let Some(reason) = line.strip_prefix("locked ") {
396 is_locked = true;
397 lock_reason = Some(reason.to_string());
398 }
399 }
400
401 if let Some(path) = current_path.take() {
403 entries.push(WorktreeEntry {
404 path,
405 head: current_head,
406 branch: current_branch,
407 is_bare,
408 is_locked,
409 lock_reason,
410 });
411 }
412
413 entries
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_parse_worktree_porcelain_basic() {
422 let output = "\
423worktree /Users/dev/code/repo
424HEAD abc123def456
425branch refs/heads/main
426
427worktree /Users/dev/loom/ws/repo
428HEAD def789abc012
429branch refs/heads/loom/my-feature
430
431";
432 let entries = parse_worktree_porcelain(output);
433 assert_eq!(entries.len(), 2);
434
435 assert_eq!(entries[0].path, PathBuf::from("/Users/dev/code/repo"));
436 assert_eq!(entries[0].head, "abc123def456");
437 assert_eq!(entries[0].branch.as_deref(), Some("main"));
438 assert!(!entries[0].is_bare);
439 assert!(!entries[0].is_locked);
440
441 assert_eq!(entries[1].path, PathBuf::from("/Users/dev/loom/ws/repo"));
442 assert_eq!(entries[1].branch.as_deref(), Some("loom/my-feature"));
443 }
444
445 #[test]
446 fn test_parse_worktree_porcelain_locked() {
447 let output = "\
448worktree /Users/dev/loom/ws/repo
449HEAD def789
450branch refs/heads/loom/feature
451locked loom:my-workspace
452
453";
454 let entries = parse_worktree_porcelain(output);
455 assert_eq!(entries.len(), 1);
456 assert!(entries[0].is_locked);
457 assert_eq!(entries[0].lock_reason.as_deref(), Some("loom:my-workspace"));
458 }
459
460 #[test]
461 fn test_parse_worktree_porcelain_bare() {
462 let output = "\
463worktree /Users/dev/code/repo.git
464HEAD abc123
465bare
466
467";
468 let entries = parse_worktree_porcelain(output);
469 assert_eq!(entries.len(), 1);
470 assert!(entries[0].is_bare);
471 assert!(entries[0].branch.is_none());
472 }
473
474 #[test]
475 fn test_parse_worktree_porcelain_no_trailing_newline() {
476 let output = "\
477worktree /path/to/repo
478HEAD abc123
479branch refs/heads/main";
480
481 let entries = parse_worktree_porcelain(output);
482 assert_eq!(entries.len(), 1);
483 assert_eq!(entries[0].branch.as_deref(), Some("main"));
484 }
485
486 #[test]
487 fn test_check_git_version() {
488 let result = check_git_version();
490 assert!(result.is_ok(), "git should be installed: {result:?}");
491 let version = result.unwrap();
492 assert!(!version.is_empty());
493 }
494
495 #[test]
496 fn test_git_repo_is_git_repo() {
497 let dir = tempfile::tempdir().unwrap();
498
499 let repo = GitRepo::new(dir.path());
501 assert!(!repo.is_git_repo());
502
503 std::process::Command::new("git")
505 .args(["init", &dir.path().to_string_lossy()])
506 .env("LC_ALL", "C")
507 .output()
508 .unwrap();
509
510 assert!(repo.is_git_repo());
511 }
512
513 #[test]
514 fn test_git_repo_current_branch() {
515 let dir = tempfile::tempdir().unwrap();
516 let path = dir.path();
517
518 std::process::Command::new("git")
520 .args(["init", "-b", "main", &path.to_string_lossy()])
521 .env("LC_ALL", "C")
522 .output()
523 .unwrap();
524
525 std::process::Command::new("git")
526 .args([
527 "-C",
528 &path.to_string_lossy(),
529 "commit",
530 "--allow-empty",
531 "-m",
532 "init",
533 ])
534 .env("LC_ALL", "C")
535 .output()
536 .unwrap();
537
538 let repo = GitRepo::new(path);
539 let branch = repo.current_branch().unwrap();
540 assert_eq!(branch, "main");
541 }
542
543 #[test]
544 fn test_git_repo_is_dirty() {
545 let dir = tempfile::tempdir().unwrap();
546 let path = dir.path();
547
548 std::process::Command::new("git")
549 .args(["init", "-b", "main", &path.to_string_lossy()])
550 .env("LC_ALL", "C")
551 .output()
552 .unwrap();
553
554 std::process::Command::new("git")
555 .args([
556 "-C",
557 &path.to_string_lossy(),
558 "commit",
559 "--allow-empty",
560 "-m",
561 "init",
562 ])
563 .env("LC_ALL", "C")
564 .output()
565 .unwrap();
566
567 let repo = GitRepo::new(path);
568
569 assert!(!repo.is_dirty().unwrap());
571
572 std::fs::write(path.join("test.txt"), "hello").unwrap();
574 assert!(repo.is_dirty().unwrap());
575 }
576
577 #[test]
578 fn test_resolve_start_point_no_remote() {
579 let dir = tempfile::tempdir().unwrap();
580 let path = dir.path();
581
582 std::process::Command::new("git")
583 .args(["init", "-b", "main", &path.to_string_lossy()])
584 .env("LC_ALL", "C")
585 .output()
586 .unwrap();
587 std::process::Command::new("git")
588 .args([
589 "-C",
590 &path.to_string_lossy(),
591 "commit",
592 "--allow-empty",
593 "-m",
594 "init",
595 ])
596 .env("LC_ALL", "C")
597 .output()
598 .unwrap();
599
600 let repo = GitRepo::new(path);
601 assert_eq!(repo.resolve_start_point("main"), "main");
603 }
604
605 #[test]
606 fn test_resolve_start_point_with_remote() {
607 let remote_dir = tempfile::tempdir().unwrap();
609 let remote_path = remote_dir.path();
610 std::process::Command::new("git")
611 .args(["init", "-b", "main", &remote_path.to_string_lossy()])
612 .env("LC_ALL", "C")
613 .output()
614 .unwrap();
615 std::process::Command::new("git")
616 .args([
617 "-C",
618 &remote_path.to_string_lossy(),
619 "commit",
620 "--allow-empty",
621 "-m",
622 "init",
623 ])
624 .env("LC_ALL", "C")
625 .output()
626 .unwrap();
627
628 let local_dir = tempfile::tempdir().unwrap();
630 let local_path = local_dir.path();
631 std::process::Command::new("git")
632 .args(["init", "-b", "main", &local_path.to_string_lossy()])
633 .env("LC_ALL", "C")
634 .output()
635 .unwrap();
636 std::process::Command::new("git")
637 .args([
638 "-C",
639 &local_path.to_string_lossy(),
640 "remote",
641 "add",
642 "origin",
643 &remote_path.to_string_lossy(),
644 ])
645 .env("LC_ALL", "C")
646 .output()
647 .unwrap();
648 std::process::Command::new("git")
649 .args(["-C", &local_path.to_string_lossy(), "fetch", "origin"])
650 .env("LC_ALL", "C")
651 .output()
652 .unwrap();
653
654 let repo = GitRepo::new(local_path);
655 assert_eq!(repo.resolve_start_point("main"), "origin/main");
657 }
658
659 #[test]
660 fn test_ref_exists_local_branch() {
661 let dir = tempfile::tempdir().unwrap();
662 let path = dir.path();
663
664 std::process::Command::new("git")
665 .args(["init", "-b", "main", &path.to_string_lossy()])
666 .env("LC_ALL", "C")
667 .output()
668 .unwrap();
669 std::process::Command::new("git")
670 .args([
671 "-C",
672 &path.to_string_lossy(),
673 "commit",
674 "--allow-empty",
675 "-m",
676 "init",
677 ])
678 .env("LC_ALL", "C")
679 .output()
680 .unwrap();
681
682 let repo = GitRepo::new(path);
683 assert!(repo.ref_exists("main").unwrap());
684 assert!(!repo.ref_exists("nonexistent").unwrap());
685 }
686
687 #[test]
688 fn test_ref_exists_remote_ref() {
689 let remote_dir = tempfile::tempdir().unwrap();
691 let remote_path = remote_dir.path();
692 std::process::Command::new("git")
693 .args(["init", "-b", "main", &remote_path.to_string_lossy()])
694 .env("LC_ALL", "C")
695 .output()
696 .unwrap();
697 std::process::Command::new("git")
698 .args([
699 "-C",
700 &remote_path.to_string_lossy(),
701 "commit",
702 "--allow-empty",
703 "-m",
704 "init",
705 ])
706 .env("LC_ALL", "C")
707 .output()
708 .unwrap();
709
710 let local_dir = tempfile::tempdir().unwrap();
712 let local_path = local_dir.path();
713 std::process::Command::new("git")
714 .args(["init", "-b", "main", &local_path.to_string_lossy()])
715 .env("LC_ALL", "C")
716 .output()
717 .unwrap();
718 std::process::Command::new("git")
719 .args([
720 "-C",
721 &local_path.to_string_lossy(),
722 "remote",
723 "add",
724 "origin",
725 &remote_path.to_string_lossy(),
726 ])
727 .env("LC_ALL", "C")
728 .output()
729 .unwrap();
730 std::process::Command::new("git")
731 .args(["-C", &local_path.to_string_lossy(), "fetch", "origin"])
732 .env("LC_ALL", "C")
733 .output()
734 .unwrap();
735
736 let repo = GitRepo::new(local_path);
737 assert!(repo.ref_exists("origin/main").unwrap());
738 assert!(!repo.ref_exists("origin/nonexistent").unwrap());
739 }
740}