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