Skip to main content

rung_git/
repository.rs

1//! Repository wrapper providing high-level git operations.
2
3use std::path::Path;
4
5use git2::{BranchType, Oid, RepositoryState, Signature};
6
7use crate::error::{Error, Result};
8
9/// Divergence state between a local branch and its tracking remote (upstream, falls back to origin).
10///
11/// This is distinct from `BranchState::Diverged` which tracks divergence from the
12/// *parent branch* (needs sync). `RemoteDivergence` tracks local vs remote (needs push/pull).
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RemoteDivergence {
15    /// Local and remote are at the same commit.
16    InSync,
17    /// Local has commits not on remote (safe push).
18    Ahead {
19        /// Number of commits local is ahead of remote.
20        commits: usize,
21    },
22    /// Remote has commits not on local (need pull).
23    Behind {
24        /// Number of commits local is behind remote.
25        commits: usize,
26    },
27    /// Both have unique commits (need force push after rebase).
28    Diverged {
29        /// Number of commits local is ahead of remote.
30        ahead: usize,
31        /// Number of commits local is behind remote.
32        behind: usize,
33    },
34    /// No remote tracking branch exists (first push).
35    NoRemote,
36}
37
38/// High-level wrapper around a git repository.
39pub struct Repository {
40    inner: git2::Repository,
41}
42
43impl Repository {
44    /// Open a repository at the given path.
45    ///
46    /// # Errors
47    /// Returns error if no repository found at path or any parent.
48    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
49        let inner = git2::Repository::discover(path)?;
50        Ok(Self { inner })
51    }
52
53    /// Open the repository containing the current directory.
54    ///
55    /// # Errors
56    /// Returns error if not inside a git repository.
57    pub fn open_current() -> Result<Self> {
58        Self::open(".")
59    }
60
61    /// Get the path to the repository root (workdir).
62    #[must_use]
63    pub fn workdir(&self) -> Option<&Path> {
64        self.inner.workdir()
65    }
66
67    /// Get the path to the .git directory.
68    #[must_use]
69    pub fn git_dir(&self) -> &Path {
70        self.inner.path()
71    }
72
73    /// Get the current repository state.
74    #[must_use]
75    pub fn state(&self) -> RepositoryState {
76        self.inner.state()
77    }
78
79    /// Check if there's a rebase in progress.
80    #[must_use]
81    pub fn is_rebasing(&self) -> bool {
82        matches!(
83            self.state(),
84            RepositoryState::Rebase
85                | RepositoryState::RebaseInteractive
86                | RepositoryState::RebaseMerge
87        )
88    }
89
90    /// Check if HEAD is detached (not pointing at a branch).
91    ///
92    /// # Errors
93    /// Returns error if HEAD cannot be read (e.g. unborn repo).
94    pub fn head_detached(&self) -> Result<bool> {
95        let head = self.inner.head()?;
96        Ok(!head.is_branch())
97    }
98
99    // === Branch operations ===
100
101    /// Get the name of the current branch.
102    ///
103    /// # Errors
104    /// Returns error if HEAD is detached.
105    pub fn current_branch(&self) -> Result<String> {
106        let head = self.inner.head()?;
107        if !head.is_branch() {
108            return Err(Error::DetachedHead);
109        }
110
111        head.shorthand()
112            .map(String::from)
113            .ok_or(Error::DetachedHead)
114    }
115
116    /// Get the commit SHA for a branch.
117    ///
118    /// # Errors
119    /// Returns error if branch doesn't exist.
120    pub fn branch_commit(&self, branch_name: &str) -> Result<Oid> {
121        let branch = self
122            .inner
123            .find_branch(branch_name, BranchType::Local)
124            .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
125
126        branch
127            .get()
128            .target()
129            .ok_or_else(|| Error::BranchNotFound(branch_name.into()))
130    }
131
132    /// Get the commit ID of a remote branch tip.
133    ///
134    /// Uses the configured upstream if set, otherwise falls back to `origin/<branch>`.
135    ///
136    /// # Errors
137    /// Returns error if branch not found.
138    pub fn remote_branch_commit(&self, branch_name: &str) -> Result<Oid> {
139        // Try configured upstream first, fall back to origin/<branch>
140        let remote_ref = self
141            .branch_upstream_ref(branch_name)
142            .unwrap_or_else(|| format!("refs/remotes/origin/{branch_name}"));
143
144        let reference = self
145            .inner
146            .find_reference(&remote_ref)
147            .map_err(|_| Error::BranchNotFound(remote_ref.clone()))?;
148
149        reference.target().ok_or(Error::BranchNotFound(remote_ref))
150    }
151
152    /// Get the configured upstream ref for a branch, if any.
153    ///
154    /// Returns `None` if no upstream is configured or the branch doesn't exist.
155    /// Uses `branch_upstream_name` to read from git config, which works even when
156    /// the remote-tracking ref doesn't exist locally.
157    fn branch_upstream_ref(&self, branch_name: &str) -> Option<String> {
158        let refname = format!("refs/heads/{branch_name}");
159        let upstream_buf = self.inner.branch_upstream_name(&refname).ok()?;
160        upstream_buf.as_str().map(String::from)
161    }
162
163    /// Create a new branch at the current HEAD.
164    ///
165    /// # Errors
166    /// Returns error if branch creation fails.
167    pub fn create_branch(&self, name: &str) -> Result<Oid> {
168        let head_commit = self.inner.head()?.peel_to_commit()?;
169        let branch = self.inner.branch(name, &head_commit, false)?;
170
171        branch
172            .get()
173            .target()
174            .ok_or_else(|| Error::BranchNotFound(name.into()))
175    }
176
177    /// Checkout a branch.
178    ///
179    /// # Errors
180    /// Returns error if checkout fails.
181    pub fn checkout(&self, branch_name: &str) -> Result<()> {
182        let branch = self
183            .inner
184            .find_branch(branch_name, BranchType::Local)
185            .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
186
187        let reference = branch.get();
188        let object = reference.peel(git2::ObjectType::Commit)?;
189
190        self.inner.checkout_tree(&object, None)?;
191        self.inner.set_head(&format!("refs/heads/{branch_name}"))?;
192
193        Ok(())
194    }
195
196    /// List all local branches.
197    ///
198    /// # Errors
199    /// Returns error if branch listing fails.
200    pub fn list_branches(&self) -> Result<Vec<String>> {
201        let branches = self.inner.branches(Some(BranchType::Local))?;
202
203        let names: Vec<String> = branches
204            .filter_map(std::result::Result::ok)
205            .filter_map(|(b, _)| b.name().ok().flatten().map(String::from))
206            .collect();
207
208        Ok(names)
209    }
210
211    /// Check if a branch exists.
212    #[must_use]
213    pub fn branch_exists(&self, name: &str) -> bool {
214        self.inner.find_branch(name, BranchType::Local).is_ok()
215    }
216
217    /// Delete a local branch.
218    ///
219    /// # Errors
220    /// Returns error if branch deletion fails.
221    pub fn delete_branch(&self, name: &str) -> Result<()> {
222        let mut branch = self.inner.find_branch(name, BranchType::Local)?;
223        branch.delete()?;
224        Ok(())
225    }
226
227    // === Working directory state ===
228
229    /// Check if the working directory is clean (no modified or staged files).
230    ///
231    /// Untracked files are ignored - only tracked files that have been
232    /// modified or staged count as "dirty".
233    ///
234    /// # Errors
235    /// Returns error if status check fails.
236    pub fn is_clean(&self) -> Result<bool> {
237        let mut opts = git2::StatusOptions::new();
238        opts.include_untracked(false)
239            .include_ignored(false)
240            .include_unmodified(false)
241            .exclude_submodules(true);
242        let statuses = self.inner.statuses(Some(&mut opts))?;
243
244        // Check if any status indicates modified/staged files
245        for entry in statuses.iter() {
246            let status = entry.status();
247            // These indicate actual changes to tracked files
248            if status.intersects(
249                git2::Status::INDEX_NEW
250                    | git2::Status::INDEX_MODIFIED
251                    | git2::Status::INDEX_DELETED
252                    | git2::Status::INDEX_RENAMED
253                    | git2::Status::INDEX_TYPECHANGE
254                    | git2::Status::WT_MODIFIED
255                    | git2::Status::WT_DELETED
256                    | git2::Status::WT_TYPECHANGE
257                    | git2::Status::WT_RENAMED,
258            ) {
259                return Ok(false);
260            }
261        }
262        Ok(true)
263    }
264
265    /// Ensure working directory is clean, returning error if not.
266    ///
267    /// # Errors
268    /// Returns `DirtyWorkingDirectory` if there are uncommitted changes.
269    pub fn require_clean(&self) -> Result<()> {
270        if self.is_clean()? {
271            Ok(())
272        } else {
273            Err(Error::DirtyWorkingDirectory)
274        }
275    }
276
277    // === Staging operations ===
278
279    /// Stage all changes (tracked and untracked files).
280    ///
281    /// Equivalent to `git add -A`.
282    ///
283    /// # Errors
284    /// Returns error if staging fails.
285    pub fn stage_all(&self) -> Result<()> {
286        let workdir = self.workdir().ok_or(Error::NotARepository)?;
287
288        let output = std::process::Command::new("git")
289            .args(["add", "-A"])
290            .current_dir(workdir)
291            .output()
292            .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
293
294        if output.status.success() {
295            Ok(())
296        } else {
297            let stderr = String::from_utf8_lossy(&output.stderr);
298            Err(Error::Git2(git2::Error::from_str(&stderr)))
299        }
300    }
301
302    /// Check if there are staged changes ready to commit.
303    ///
304    /// # Errors
305    /// Returns error if status check fails.
306    pub fn has_staged_changes(&self) -> Result<bool> {
307        let mut opts = git2::StatusOptions::new();
308        opts.include_untracked(false)
309            .include_ignored(false)
310            .include_unmodified(false);
311        let statuses = self.inner.statuses(Some(&mut opts))?;
312
313        for entry in statuses.iter() {
314            let status = entry.status();
315            if status.intersects(
316                git2::Status::INDEX_NEW
317                    | git2::Status::INDEX_MODIFIED
318                    | git2::Status::INDEX_DELETED
319                    | git2::Status::INDEX_RENAMED
320                    | git2::Status::INDEX_TYPECHANGE,
321            ) {
322                return Ok(true);
323            }
324        }
325        Ok(false)
326    }
327
328    /// Create a commit with the given message on HEAD.
329    ///
330    /// Handles both normal commits (with parent) and initial commits (no parent).
331    ///
332    /// # Errors
333    /// Returns error if commit creation fails.
334    pub fn create_commit(&self, message: &str) -> Result<Oid> {
335        let sig = self.signature()?;
336        let mut index = self.inner.index()?;
337        let tree_id = index.write_tree()?;
338        let tree = self.inner.find_tree(tree_id)?;
339
340        // Handle initial commit case (unborn HEAD)
341        let oid = match self.inner.head().and_then(|h| h.peel_to_commit()) {
342            Ok(parent) => {
343                self.inner
344                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
345            }
346            Err(_) => {
347                // Initial commit - no parent
348                self.inner
349                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
350            }
351        };
352
353        Ok(oid)
354    }
355
356    // === Commit operations ===
357
358    /// Get a commit by its SHA.
359    ///
360    /// # Errors
361    /// Returns error if commit not found.
362    pub fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>> {
363        Ok(self.inner.find_commit(oid)?)
364    }
365
366    /// Get the commit message from a branch's tip commit.
367    ///
368    /// # Errors
369    /// Returns error if branch doesn't exist or has no commits.
370    pub fn branch_commit_message(&self, branch_name: &str) -> Result<String> {
371        let oid = self.branch_commit(branch_name)?;
372        let commit = self.inner.find_commit(oid)?;
373        commit
374            .message()
375            .map(String::from)
376            .ok_or_else(|| Error::Git2(git2::Error::from_str("commit has no message")))
377    }
378
379    /// Get the merge base between two commits.
380    ///
381    /// # Errors
382    /// Returns error if merge base calculation fails.
383    pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
384        Ok(self.inner.merge_base(one, two)?)
385    }
386
387    /// Count commits between two points.
388    ///
389    /// # Errors
390    /// Returns error if revwalk fails.
391    pub fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
392        let mut revwalk = self.inner.revwalk()?;
393        revwalk.push(to)?;
394        revwalk.hide(from)?;
395
396        Ok(revwalk.count())
397    }
398
399    /// Get commits between two points.
400    ///
401    /// # Errors
402    /// Return error if revwalk fails.
403    pub fn commits_between(&self, from: Oid, to: Oid) -> Result<Vec<Oid>> {
404        let mut revwalk = self.inner.revwalk()?;
405        revwalk.push(to)?;
406        revwalk.hide(from)?;
407
408        let mut commits = Vec::new();
409        for oid in revwalk {
410            let oid = oid?;
411            commits.push(oid);
412        }
413
414        Ok(commits)
415    }
416
417    // === Reset operations ===
418
419    /// Hard reset a branch to a specific commit.
420    ///
421    /// # Errors
422    /// Returns error if reset fails.
423    pub fn reset_branch(&self, branch_name: &str, target: Oid) -> Result<()> {
424        let commit = self.inner.find_commit(target)?;
425        let reference_name = format!("refs/heads/{branch_name}");
426
427        self.inner.reference(
428            &reference_name,
429            target,
430            true, // force
431            &format!("rung: reset to {}", &target.to_string()[..8]),
432        )?;
433
434        // If this is the current branch, also update working directory
435        if self.current_branch().ok().as_deref() == Some(branch_name) {
436            self.inner
437                .reset(commit.as_object(), git2::ResetType::Hard, None)?;
438        }
439
440        Ok(())
441    }
442
443    // === Signature ===
444
445    /// Get the default signature for commits.
446    ///
447    /// # Errors
448    /// Returns error if git config doesn't have user.name/email.
449    pub fn signature(&self) -> Result<Signature<'_>> {
450        Ok(self.inner.signature()?)
451    }
452
453    // === Rebase operations ===
454
455    /// Rebase the current branch onto a target commit.
456    ///
457    /// Returns `Ok(())` on success, or `Err(RebaseConflict)` if there are conflicts.
458    ///
459    /// # Errors
460    /// Returns error if rebase fails or conflicts occur.
461    pub fn rebase_onto(&self, target: Oid) -> Result<()> {
462        let workdir = self.workdir().ok_or(Error::NotARepository)?;
463
464        let output = std::process::Command::new("git")
465            .args(["rebase", &target.to_string()])
466            .current_dir(workdir)
467            .output()
468            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
469
470        if output.status.success() {
471            return Ok(());
472        }
473
474        // Check if it's a conflict
475        if self.is_rebasing() {
476            let conflicts = self.conflicting_files()?;
477            return Err(Error::RebaseConflict(conflicts));
478        }
479
480        let stderr = String::from_utf8_lossy(&output.stderr);
481        Err(Error::RebaseFailed(stderr.to_string()))
482    }
483
484    /// Rebase the current branch onto a new base, replaying only commits after `old_base`.
485    ///
486    /// This is equivalent to `git rebase --onto <new_base> <old_base>`.
487    /// Use this when the `old_base` was squash-merged and you want to bring only
488    /// the unique commits from the current branch.
489    ///
490    /// # Errors
491    /// Returns error if rebase fails or conflicts occur.
492    pub fn rebase_onto_from(&self, new_base: Oid, old_base: Oid) -> Result<()> {
493        let workdir = self.workdir().ok_or(Error::NotARepository)?;
494
495        let output = std::process::Command::new("git")
496            .args([
497                "rebase",
498                "--onto",
499                &new_base.to_string(),
500                &old_base.to_string(),
501            ])
502            .current_dir(workdir)
503            .output()
504            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
505
506        if output.status.success() {
507            return Ok(());
508        }
509
510        // Check if it's a conflict
511        if self.is_rebasing() {
512            let conflicts = self.conflicting_files()?;
513            return Err(Error::RebaseConflict(conflicts));
514        }
515
516        let stderr = String::from_utf8_lossy(&output.stderr);
517        Err(Error::RebaseFailed(stderr.to_string()))
518    }
519
520    /// Get list of files with conflicts.
521    ///
522    /// # Errors
523    /// Returns error if status check fails.
524    pub fn conflicting_files(&self) -> Result<Vec<String>> {
525        let statuses = self.inner.statuses(None)?;
526        let conflicts: Vec<String> = statuses
527            .iter()
528            .filter(|s| s.status().is_conflicted())
529            .filter_map(|s| s.path().map(String::from))
530            .collect();
531        Ok(conflicts)
532    }
533
534    /// Abort an in-progress rebase.
535    ///
536    /// # Errors
537    /// Returns error if abort fails.
538    pub fn rebase_abort(&self) -> Result<()> {
539        let workdir = self.workdir().ok_or(Error::NotARepository)?;
540
541        let output = std::process::Command::new("git")
542            .args(["rebase", "--abort"])
543            .current_dir(workdir)
544            .output()
545            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
546
547        if output.status.success() {
548            Ok(())
549        } else {
550            let stderr = String::from_utf8_lossy(&output.stderr);
551            Err(Error::RebaseFailed(stderr.to_string()))
552        }
553    }
554
555    /// Continue an in-progress rebase.
556    ///
557    /// # Errors
558    /// Returns error if continue fails or new conflicts occur.
559    pub fn rebase_continue(&self) -> Result<()> {
560        let workdir = self.workdir().ok_or(Error::NotARepository)?;
561
562        let output = std::process::Command::new("git")
563            .args(["rebase", "--continue"])
564            .current_dir(workdir)
565            .output()
566            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
567
568        if output.status.success() {
569            return Ok(());
570        }
571
572        // Check if it's a conflict
573        if self.is_rebasing() {
574            let conflicts = self.conflicting_files()?;
575            return Err(Error::RebaseConflict(conflicts));
576        }
577
578        let stderr = String::from_utf8_lossy(&output.stderr);
579        Err(Error::RebaseFailed(stderr.to_string()))
580    }
581
582    // === Remote operations ===
583
584    /// Check how a local branch relates to its remote counterpart.
585    ///
586    /// Uses the configured upstream if set, otherwise falls back to `origin/<branch>`.
587    /// Compares the local branch tip with the remote tracking branch to determine
588    /// if the local branch is ahead, behind, diverged, or in sync with the remote.
589    ///
590    /// Uses `graph_ahead_behind` for efficient single-traversal computation.
591    ///
592    /// # Errors
593    /// Returns error if branch doesn't exist or git operations fail.
594    pub fn remote_divergence(&self, branch: &str) -> Result<RemoteDivergence> {
595        let local = self.branch_commit(branch)?;
596
597        // Try to get remote - NoRemote if doesn't exist
598        let remote = match self.remote_branch_commit(branch) {
599            Ok(oid) => oid,
600            Err(Error::BranchNotFound(_)) => return Ok(RemoteDivergence::NoRemote),
601            Err(e) => return Err(e),
602        };
603
604        if local == remote {
605            return Ok(RemoteDivergence::InSync);
606        }
607
608        // Use graph_ahead_behind for efficient single-traversal computation.
609        // NotFound means no merge base (unrelated histories) - treat as (0, 0).
610        let (ahead, behind) = match self.inner.graph_ahead_behind(local, remote) {
611            Ok(counts) => counts,
612            Err(e) if e.code() == git2::ErrorCode::NotFound => (0, 0),
613            Err(e) => return Err(Error::Git2(e)),
614        };
615
616        // Unrelated histories: (0, 0) but local != remote. Count all commits on each side.
617        if ahead == 0 && behind == 0 {
618            return Ok(RemoteDivergence::Diverged {
619                ahead: self.count_all_commits(local)?,
620                behind: self.count_all_commits(remote)?,
621            });
622        }
623
624        Ok(match (ahead, behind) {
625            (a, 0) => RemoteDivergence::Ahead { commits: a },
626            (0, b) => RemoteDivergence::Behind { commits: b },
627            (a, b) => RemoteDivergence::Diverged {
628                ahead: a,
629                behind: b,
630            },
631        })
632    }
633
634    /// Count all commits reachable from a given commit.
635    ///
636    /// Used for unrelated histories where there's no merge base.
637    fn count_all_commits(&self, from: Oid) -> Result<usize> {
638        let mut revwalk = self.inner.revwalk()?;
639        revwalk.push(from)?;
640        Ok(revwalk.count())
641    }
642
643    /// Get the URL of the origin remote.
644    ///
645    /// # Errors
646    /// Returns error if origin remote is not found.
647    pub fn origin_url(&self) -> Result<String> {
648        let remote = self
649            .inner
650            .find_remote("origin")
651            .map_err(|_| Error::RemoteNotFound("origin".into()))?;
652
653        remote
654            .url()
655            .map(String::from)
656            .ok_or_else(|| Error::RemoteNotFound("origin".into()))
657    }
658
659    /// Detect the default branch from the remote's HEAD.
660    ///
661    /// Checks `refs/remotes/origin/HEAD` to determine the remote's default branch.
662    /// Returns `None` if the remote HEAD is not set (e.g., fresh clone without `--set-upstream`).
663    #[must_use]
664    pub fn detect_default_branch(&self) -> Option<String> {
665        // Try to resolve refs/remotes/origin/HEAD which points to the default branch
666        let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?;
667
668        // Resolve the symbolic reference to get the actual branch
669        let resolved = reference.resolve().ok()?;
670        let name = resolved.name()?;
671
672        // Extract branch name from "refs/remotes/origin/main" -> "main"
673        name.strip_prefix("refs/remotes/origin/").map(String::from)
674    }
675
676    /// Parse owner and repo name from a GitHub URL.
677    ///
678    /// Supports both HTTPS and SSH URLs:
679    /// - `https://github.com/owner/repo.git`
680    /// - `git@github.com:owner/repo.git`
681    ///
682    /// # Errors
683    /// Returns error if URL cannot be parsed.
684    pub fn parse_github_remote(url: &str) -> Result<(String, String)> {
685        // SSH format: git@github.com:owner/repo.git
686        if let Some(rest) = url.strip_prefix("git@github.com:") {
687            let path = rest.strip_suffix(".git").unwrap_or(rest);
688            if let Some((owner, repo)) = path.split_once('/') {
689                return Ok((owner.to_string(), repo.to_string()));
690            }
691        }
692
693        // HTTPS format: https://github.com/owner/repo.git
694        if let Some(rest) = url
695            .strip_prefix("https://github.com/")
696            .or_else(|| url.strip_prefix("http://github.com/"))
697        {
698            let path = rest.strip_suffix(".git").unwrap_or(rest);
699            if let Some((owner, repo)) = path.split_once('/') {
700                return Ok((owner.to_string(), repo.to_string()));
701            }
702        }
703
704        Err(Error::InvalidRemoteUrl(url.to_string()))
705    }
706
707    /// Push a branch to the remote.
708    ///
709    /// # Errors
710    /// Returns error if push fails.
711    pub fn push(&self, branch: &str, force: bool) -> Result<()> {
712        let workdir = self.workdir().ok_or(Error::NotARepository)?;
713
714        let mut args = vec!["push", "-u", "origin", branch];
715        if force {
716            args.insert(1, "--force-with-lease");
717        }
718
719        let output = std::process::Command::new("git")
720            .args(&args)
721            .current_dir(workdir)
722            .output()
723            .map_err(|e| Error::PushFailed(e.to_string()))?;
724
725        if output.status.success() {
726            Ok(())
727        } else {
728            let stderr = String::from_utf8_lossy(&output.stderr);
729            Err(Error::PushFailed(stderr.to_string()))
730        }
731    }
732
733    /// Fetch all remote tracking refs from origin.
734    ///
735    /// # Errors
736    /// Returns error if fetch fails.
737    pub fn fetch_all(&self) -> Result<()> {
738        let workdir = self.workdir().ok_or(Error::NotARepository)?;
739
740        let output = std::process::Command::new("git")
741            .args(["fetch", "origin", "--prune"])
742            .current_dir(workdir)
743            .output()
744            .map_err(|e| Error::FetchFailed(e.to_string()))?;
745
746        if output.status.success() {
747            Ok(())
748        } else {
749            let stderr = String::from_utf8_lossy(&output.stderr);
750            Err(Error::FetchFailed(stderr.to_string()))
751        }
752    }
753
754    /// Fetch a branch from origin.
755    ///
756    /// # Errors
757    /// Returns error if fetch fails.
758    pub fn fetch(&self, branch: &str) -> Result<()> {
759        let workdir = self.workdir().ok_or(Error::NotARepository)?;
760
761        // Use refspec to update both remote tracking branch and local branch
762        // Format: origin/branch:refs/heads/branch
763        let refspec = format!("{branch}:refs/heads/{branch}");
764        let output = std::process::Command::new("git")
765            .args(["fetch", "origin", &refspec])
766            .current_dir(workdir)
767            .output()
768            .map_err(|e| Error::FetchFailed(e.to_string()))?;
769
770        if output.status.success() {
771            Ok(())
772        } else {
773            let stderr = String::from_utf8_lossy(&output.stderr);
774            Err(Error::FetchFailed(stderr.to_string()))
775        }
776    }
777
778    /// Pull (fast-forward only) the current branch from origin.
779    ///
780    /// This fetches and merges `origin/<branch>` into the current branch,
781    /// but only if it can be fast-forwarded.
782    ///
783    /// # Errors
784    /// Returns error if pull fails or fast-forward is not possible.
785    pub fn pull_ff(&self) -> Result<()> {
786        let workdir = self.workdir().ok_or(Error::NotARepository)?;
787
788        let output = std::process::Command::new("git")
789            .args(["pull", "--ff-only"])
790            .current_dir(workdir)
791            .output()
792            .map_err(|e| Error::FetchFailed(e.to_string()))?;
793
794        if output.status.success() {
795            Ok(())
796        } else {
797            let stderr = String::from_utf8_lossy(&output.stderr);
798            Err(Error::FetchFailed(stderr.to_string()))
799        }
800    }
801
802    // === Low-level access ===
803
804    /// Get a reference to the underlying git2 repository.
805    ///
806    /// Use sparingly - prefer high-level methods.
807    #[must_use]
808    pub const fn inner(&self) -> &git2::Repository {
809        &self.inner
810    }
811}
812
813impl std::fmt::Debug for Repository {
814    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
815        f.debug_struct("Repository")
816            .field("path", &self.git_dir())
817            .finish()
818    }
819}
820
821#[cfg(test)]
822#[allow(clippy::unwrap_used)]
823mod tests {
824    use super::*;
825    use std::fs;
826    use tempfile::TempDir;
827
828    fn init_test_repo() -> (TempDir, Repository) {
829        let temp = TempDir::new().unwrap();
830        let repo = git2::Repository::init(temp.path()).unwrap();
831
832        // Create initial commit with owned signature (avoids borrowing repo)
833        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
834        let tree_id = repo.index().unwrap().write_tree().unwrap();
835        let tree = repo.find_tree(tree_id).unwrap();
836        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
837            .unwrap();
838        drop(tree);
839
840        let wrapped = Repository { inner: repo };
841        (temp, wrapped)
842    }
843
844    #[test]
845    fn test_current_branch() {
846        let (_temp, repo) = init_test_repo();
847        // Default branch after init
848        let branch = repo.current_branch().unwrap();
849        assert!(branch == "main" || branch == "master");
850    }
851
852    #[test]
853    fn test_create_and_checkout_branch() {
854        let (_temp, repo) = init_test_repo();
855
856        repo.create_branch("feature/test").unwrap();
857        assert!(repo.branch_exists("feature/test"));
858
859        repo.checkout("feature/test").unwrap();
860        assert_eq!(repo.current_branch().unwrap(), "feature/test");
861    }
862
863    #[test]
864    fn test_is_clean() {
865        let (temp, repo) = init_test_repo();
866
867        assert!(repo.is_clean().unwrap());
868
869        // Create and commit a tracked file
870        fs::write(temp.path().join("test.txt"), "initial").unwrap();
871        {
872            let mut index = repo.inner.index().unwrap();
873            index.add_path(std::path::Path::new("test.txt")).unwrap();
874            index.write().unwrap();
875            let tree_id = index.write_tree().unwrap();
876            let tree = repo.inner.find_tree(tree_id).unwrap();
877            let parent = repo.inner.head().unwrap().peel_to_commit().unwrap();
878            let sig = git2::Signature::now("Test", "test@example.com").unwrap();
879            repo.inner
880                .commit(Some("HEAD"), &sig, &sig, "Add test file", &tree, &[&parent])
881                .unwrap();
882        }
883
884        // Should still be clean after commit
885        assert!(repo.is_clean().unwrap());
886
887        // Modify tracked file
888        fs::write(temp.path().join("test.txt"), "modified").unwrap();
889        assert!(!repo.is_clean().unwrap());
890    }
891
892    #[test]
893    fn test_list_branches() {
894        let (_temp, repo) = init_test_repo();
895
896        repo.create_branch("feature/a").unwrap();
897        repo.create_branch("feature/b").unwrap();
898
899        let branches = repo.list_branches().unwrap();
900        assert!(branches.len() >= 3); // main/master + 2 features
901        assert!(branches.iter().any(|b| b == "feature/a"));
902        assert!(branches.iter().any(|b| b == "feature/b"));
903    }
904}