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    /// Resolves the default base branch for commit-range defaults.
138    ///
139    /// Prefers remote-tracking refs so the default range binds to the remote's
140    /// view of the mainline rather than a possibly-stale local branch:
141    /// `origin/main` → `origin/master` → `main` → `master`.
142    /// Returns `None` when none of these refs exist.
143    pub fn resolve_default_base_branch(&self) -> Option<String> {
144        const CANDIDATES: [(&str, git2::BranchType); 4] = [
145            ("origin/main", git2::BranchType::Remote),
146            ("origin/master", git2::BranchType::Remote),
147            ("main", git2::BranchType::Local),
148            ("master", git2::BranchType::Local),
149        ];
150        CANDIDATES
151            .iter()
152            .find(|(name, kind)| self.repo.find_branch(name, *kind).is_ok())
153            .map(|(name, _)| (*name).to_string())
154    }
155
156    /// Parses a commit range and returns the commits.
157    pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
158        let mut commits = Vec::new();
159
160        // Resolved once per invocation; containment is checked per commit.
161        let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
162
163        if range == "HEAD" {
164            // Single HEAD commit
165            let head = self.repo.head().context("Failed to get HEAD")?;
166            let commit = head
167                .peel_to_commit()
168                .context("Failed to peel HEAD to commit")?;
169            commits.push(CommitInfo::from_git_commit(
170                &self.repo, &commit, &main_tips,
171            )?);
172        } else if range.contains("..") {
173            // Range format like HEAD~3..HEAD
174            let parts: Vec<&str> = range.split("..").collect();
175            if parts.len() != 2 {
176                anyhow::bail!("Invalid range format: {range}");
177            }
178
179            let start_spec = parts[0];
180            let end_spec = parts[1];
181
182            // Parse start and end commits
183            let start_obj = self
184                .repo
185                .revparse_single(start_spec)
186                .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
187            let end_obj = self
188                .repo
189                .revparse_single(end_spec)
190                .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
191
192            let start_commit = start_obj
193                .peel_to_commit()
194                .context("Failed to peel start object to commit")?;
195            let end_commit = end_obj
196                .peel_to_commit()
197                .context("Failed to peel end object to commit")?;
198
199            // Walk from end_commit back to start_commit (exclusive)
200            let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
201            walker
202                .push(end_commit.id())
203                .context("Failed to push end commit")?;
204            walker
205                .hide(start_commit.id())
206                .context("Failed to hide start commit")?;
207
208            for oid in walker {
209                let oid = oid.context("Failed to get commit OID from walker")?;
210                let commit = self
211                    .repo
212                    .find_commit(oid)
213                    .context("Failed to find commit")?;
214
215                // Skip merge commits
216                if commit.parent_count() > 1 {
217                    continue;
218                }
219
220                commits.push(CommitInfo::from_git_commit(
221                    &self.repo, &commit, &main_tips,
222                )?);
223            }
224
225            // Reverse to get chronological order (oldest first)
226            commits.reverse();
227        } else {
228            // Single commit by hash or reference
229            let obj = self
230                .repo
231                .revparse_single(range)
232                .with_context(|| format!("Failed to parse commit: {range}"))?;
233            let commit = obj
234                .peel_to_commit()
235                .context("Failed to peel object to commit")?;
236            commits.push(CommitInfo::from_git_commit(
237                &self.repo, &commit, &main_tips,
238            )?);
239        }
240
241        Ok(commits)
242    }
243}
244
245/// Formats git status flags into a string representation.
246fn format_status_flags(flags: Status) -> String {
247    let mut status = String::new();
248
249    if flags.contains(Status::INDEX_NEW) {
250        status.push('A');
251    } else if flags.contains(Status::INDEX_MODIFIED) {
252        status.push('M');
253    } else if flags.contains(Status::INDEX_DELETED) {
254        status.push('D');
255    } else if flags.contains(Status::INDEX_RENAMED) {
256        status.push('R');
257    } else if flags.contains(Status::INDEX_TYPECHANGE) {
258        status.push('T');
259    } else {
260        status.push(' ');
261    }
262
263    if flags.contains(Status::WT_NEW) {
264        status.push('?');
265    } else if flags.contains(Status::WT_MODIFIED) {
266        status.push('M');
267    } else if flags.contains(Status::WT_DELETED) {
268        status.push('D');
269    } else if flags.contains(Status::WT_TYPECHANGE) {
270        status.push('T');
271    } else if flags.contains(Status::WT_RENAMED) {
272        status.push('R');
273    } else {
274        status.push(' ');
275    }
276
277    status
278}
279
280impl GitRepository {
281    /// Runs a `git` CLI subcommand in the repository's working directory.
282    ///
283    /// Remote operations shell out to the user's `git` rather than using
284    /// libgit2's network transport so they work across all URL schemes (SSH,
285    /// HTTPS) and honour the user's existing authentication configuration
286    /// (`ssh-agent`, `~/.ssh/config`, credential helpers). The vendored libgit2
287    /// lacks a reliable SSH transport on some platforms. See issue #903.
288    fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
289        let workdir = self
290            .repo
291            .workdir()
292            .context("Cannot run git command: repository has no working directory")?;
293
294        std::process::Command::new("git")
295            .current_dir(workdir)
296            .args(args)
297            .output()
298            .context("Failed to execute git command")
299    }
300
301    /// Pushes the current branch to remote.
302    pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
303        info!(
304            "Pushing branch '{}' to remote '{}'",
305            branch_name, remote_name
306        );
307
308        // Shell out to `git push` so the push works across all URL schemes and
309        // uses the user's configured authentication. `--set-upstream` records
310        // the tracking branch in the same step. See [`Self::run_git`].
311        debug!("Pushing via git CLI to '{}'", remote_name);
312        let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
313
314        if output.status.success() {
315            info!(
316                "Successfully pushed branch '{}' to remote '{}'",
317                branch_name, remote_name
318            );
319            Ok(())
320        } else {
321            let stderr = String::from_utf8_lossy(&output.stderr);
322            let stderr = stderr.trim();
323            error!("Failed to push branch: {}", stderr);
324            anyhow::bail!(
325                "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
326            )
327        }
328    }
329
330    /// Checks if a branch exists on remote.
331    pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
332        debug!(
333            "Checking if branch '{}' exists on remote '{}'",
334            branch_name, remote_name
335        );
336
337        // Query the remote via `git ls-remote` so the lookup works across all
338        // URL schemes and uses the user's configured authentication. See
339        // [`Self::run_git`].
340        debug!("Listing remote refs via git CLI from '{}'", remote_name);
341        let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
342
343        if !output.status.success() {
344            let stderr = String::from_utf8_lossy(&output.stderr);
345            let stderr = stderr.trim();
346            error!("Failed to list remote refs: {}", stderr);
347            anyhow::bail!(
348                "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
349            )
350        }
351
352        // `git ls-remote --heads <remote> <branch>` emits one `<sha>\t<ref>`
353        // line per matching head. The branch argument is a glob pattern that
354        // matches on the ref tail, so compare the ref column exactly to avoid
355        // false positives like `refs/heads/foo/<branch>`.
356        let remote_branch_ref = format!("refs/heads/{branch_name}");
357        let stdout = String::from_utf8_lossy(&output.stdout);
358        let exists = stdout
359            .lines()
360            .filter_map(|line| line.split('\t').nth(1))
361            .any(|reference| reference == remote_branch_ref);
362
363        if exists {
364            info!(
365                "Branch '{}' exists on remote '{}'",
366                branch_name, remote_name
367            );
368        } else {
369            info!(
370                "Branch '{}' does not exist on remote '{}'",
371                branch_name, remote_name
372            );
373        }
374        Ok(exists)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    // ── format_status_flags ────────────────────────────────────────
383
384    #[test]
385    fn status_flags_new_index() {
386        let status = format_status_flags(Status::INDEX_NEW);
387        assert_eq!(status, "A ");
388    }
389
390    #[test]
391    fn status_flags_modified_index() {
392        let status = format_status_flags(Status::INDEX_MODIFIED);
393        assert_eq!(status, "M ");
394    }
395
396    #[test]
397    fn status_flags_deleted_index() {
398        let status = format_status_flags(Status::INDEX_DELETED);
399        assert_eq!(status, "D ");
400    }
401
402    #[test]
403    fn status_flags_wt_new() {
404        let status = format_status_flags(Status::WT_NEW);
405        assert_eq!(status, " ?");
406    }
407
408    #[test]
409    fn status_flags_wt_modified() {
410        let status = format_status_flags(Status::WT_MODIFIED);
411        assert_eq!(status, " M");
412    }
413
414    #[test]
415    fn status_flags_combined() {
416        let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
417        assert_eq!(status, "AM");
418    }
419
420    #[test]
421    fn status_flags_empty() {
422        let status = format_status_flags(Status::empty());
423        assert_eq!(status, "  ");
424    }
425
426    // ── GitRepository with temp repo ───────────────────────────────
427
428    /// Creates an empty git-inited tempdir anchored at `$CARGO_MANIFEST_DIR/tmp`.
429    ///
430    /// Centralising the setup avoids scattering four copies of the same
431    /// `?`-laced boilerplate across these tests, which also gives codecov a
432    /// single place to attribute coverage for the directory-creation
433    /// machinery.
434    #[allow(clippy::unwrap_used)]
435    fn init_tmp_repo() -> tempfile::TempDir {
436        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
437        std::fs::create_dir_all(&tmp_root).unwrap();
438        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
439        git2::Repository::init(temp_dir.path()).unwrap();
440        temp_dir
441    }
442
443    #[test]
444    fn open_at_temp_repo() -> Result<()> {
445        let temp_dir = init_tmp_repo();
446        let repo = GitRepository::open_at(temp_dir.path())?;
447        assert!(repo.path().exists());
448        Ok(())
449    }
450
451    #[test]
452    fn working_directory_clean_empty_repo() -> Result<()> {
453        let temp_dir = init_tmp_repo();
454        let repo = GitRepository::open_at(temp_dir.path())?;
455        let status = repo.get_working_directory_status()?;
456        assert!(status.clean);
457        assert!(status.untracked_changes.is_empty());
458        Ok(())
459    }
460
461    #[test]
462    fn working_directory_dirty_with_file() -> Result<()> {
463        let temp_dir = init_tmp_repo();
464        std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
465        let repo = GitRepository::open_at(temp_dir.path())?;
466        let status = repo.get_working_directory_status()?;
467        assert!(!status.clean);
468        assert!(!status.untracked_changes.is_empty());
469        Ok(())
470    }
471
472    #[test]
473    fn is_working_directory_clean_delegator() -> Result<()> {
474        let temp_dir = init_tmp_repo();
475        let repo = GitRepository::open_at(temp_dir.path())?;
476        assert!(repo.is_working_directory_clean()?);
477        Ok(())
478    }
479
480    #[test]
481    fn current_branch_on_a_branch() -> Result<()> {
482        let temp_dir = init_tmp_repo();
483        let p = temp_dir.path();
484        std::fs::write(p.join("f.txt"), "x")?;
485        git_in(p, &["add", "."]);
486        git_in(p, &["commit", "-m", "init"]);
487        let repo = GitRepository::open_at(p)?;
488        // The branch name is whichever the local git default is (main/master);
489        // either way it must resolve to a non-"HEAD" shorthand.
490        assert_ne!(repo.get_current_branch()?, "HEAD");
491        Ok(())
492    }
493
494    #[test]
495    fn current_branch_errors_in_detached_head() -> Result<()> {
496        // CI checks PRs out as a detached HEAD, which is what makes the bail at
497        // the end of `get_current_branch` flicker run-to-run; pin it here.
498        let temp_dir = init_tmp_repo();
499        let p = temp_dir.path();
500        std::fs::write(p.join("f.txt"), "x")?;
501        git_in(p, &["add", "."]);
502        git_in(p, &["commit", "-m", "init"]);
503        git_in(p, &["checkout", "--detach", "HEAD"]);
504        let repo = GitRepository::open_at(p)?;
505        let result = repo.get_current_branch();
506        assert!(
507            matches!(&result, Err(e) if e.to_string().contains("detached HEAD")),
508            "expected detached-HEAD error, got: {result:?}"
509        );
510        Ok(())
511    }
512
513    // ── remote operations via the git CLI (issue #903) ─────────────
514
515    /// Runs `git` in `dir` with a deterministic identity, asserting success.
516    #[allow(clippy::unwrap_used)]
517    fn git_in(dir: &std::path::Path, args: &[&str]) {
518        let output = std::process::Command::new("git")
519            .current_dir(dir)
520            .args([
521                "-c",
522                "user.email=test@example.com",
523                "-c",
524                "user.name=Test",
525                // Disable signing so the tests stay hermetic regardless of the
526                // developer's global `commit.gpgsign` / `tag.gpgsign` config —
527                // GPG signing also races under parallel test execution.
528                "-c",
529                "commit.gpgsign=false",
530                "-c",
531                "tag.gpgsign=false",
532            ])
533            .args(args)
534            .output()
535            .unwrap();
536        let stderr = String::from_utf8_lossy(&output.stderr);
537        assert!(output.status.success(), "git {args:?} failed: {stderr}");
538    }
539
540    /// Builds a work repo with one commit on `feature-branch` and a bare
541    /// `origin` remote it can push to. Both temp dirs are returned so the
542    /// caller keeps them alive for the duration of the test.
543    #[allow(clippy::unwrap_used)]
544    fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
545        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
546        std::fs::create_dir_all(&tmp_root).unwrap();
547        let bare = tempfile::tempdir_in(&tmp_root).unwrap();
548        git_in(bare.path(), &["init", "--bare"]);
549
550        let work = init_tmp_repo();
551        std::fs::write(work.path().join("file.txt"), "content").unwrap();
552        git_in(work.path(), &["checkout", "-b", "feature-branch"]);
553        git_in(work.path(), &["add", "."]);
554        git_in(work.path(), &["commit", "-m", "initial"]);
555        git_in(
556            work.path(),
557            &["remote", "add", "origin", bare.path().to_str().unwrap()],
558        );
559
560        let repo = GitRepository::open_at(work.path()).unwrap();
561        (work, bare, repo)
562    }
563
564    #[test]
565    fn branch_absent_on_remote_before_push() -> Result<()> {
566        let (_work, _bare, repo) = repo_with_bare_remote();
567        assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
568        Ok(())
569    }
570
571    #[test]
572    fn push_branch_then_present_on_remote() -> Result<()> {
573        let (_work, _bare, repo) = repo_with_bare_remote();
574        repo.push_branch("feature-branch", "origin")?;
575        assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
576        assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
577        Ok(())
578    }
579
580    #[test]
581    fn branch_exists_requires_exact_ref_match() -> Result<()> {
582        // `git ls-remote <branch>` matches on the ref tail, so a sibling like
583        // `team/feature-branch` would glob-match `feature-branch`. The exact
584        // ref comparison must reject it as a false positive.
585        let (work, _bare, repo) = repo_with_bare_remote();
586        git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
587        repo.push_branch("team/feature-branch", "origin")?;
588        assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
589        assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
590        Ok(())
591    }
592
593    #[test]
594    fn push_branch_reports_failure_for_unknown_remote() {
595        let (_work, _bare, repo) = repo_with_bare_remote();
596        let result = repo.push_branch("feature-branch", "nonexistent");
597        assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
598    }
599
600    #[test]
601    fn branch_exists_reports_failure_for_unknown_remote() {
602        let (_work, _bare, repo) = repo_with_bare_remote();
603        let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
604        assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
605    }
606
607    // ── resolve_default_base_branch (issue #1106) ──────────────────
608
609    #[test]
610    fn resolve_default_base_prefers_origin_main_over_local() -> Result<()> {
611        let (work, _bare, repo) = repo_with_bare_remote();
612        git_in(work.path(), &["branch", "main"]);
613        // Pushing creates `refs/remotes/origin/main` in the work repo, so both
614        // the local and the remote-tracking branch exist; the remote must win.
615        repo.push_branch("main", "origin")?;
616        assert_eq!(
617            repo.resolve_default_base_branch(),
618            Some("origin/main".to_string())
619        );
620        Ok(())
621    }
622
623    #[test]
624    fn resolve_default_base_prefers_origin_master_over_local_main() -> Result<()> {
625        // Interleaved order: a remote-tracking `origin/master` outranks a
626        // (possibly stale) local `main`.
627        let (work, _bare, repo) = repo_with_bare_remote();
628        git_in(work.path(), &["branch", "main"]);
629        git_in(work.path(), &["branch", "master"]);
630        repo.push_branch("master", "origin")?;
631        assert_eq!(
632            repo.resolve_default_base_branch(),
633            Some("origin/master".to_string())
634        );
635        Ok(())
636    }
637
638    #[test]
639    fn resolve_default_base_uses_local_main_without_remote() -> Result<()> {
640        let temp_dir = init_tmp_repo();
641        let p = temp_dir.path();
642        std::fs::write(p.join("f.txt"), "x")?;
643        git_in(p, &["checkout", "-b", "main"]);
644        git_in(p, &["add", "."]);
645        git_in(p, &["commit", "-m", "init"]);
646        let repo = GitRepository::open_at(p)?;
647        assert_eq!(repo.resolve_default_base_branch(), Some("main".to_string()));
648        Ok(())
649    }
650
651    #[test]
652    fn resolve_default_base_falls_back_to_local_master() -> Result<()> {
653        let temp_dir = init_tmp_repo();
654        let p = temp_dir.path();
655        std::fs::write(p.join("f.txt"), "x")?;
656        git_in(p, &["checkout", "-b", "master"]);
657        git_in(p, &["add", "."]);
658        git_in(p, &["commit", "-m", "init"]);
659        let repo = GitRepository::open_at(p)?;
660        assert_eq!(
661            repo.resolve_default_base_branch(),
662            Some("master".to_string())
663        );
664        Ok(())
665    }
666
667    #[test]
668    fn resolve_default_base_none_without_mainline() -> Result<()> {
669        let temp_dir = init_tmp_repo();
670        let p = temp_dir.path();
671        std::fs::write(p.join("f.txt"), "x")?;
672        git_in(p, &["checkout", "-b", "dev"]);
673        git_in(p, &["add", "."]);
674        git_in(p, &["commit", "-m", "init"]);
675        let repo = GitRepository::open_at(p)?;
676        assert_eq!(repo.resolve_default_base_branch(), None);
677        Ok(())
678    }
679
680    // ── in_main_branches population (issue #1105) ──────────────────
681
682    #[test]
683    fn commits_in_range_report_main_branch_containment() -> Result<()> {
684        // Work repo on `main` with one pushed commit and one unpushed commit
685        // on top of it.
686        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
687        std::fs::create_dir_all(&tmp_root)?;
688        let bare = tempfile::tempdir_in(&tmp_root)?;
689        git_in(bare.path(), &["init", "--bare"]);
690
691        let work = init_tmp_repo();
692        let p = work.path();
693        git_in(p, &["checkout", "-b", "main"]);
694        std::fs::write(p.join("a.txt"), "pushed")?;
695        git_in(p, &["add", "."]);
696        git_in(p, &["commit", "-m", "pushed commit"]);
697        #[allow(clippy::unwrap_used)]
698        git_in(
699            p,
700            &["remote", "add", "origin", bare.path().to_str().unwrap()],
701        );
702        git_in(p, &["push", "origin", "main"]);
703        std::fs::write(p.join("b.txt"), "unpushed")?;
704        git_in(p, &["add", "."]);
705        git_in(p, &["commit", "-m", "unpushed commit"]);
706
707        let repo = GitRepository::open_at(p)?;
708        // Single-rev path: the pushed commit is contained in origin/main.
709        let pushed = repo.get_commits_in_range("HEAD~1")?;
710        assert_eq!(pushed.len(), 1);
711        assert_eq!(pushed[0].in_main_branches, vec!["origin/main".to_string()]);
712        // Range path: the unpushed commit on top is not contained.
713        let unpushed = repo.get_commits_in_range("HEAD~1..HEAD")?;
714        assert_eq!(unpushed.len(), 1);
715        assert!(unpushed[0].in_main_branches.is_empty());
716        Ok(())
717    }
718
719    #[test]
720    fn commits_in_range_empty_containment_without_remotes() -> Result<()> {
721        let work = init_tmp_repo();
722        let p = work.path();
723        std::fs::write(p.join("a.txt"), "x")?;
724        git_in(p, &["add", "."]);
725        git_in(p, &["commit", "-m", "local only"]);
726
727        let repo = GitRepository::open_at(p)?;
728        let commits = repo.get_commits_in_range("HEAD")?;
729        assert_eq!(commits.len(), 1);
730        assert!(commits[0].in_main_branches.is_empty());
731        Ok(())
732    }
733}