Skip to main content

loom_core/git/
repo.rs

1use std::path::{Path, PathBuf};
2
3use super::command::GitCommand;
4use super::error::GitError;
5
6/// Parsed entry from `git worktree list --porcelain`
7#[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/// A git repository handle providing typed methods for git operations.
18/// All operations shell out to `git -C {path}` with `LC_ALL=C`.
19#[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    // --- Repository checks ---
38
39    /// Check if the path is a git repository (has .git directory or file)
40    pub fn is_git_repo(&self) -> bool {
41        self.path.join(".git").exists()
42    }
43
44    /// Get the default branch name (main or master)
45    pub fn default_branch(&self) -> Result<String, GitError> {
46        // Try refs/remotes/origin/HEAD first
47        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            // Strip "origin/" prefix if present
55            return Ok(branch
56                .strip_prefix("origin/")
57                .unwrap_or(&branch)
58                .to_string());
59        }
60
61        // Fallback: check if main or master exists
62        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    // --- Status ---
80
81    /// Check if the working tree has uncommitted changes
82    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    /// Count the number of changed files (staged + unstaged + untracked).
88    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    /// Get the current branch name (empty string if detached HEAD)
94    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    /// Get ahead/behind counts relative to a base branch
100    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)); // Can't compare, return zero
113        }
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    // --- Worktree operations ---
126
127    /// Add a new worktree
128    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    /// Remove a worktree
143    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    /// Lock a worktree with a reason
153    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    /// Unlock a worktree
167    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    /// Prune stale worktree entries
175    pub fn worktree_prune(&self) -> Result<(), GitError> {
176        self.git().args(&["worktree", "prune"]).run()?;
177        Ok(())
178    }
179
180    /// List worktrees using `--porcelain` format for reliable parsing
181    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    // --- Branch operations ---
191
192    /// Delete a local branch
193    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    /// Check if a ref exists (local branch, remote ref, tag, or arbitrary ref).
200    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    // --- Remote operations ---
209
210    /// Push a branch and set up tracking
211    pub fn push_tracking(&self, branch: &str) -> Result<(), GitError> {
212        self.git().args(&["push", "-u", "origin", branch]).run()?;
213        Ok(())
214    }
215
216    /// Fetch from origin
217    pub fn fetch(&self) -> Result<(), GitError> {
218        self.git().args(&["fetch", "origin"]).run()?;
219        Ok(())
220    }
221
222    /// Resolve the best available start point for a new worktree.
223    /// Prefers origin/{branch} (freshest state), falls back to local {branch}.
224    /// Call after `fetch()` to ensure remote refs are up-to-date.
225    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    /// Pull with rebase from origin
235    pub fn pull_rebase(&self) -> Result<(), GitError> {
236        self.git().args(&["pull", "--rebase"]).run()?;
237        Ok(())
238    }
239
240    /// Abort a rebase in progress
241    pub fn rebase_abort(&self) -> Result<(), GitError> {
242        self.git().args(&["rebase", "--abort"]).run()?;
243        Ok(())
244    }
245
246    /// Hard reset to HEAD (discard all staged and unstaged changes)
247    pub fn reset_hard(&self) -> Result<(), GitError> {
248        self.git().args(&["reset", "--hard", "HEAD"]).run()?;
249        Ok(())
250    }
251
252    /// Hard reset to a specific ref
253    pub fn reset_hard_to(&self, target: &str) -> Result<(), GitError> {
254        self.git().args(&["reset", "--hard", target]).run()?;
255        Ok(())
256    }
257
258    /// Remove untracked files and directories
259    pub fn clean_untracked(&self) -> Result<(), GitError> {
260        self.git().args(&["clean", "-fd"]).run()?;
261        Ok(())
262    }
263
264    /// Rebase current branch onto a target ref
265    pub fn rebase(&self, target: &str) -> Result<(), GitError> {
266        self.git().args(&["rebase", target]).run()?;
267        Ok(())
268    }
269
270    /// Stage a file
271    pub fn add(&self, path: &str) -> Result<(), GitError> {
272        self.git().args(&["add", path]).run()?;
273        Ok(())
274    }
275
276    /// Commit with a message
277    pub fn commit(&self, message: &str) -> Result<(), GitError> {
278        self.git().args(&["commit", "-m", message]).run()?;
279        Ok(())
280    }
281
282    /// Push to origin (current branch)
283    pub fn push(&self) -> Result<(), GitError> {
284        self.git().args(&["push"]).run()?;
285        Ok(())
286    }
287
288    /// Get the remote URL for origin
289    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
303/// Clone a repository (no GitRepo context needed)
304pub fn clone_repo(url: &str, target: &Path) -> Result<(), GitError> {
305    super::command::git_global(&["clone", url, &target.to_string_lossy()])?;
306    Ok(())
307}
308
309/// Check git version and return it. Errors if < minimum.
310pub fn check_git_version() -> Result<String, GitError> {
311    let output = super::command::git_global(&["--version"])?;
312    let version_str = output.stdout.trim();
313
314    // Parse "git version 2.43.0" format
315    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
342/// Parse `git worktree list --porcelain` output into structured entries.
343///
344/// Porcelain format example:
345/// ```text
346/// worktree /path/to/main
347/// HEAD abc1234
348/// branch refs/heads/main
349///
350/// worktree /path/to/feature
351/// HEAD def5678
352/// branch refs/heads/feature
353/// locked reason: loom:my-workspace
354/// ```
355fn 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            // End of entry
367            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            // Strip refs/heads/ prefix
385            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    // Handle last entry (if output doesn't end with blank line)
402    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        // This test requires git to be installed
489        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        // Not a git repo
500        let repo = GitRepo::new(dir.path());
501        assert!(!repo.is_git_repo());
502
503        // Init it
504        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        // Init repo with initial commit
519        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        // Clean state
570        assert!(!repo.is_dirty().unwrap());
571
572        // Create untracked file
573        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        // No remote configured — should fall back to local branch name
602        assert_eq!(repo.resolve_start_point("main"), "main");
603    }
604
605    #[test]
606    fn test_resolve_start_point_with_remote() {
607        // Create "remote" repo
608        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        // Create local repo with remote pointing to the above
629        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        // Remote ref exists after fetch — should return origin/main
656        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        // Create "remote" repo
690        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        // Create local repo with remote
711        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}