Skip to main content

omni_dev/git/
repository.rs

1//! Git repository operations.
2
3use anyhow::{Context, Result};
4use git2::{Repository, Status};
5use tracing::{debug, error, info};
6
7use crate::git::CommitInfo;
8
9/// Git repository wrapper.
10pub struct GitRepository {
11    repo: Repository,
12}
13
14/// Working directory status.
15#[derive(Debug)]
16pub struct WorkingDirectoryStatus {
17    /// Whether the working directory has no changes.
18    pub clean: bool,
19    /// List of files with uncommitted changes.
20    pub untracked_changes: Vec<FileStatus>,
21}
22
23/// File status information.
24#[derive(Debug)]
25pub struct FileStatus {
26    /// Git status flags (e.g., "AM", "??", "M ").
27    pub status: String,
28    /// Path to the file relative to repository root.
29    pub file: String,
30}
31
32impl GitRepository {
33    /// Opens a repository at the current directory.
34    pub fn open() -> Result<Self> {
35        let repo = Repository::open(".").context("Not in a git repository")?;
36
37        Ok(Self { repo })
38    }
39
40    /// Opens a repository at the specified path.
41    pub fn open_at<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
42        let repo = Repository::open(path).context("Failed to open git repository")?;
43
44        Ok(Self { repo })
45    }
46
47    /// Returns the working directory status.
48    pub fn get_working_directory_status(&self) -> Result<WorkingDirectoryStatus> {
49        let statuses = self
50            .repo
51            .statuses(None)
52            .context("Failed to get repository status")?;
53
54        let mut untracked_changes = Vec::new();
55
56        for entry in statuses.iter() {
57            if let Ok(path) = entry.path() {
58                let status_flags = entry.status();
59
60                // Skip ignored files - they should not affect clean status
61                if status_flags.contains(Status::IGNORED) {
62                    continue;
63                }
64
65                let status_str = format_status_flags(status_flags);
66
67                untracked_changes.push(FileStatus {
68                    status: status_str,
69                    file: path.to_string(),
70                });
71            }
72        }
73
74        let clean = untracked_changes.is_empty();
75
76        Ok(WorkingDirectoryStatus {
77            clean,
78            untracked_changes,
79        })
80    }
81
82    /// Checks if the working directory is clean.
83    pub fn is_working_directory_clean(&self) -> Result<bool> {
84        let status = self.get_working_directory_status()?;
85        Ok(status.clean)
86    }
87
88    /// Returns the repository path.
89    pub fn path(&self) -> &std::path::Path {
90        self.repo.path()
91    }
92
93    /// Returns the workdir path.
94    pub fn workdir(&self) -> Option<&std::path::Path> {
95        self.repo.workdir()
96    }
97
98    /// Returns access to the underlying `git2::Repository`.
99    pub fn repository(&self) -> &Repository {
100        &self.repo
101    }
102
103    /// Returns the current branch name.
104    pub fn get_current_branch(&self) -> Result<String> {
105        let head = self.repo.head().context("Failed to get HEAD reference")?;
106
107        if let Ok(name) = head.shorthand() {
108            if name != "HEAD" {
109                return Ok(name.to_string());
110            }
111        }
112
113        anyhow::bail!("Repository is in detached HEAD state")
114    }
115
116    /// Checks if a branch exists.
117    pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
118        // Check if it exists as a local branch
119        if self
120            .repo
121            .find_branch(branch_name, git2::BranchType::Local)
122            .is_ok()
123        {
124            return Ok(true);
125        }
126
127        // Check if it exists as a remote branch
128        if self
129            .repo
130            .find_branch(branch_name, git2::BranchType::Remote)
131            .is_ok()
132        {
133            return Ok(true);
134        }
135
136        // Check if we can resolve it as a reference
137        if self.repo.revparse_single(branch_name).is_ok() {
138            return Ok(true);
139        }
140
141        Ok(false)
142    }
143
144    /// Parses a commit range and returns the commits.
145    pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
146        let mut commits = Vec::new();
147
148        if range == "HEAD" {
149            // Single HEAD commit
150            let head = self.repo.head().context("Failed to get HEAD")?;
151            let commit = head
152                .peel_to_commit()
153                .context("Failed to peel HEAD to commit")?;
154            commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
155        } else if range.contains("..") {
156            // Range format like HEAD~3..HEAD
157            let parts: Vec<&str> = range.split("..").collect();
158            if parts.len() != 2 {
159                anyhow::bail!("Invalid range format: {range}");
160            }
161
162            let start_spec = parts[0];
163            let end_spec = parts[1];
164
165            // Parse start and end commits
166            let start_obj = self
167                .repo
168                .revparse_single(start_spec)
169                .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
170            let end_obj = self
171                .repo
172                .revparse_single(end_spec)
173                .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
174
175            let start_commit = start_obj
176                .peel_to_commit()
177                .context("Failed to peel start object to commit")?;
178            let end_commit = end_obj
179                .peel_to_commit()
180                .context("Failed to peel end object to commit")?;
181
182            // Walk from end_commit back to start_commit (exclusive)
183            let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
184            walker
185                .push(end_commit.id())
186                .context("Failed to push end commit")?;
187            walker
188                .hide(start_commit.id())
189                .context("Failed to hide start commit")?;
190
191            for oid in walker {
192                let oid = oid.context("Failed to get commit OID from walker")?;
193                let commit = self
194                    .repo
195                    .find_commit(oid)
196                    .context("Failed to find commit")?;
197
198                // Skip merge commits
199                if commit.parent_count() > 1 {
200                    continue;
201                }
202
203                commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
204            }
205
206            // Reverse to get chronological order (oldest first)
207            commits.reverse();
208        } else {
209            // Single commit by hash or reference
210            let obj = self
211                .repo
212                .revparse_single(range)
213                .with_context(|| format!("Failed to parse commit: {range}"))?;
214            let commit = obj
215                .peel_to_commit()
216                .context("Failed to peel object to commit")?;
217            commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
218        }
219
220        Ok(commits)
221    }
222}
223
224/// Formats git status flags into a string representation.
225fn format_status_flags(flags: Status) -> String {
226    let mut status = String::new();
227
228    if flags.contains(Status::INDEX_NEW) {
229        status.push('A');
230    } else if flags.contains(Status::INDEX_MODIFIED) {
231        status.push('M');
232    } else if flags.contains(Status::INDEX_DELETED) {
233        status.push('D');
234    } else if flags.contains(Status::INDEX_RENAMED) {
235        status.push('R');
236    } else if flags.contains(Status::INDEX_TYPECHANGE) {
237        status.push('T');
238    } else {
239        status.push(' ');
240    }
241
242    if flags.contains(Status::WT_NEW) {
243        status.push('?');
244    } else if flags.contains(Status::WT_MODIFIED) {
245        status.push('M');
246    } else if flags.contains(Status::WT_DELETED) {
247        status.push('D');
248    } else if flags.contains(Status::WT_TYPECHANGE) {
249        status.push('T');
250    } else if flags.contains(Status::WT_RENAMED) {
251        status.push('R');
252    } else {
253        status.push(' ');
254    }
255
256    status
257}
258
259impl GitRepository {
260    /// Runs a `git` CLI subcommand in the repository's working directory.
261    ///
262    /// Remote operations shell out to the user's `git` rather than using
263    /// libgit2's network transport so they work across all URL schemes (SSH,
264    /// HTTPS) and honour the user's existing authentication configuration
265    /// (`ssh-agent`, `~/.ssh/config`, credential helpers). The vendored libgit2
266    /// lacks a reliable SSH transport on some platforms. See issue #903.
267    fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
268        let workdir = self
269            .repo
270            .workdir()
271            .context("Cannot run git command: repository has no working directory")?;
272
273        std::process::Command::new("git")
274            .current_dir(workdir)
275            .args(args)
276            .output()
277            .context("Failed to execute git command")
278    }
279
280    /// Pushes the current branch to remote.
281    pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
282        info!(
283            "Pushing branch '{}' to remote '{}'",
284            branch_name, remote_name
285        );
286
287        // Shell out to `git push` so the push works across all URL schemes and
288        // uses the user's configured authentication. `--set-upstream` records
289        // the tracking branch in the same step. See [`Self::run_git`].
290        debug!("Pushing via git CLI to '{}'", remote_name);
291        let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
292
293        if output.status.success() {
294            info!(
295                "Successfully pushed branch '{}' to remote '{}'",
296                branch_name, remote_name
297            );
298            Ok(())
299        } else {
300            let stderr = String::from_utf8_lossy(&output.stderr);
301            let stderr = stderr.trim();
302            error!("Failed to push branch: {}", stderr);
303            anyhow::bail!(
304                "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
305            )
306        }
307    }
308
309    /// Checks if a branch exists on remote.
310    pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
311        debug!(
312            "Checking if branch '{}' exists on remote '{}'",
313            branch_name, remote_name
314        );
315
316        // Query the remote via `git ls-remote` so the lookup works across all
317        // URL schemes and uses the user's configured authentication. See
318        // [`Self::run_git`].
319        debug!("Listing remote refs via git CLI from '{}'", remote_name);
320        let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
321
322        if !output.status.success() {
323            let stderr = String::from_utf8_lossy(&output.stderr);
324            let stderr = stderr.trim();
325            error!("Failed to list remote refs: {}", stderr);
326            anyhow::bail!(
327                "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
328            )
329        }
330
331        // `git ls-remote --heads <remote> <branch>` emits one `<sha>\t<ref>`
332        // line per matching head. The branch argument is a glob pattern that
333        // matches on the ref tail, so compare the ref column exactly to avoid
334        // false positives like `refs/heads/foo/<branch>`.
335        let remote_branch_ref = format!("refs/heads/{branch_name}");
336        let stdout = String::from_utf8_lossy(&output.stdout);
337        let exists = stdout
338            .lines()
339            .filter_map(|line| line.split('\t').nth(1))
340            .any(|reference| reference == remote_branch_ref);
341
342        if exists {
343            info!(
344                "Branch '{}' exists on remote '{}'",
345                branch_name, remote_name
346            );
347        } else {
348            info!(
349                "Branch '{}' does not exist on remote '{}'",
350                branch_name, remote_name
351            );
352        }
353        Ok(exists)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    // ── format_status_flags ────────────────────────────────────────
362
363    #[test]
364    fn status_flags_new_index() {
365        let status = format_status_flags(Status::INDEX_NEW);
366        assert_eq!(status, "A ");
367    }
368
369    #[test]
370    fn status_flags_modified_index() {
371        let status = format_status_flags(Status::INDEX_MODIFIED);
372        assert_eq!(status, "M ");
373    }
374
375    #[test]
376    fn status_flags_deleted_index() {
377        let status = format_status_flags(Status::INDEX_DELETED);
378        assert_eq!(status, "D ");
379    }
380
381    #[test]
382    fn status_flags_wt_new() {
383        let status = format_status_flags(Status::WT_NEW);
384        assert_eq!(status, " ?");
385    }
386
387    #[test]
388    fn status_flags_wt_modified() {
389        let status = format_status_flags(Status::WT_MODIFIED);
390        assert_eq!(status, " M");
391    }
392
393    #[test]
394    fn status_flags_combined() {
395        let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
396        assert_eq!(status, "AM");
397    }
398
399    #[test]
400    fn status_flags_empty() {
401        let status = format_status_flags(Status::empty());
402        assert_eq!(status, "  ");
403    }
404
405    // ── GitRepository with temp repo ───────────────────────────────
406
407    /// Creates an empty git-inited tempdir anchored at `$CARGO_MANIFEST_DIR/tmp`.
408    ///
409    /// Centralising the setup avoids scattering four copies of the same
410    /// `?`-laced boilerplate across these tests, which also gives codecov a
411    /// single place to attribute coverage for the directory-creation
412    /// machinery.
413    #[allow(clippy::unwrap_used)]
414    fn init_tmp_repo() -> tempfile::TempDir {
415        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
416        std::fs::create_dir_all(&tmp_root).unwrap();
417        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
418        git2::Repository::init(temp_dir.path()).unwrap();
419        temp_dir
420    }
421
422    #[test]
423    fn open_at_temp_repo() -> Result<()> {
424        let temp_dir = init_tmp_repo();
425        let repo = GitRepository::open_at(temp_dir.path())?;
426        assert!(repo.path().exists());
427        Ok(())
428    }
429
430    #[test]
431    fn working_directory_clean_empty_repo() -> Result<()> {
432        let temp_dir = init_tmp_repo();
433        let repo = GitRepository::open_at(temp_dir.path())?;
434        let status = repo.get_working_directory_status()?;
435        assert!(status.clean);
436        assert!(status.untracked_changes.is_empty());
437        Ok(())
438    }
439
440    #[test]
441    fn working_directory_dirty_with_file() -> Result<()> {
442        let temp_dir = init_tmp_repo();
443        std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
444        let repo = GitRepository::open_at(temp_dir.path())?;
445        let status = repo.get_working_directory_status()?;
446        assert!(!status.clean);
447        assert!(!status.untracked_changes.is_empty());
448        Ok(())
449    }
450
451    #[test]
452    fn is_working_directory_clean_delegator() -> Result<()> {
453        let temp_dir = init_tmp_repo();
454        let repo = GitRepository::open_at(temp_dir.path())?;
455        assert!(repo.is_working_directory_clean()?);
456        Ok(())
457    }
458
459    // ── remote operations via the git CLI (issue #903) ─────────────
460
461    /// Runs `git` in `dir` with a deterministic identity, asserting success.
462    #[allow(clippy::unwrap_used)]
463    fn git_in(dir: &std::path::Path, args: &[&str]) {
464        let output = std::process::Command::new("git")
465            .current_dir(dir)
466            .args([
467                "-c",
468                "user.email=test@example.com",
469                "-c",
470                "user.name=Test",
471                // Disable signing so the tests stay hermetic regardless of the
472                // developer's global `commit.gpgsign` / `tag.gpgsign` config —
473                // GPG signing also races under parallel test execution.
474                "-c",
475                "commit.gpgsign=false",
476                "-c",
477                "tag.gpgsign=false",
478            ])
479            .args(args)
480            .output()
481            .unwrap();
482        let stderr = String::from_utf8_lossy(&output.stderr);
483        assert!(output.status.success(), "git {args:?} failed: {stderr}");
484    }
485
486    /// Builds a work repo with one commit on `feature-branch` and a bare
487    /// `origin` remote it can push to. Both temp dirs are returned so the
488    /// caller keeps them alive for the duration of the test.
489    #[allow(clippy::unwrap_used)]
490    fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
491        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
492        std::fs::create_dir_all(&tmp_root).unwrap();
493        let bare = tempfile::tempdir_in(&tmp_root).unwrap();
494        git_in(bare.path(), &["init", "--bare"]);
495
496        let work = init_tmp_repo();
497        std::fs::write(work.path().join("file.txt"), "content").unwrap();
498        git_in(work.path(), &["checkout", "-b", "feature-branch"]);
499        git_in(work.path(), &["add", "."]);
500        git_in(work.path(), &["commit", "-m", "initial"]);
501        git_in(
502            work.path(),
503            &["remote", "add", "origin", bare.path().to_str().unwrap()],
504        );
505
506        let repo = GitRepository::open_at(work.path()).unwrap();
507        (work, bare, repo)
508    }
509
510    #[test]
511    fn branch_absent_on_remote_before_push() -> Result<()> {
512        let (_work, _bare, repo) = repo_with_bare_remote();
513        assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
514        Ok(())
515    }
516
517    #[test]
518    fn push_branch_then_present_on_remote() -> Result<()> {
519        let (_work, _bare, repo) = repo_with_bare_remote();
520        repo.push_branch("feature-branch", "origin")?;
521        assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
522        assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
523        Ok(())
524    }
525
526    #[test]
527    fn branch_exists_requires_exact_ref_match() -> Result<()> {
528        // `git ls-remote <branch>` matches on the ref tail, so a sibling like
529        // `team/feature-branch` would glob-match `feature-branch`. The exact
530        // ref comparison must reject it as a false positive.
531        let (work, _bare, repo) = repo_with_bare_remote();
532        git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
533        repo.push_branch("team/feature-branch", "origin")?;
534        assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
535        assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
536        Ok(())
537    }
538
539    #[test]
540    fn push_branch_reports_failure_for_unknown_remote() {
541        let (_work, _bare, repo) = repo_with_bare_remote();
542        let result = repo.push_branch("feature-branch", "nonexistent");
543        assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
544    }
545
546    #[test]
547    fn branch_exists_reports_failure_for_unknown_remote() {
548        let (_work, _bare, repo) = repo_with_bare_remote();
549        let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
550        assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
551    }
552}